I'll Be Backend
Principles · Rust

Abstraction: hide the data structure

Change how a class stores its data without touching the code that uses it.

12 steps4 chapters~5 min at 1.25×#oop#abstraction#data structure#private fields
The lesson plays right here. Pause anywhere, step back, or jump to a step below. Watch the Rust video on YouTube Code on GitHub

Watch the narrated video

Every step

Problem

Steps 1–3

Refactor

Steps 4–8

Payoff

Steps 9–10

Recap

Steps 11–12

The code: before and after

Before

mod warehouse {
    use std::collections::HashMap;

    #[derive(Default)]
    pub struct Warehouse {
        pub stock: HashMap<String, u32>,
    }
}

use warehouse::Warehouse;

fn restock(w: &mut Warehouse, sku: &str, qty: u32) {
    *w.stock.entry(sku.to_string()).or_insert(0) += qty;
}

fn pick_order(w: &mut Warehouse, sku: &str, qty: u32) {
    let have = w.stock.get(sku).copied().unwrap_or(0);
    if have >= qty {
        w.stock.insert(sku.to_string(), have - qty);
    }
}

fn print_report(w: &Warehouse, sku: &str) {
    let left = w.stock.get(sku).copied().unwrap_or(0);
    println!("{sku}: {left}");
}

fn main() {
    let mut w = Warehouse::default();
    restock(&mut w, "TEA-1", 20);
    restock(&mut w, "TEA-1", 10);
    pick_order(&mut w, "TEA-1", 15);
    print_report(&w, "TEA-1"); // TEA-1: 15
}

After

mod warehouse {
    use std::collections::{HashMap, VecDeque};

    #[derive(Default)]
    pub struct Warehouse {
        stock: HashMap<String, VecDeque<u32>>,
    }

    impl Warehouse {
        pub fn receive(&mut self, sku: &str, qty: u32) {
            self.stock.entry(sku.to_string()).or_default().push_back(qty);
        }

        pub fn pick(&mut self, sku: &str, qty: u32) -> bool {
            if self.quantity(sku) < qty {
                return false;
            }
            let queue = self.stock.entry(sku.to_string()).or_default();
            let mut need = qty;
            while need > 0 {
                let oldest = queue.front_mut().unwrap();
                let used = (*oldest).min(need);
                *oldest -= used;
                need -= used;
                if *oldest == 0 {
                    queue.pop_front();
                }
            }
            true
        }

        pub fn quantity(&self, sku: &str) -> u32 {
            self.stock.get(sku).map_or(0, |q| q.iter().sum())
        }

        pub fn deliveries(&self, sku: &str) -> Vec<u32> {
            self.stock.get(sku).into_iter().flatten().copied().collect()
        }
    }
}

use warehouse::Warehouse;

fn restock(w: &mut Warehouse, sku: &str, qty: u32) {
    w.receive(sku, qty);
}

fn pick_order(w: &mut Warehouse, sku: &str, qty: u32) {
    w.pick(sku, qty);
}

fn print_report(w: &Warehouse, sku: &str) {
    println!("{sku}: {}", w.quantity(sku));
}

fn main() {
    let mut w = Warehouse::default();
    restock(&mut w, "TEA-1", 20);
    restock(&mut w, "TEA-1", 10);
    pick_order(&mut w, "TEA-1", 15);
    print_report(&w, "TEA-1"); // TEA-1: 15
    println!("{:?}", w.deliveries("TEA-1")); // [5, 10]
}

Transcript, step by step

Problem

Step 1: A shop's warehouse stock Before

A small shop's warehouse keeps its stock in a public map, from product code to quantity. The warehouse lives in its own module, and the pub on the field lets code outside that module reach the map. Restock adds a delivery to the map, Pick Order takes items out when there are enough, and Print Report shows what's left. Two deliveries of tea come in, 20 and then 10, and after 15 are picked, the report prints 15.

Step 2: Oldest delivery first Code smell

The tea goes stale, so picking must take the oldest delivery first, and ops need the stock kept per delivery. The quick way turns each value in the map into a VecDeque of delivery quantities, used as a queue. Restock now pushes to the back of the queue, and Pick Order sums it, then takes from the front in a loop. Print Report sums the queue too, and the run still prints 15.

Step 3: Every caller knows the map Code smell

Every caller works on the map directly, so each one depends on how the warehouse stores its stock. Restock uses the map here, Pick Order here, and the report here. One change to the storage meant rewriting three functions that only wanted to receive, pick and count. The sum over the queue now exists twice, once in Pick Order and once in Print Report. And when the stock moves to a database next year, all three get rewritten again.

Refactor

Step 4: Refactor first Back it out

Let's take the queue out again. We'll refactor first, so that the warehouse owns its stock, and then we'll change the storage in one place.

Step 5: receive moves in Move Method

First, the update moves into an impl block for Warehouse, as a method called receive. It's marked pub, so code outside the module can call it, and restock no longer touches the map.

Step 6: pick moves in Move Method

The check and the update move in next, as pick, which returns true when there was enough stock. Pick Order calls pick on the warehouse, and the run still prints 15.

Step 7: quantity moves in Move Method

The lookup moves in last, as quantity, which returns zero for a product the map has never seen. Print Report asks the warehouse for the quantity, and then prints it.

Step 8: Make the map private Encapsulate Collection

No caller touches the map any more, so we drop pub from the field, which makes it private. Rust keeps a private field inside its module, so the compiler stops any code outside the warehouse module from reaching it. That's abstraction, where callers use what the warehouse does, receive, pick and quantity, and never see how it stores the stock. Encapsulation hides a single field, and abstraction goes one step further, because it hides how a whole collection is stored.

Payoff

Step 9: Keep stock per delivery Substitute Algorithm

Now the change request comes back, and this time it stays inside the warehouse module. The private map now holds a VecDeque of deliveries per product, and receive pushes to its back. Quantity sums the queue, and pick checks the quantity, then takes from the oldest delivery first. Each method keeps its signature and gets a new body, and that move is called Substitute Algorithm. The three functions didn't change, and main gains one line that prints the deliveries, from a new vector that callers can't use to change the stock.

Step 10: One module knows the storage Payoff

The map calls are gone from the callers, so restock calls receive here, Pick Order calls pick here, and the report calls quantity here. The sum over the queue is written once, in quantity, and pick reuses it for its check. The 15 picked came out of the oldest delivery of 20, so 5 of that one are left, next to the newer 10. The three callers depend only on receive, pick and quantity, so changing how Warehouse stores the stock stayed inside its module.

Recap

Step 11: Where we started, where we ended Before / after

On the left, the stock is a public map, and all three functions call map methods on it. On the right, the map is private and holds a queue per product, behind receive, pick and quantity. A caller like Pick Order makes one call, and the warehouse decides which delivery the items come from.

Step 12: When to reach for it Takeaways

Reach for it when several functions or types read or change one collection, and the way it's stored may change. Skip it when one short function owns the map and nothing outside reads it, because methods around it add lines and hide nothing. You'll spot the need by get, insert or entry calls on another type's map, or by the same lookup copied into several functions. Rust's own String works this way, because you call push string, and it grows its private byte vector by itself.