Skip to content

← Ownership I: Moves, Copy, Clone, Drop step 6 of 20

Easy Primitives

Clone: explicit, deep, and never free

Run a batch of names through a two-stage pipeline and return the event log.

pub fn pipeline(names: Vec<String>) -> Vec<String>

Beginners do one of two things with .clone(). Either they clone reflexively whenever the compiler complains, and ship quadratic code without noticing — or they refuse to clone on principle, and stall for hours on a problem that a clone would have solved in ten seconds. Both come from the same gap: not knowing what a clone costs for a specific type. This problem replaces the taboo with a price list, and it does it by making every clone visible in the output.

The price list

Type What .clone() does Cost
i64, bool, char, &T copies the bytes free — and clippy will tell you off for asking
String, Vec<T> one allocation, one memcpy of the whole buffer O(len)
Vec<String> one allocation for the Vec, plus one per element O(total bytes)
Rc<T> / Arc<T> bumps a reference count O(1); no data is duplicated
HashMap<K, V> allocates and clones every key and every value O(entries)

The two rows that surprise people are the last two. Rc::clone is cheap by design — it is how you share ownership on purpose — while cloning a collection of owned values is deep, all the way down, every time. A .clone() inside a loop over a Vec<String> is quietly quadratic, and nothing in the toolchain will warn you: clippy::redundant_clone is a nursery lint, off by default.

That is the honest situation. Rust makes clones explicit so you can see them, not because the compiler polices them.

The scaffolding

The grader injects a small type. You cannot edit it, and you do not need to import anything to use it:

type Log = Rc<RefCell<Vec<String>>>;

struct Tracked { name: String, log: Log }

impl Tracked {
    fn new(name: String, log: Log) -> Tracked {   // logs "new <name>"
}

impl Clone for Tracked {
    fn clone(&self) -> Tracked {                  // logs "clone <name>"
}

impl Drop for Tracked {
    fn drop(&mut self) {                          // logs "drop <name>"
}

fn new_log() -> Log;
fn snapshot(log: Log) -> Vec<String>;
fn measure(t: Tracked) -> (Tracked, usize);       // logs "measure <name>"
fn mark(t: Tracked, note: String) -> Tracked;     // logs "mark <name> <note>"

Drop is a trait with one method, and Rust runs it automatically when a value’s owner goes out of scope. Wiring it to a log turns “when did this die?” into something a test can assert — which is how the rest of this track is graded.

Note that Clone here is hand-written, not derived, and it logs. That is the point of the exercise: none of the expected logs contain a clone line. If you add a clone to appease the compiler, the test tells you, immediately, in the diff.

What to build

let log = new_log();

{
    let mut alive = Vec::new();

    for name in names {
        // 1. build a Tracked from the name
        // 2. run it through `measure`, keeping the count it returns
        // 3. run it through `mark` with that count as the note
        // 4. keep the result in `alive`
    }
}   // <- `alive` dies here, and so does everything in it

snapshot(log)

For ["ada", "bo"] the log is:

new ada, measure ada, mark ada 3, new bo, measure bo, mark bo 2, drop ada, drop bo

Two details worth noticing in that output. Everything is created and processed one name at a time, but nothing is destroyed until the inner block ends — the Vec owns the values, so they live exactly as long as it does. And when the Vec finally dies, its elements drop front to back, in the order they were pushed. (Plain let locals drop in the opposite order. That contrast gets its own problem later in the track.)

::: question The starter throws away half of what measure returns and then asks for the value again. Why is .clone() the wrong repair? Because measure already gives the value back. Its signature is

fn measure(t: Tracked) -> (Tracked, usize)

so the fix is to catch both halves:

let (t, n) = measure(t);

That re-binds the name t to the value that came out of the call — a move in, a move out, zero allocations, and the same Tracked throughout, so the log stays clean.

measure(t.clone()) also compiles. It allocates a second String for the name, a second Rc handle for the log, produces a clone ada line in the output, and then destroys the copy — all to compute a number the function was already handing you. This is the “returns what it consumed” pattern from the previous problem in a slightly better disguise, and spotting it is most of what separates clone-happy Rust from the other kind. :::

Remember the grade is compile + tests + clippy -D warnings.

Loading visualization…