</>PatchNote
Back to posts

2024-07-07

HSPACE Rust Lectures #3 — Ownership and Moves

RustHSPACE 2024OwnershipMemory

Session one covered ownership in a paragraph; this one spends the whole session on it. "I only assigned the value — why can't I use the original anymore?" got answered here.

Ownership forms a tree

A value has exactly one owner, and it's dropped when that owner goes out of scope.

fn print_padovan() {
    let mut padovan = vec![1, 1, 1];  // allocated here

    for i in 3..10 {
        let next = padovan[i - 3] + padovan[i - 2];
        padovan.push(next);
    }

    println!("P(1..10) = {:?}", padovan);
}  // dropped here

padovan is a stack value of (pointer, length, capacity) with its elements on the heap. When the function returns, dropping padovan frees the heap buffer with it. No free() call, and yet the shape of the code tells you exactly where the release happens.

What matters is that ownership nests:

struct Person { name: String, birth: i32 }

let mut composers = Vec::new();
composers.push(Person { name: "Palestrina".to_string(), birth: 1525 });
composers.push(Person { name: "Dowland".to_string(),    birth: 1563 });
composers.push(Person { name: "Lully".to_string(),      birth: 1632 });

The ownership relation looks like this:

composers (Vec<Person>)
 ├─ Person { name: String → heap buffer, birth }
 ├─ Person { name: String → heap buffer, birth }
 └─ Person { name: String → heap buffer, birth }

Dropping composers drops each Person, which drops each name, which frees each string buffer. Ownership is a tree, not a graph — release the root and everything under it is cleaned up. Same idea as C++ RAII, except here the language enforces the tree shape.

Moves

In most languages, assignment is either a copy or a shared reference. Rust has a third option: a move.

let s = vec!["udon".to_string(), "ramen".to_string(), "soba".to_string()];
let t = s;   // ownership moves to t
let u = s;   // error: s has already been moved

Since a value has one owner, t = s leaves s uninitialized. If you really want three vectors, you pay for the copies explicitly:

let t = s.clone();
let u = s.clone();

The design means deep copies are visible in the source — the opposite of C++, where a value assignment can silently trigger a heap copy and leak performance.

Everywhere a move happens

Assignment isn't the only one:

  • assigning to a variable (let t = s;)
  • passing an argument to a function
  • returning a value from a function
  • putting a value into a struct or vector (composers.push(person))

Assigning over a variable that already holds a value drops the old one in place.

let mut s = "Govinda".to_string();
s = "Siddhartha".to_string();   // "Govinda" dropped here

let mut s = "Govinda".to_string();
let t = s;                      // "Govinda" moves to t
s = "Siddhartha".to_string();   // nothing dropped — s was empty

The second case is the interesting one. s was already moved out, so there's nothing to drop on reassignment. The compiler is tracking whether each variable currently holds a value.

Moves and control flow

With branches and loops, the compiler checks the rules on every path.

let x = vec![10, 20, 30];

if c {
    f(x);   // OK to move from x here...
} else {
    g(x);   // ...and OK to move from x here
}

h(x);       // error: whichever path we took, x is already moved
let x = vec![10, 20, 30];

while cond() {
    g(x);   // error: moved in the first iteration, uninitialized in the second
}

To move a value inside a loop, you have to give the variable a fresh value each time:

let mut x = vec![10, 20, 30];

while f() {
    g(x);       // move from x
    x = h();    // give x a fresh value
}

e(x);

The compiler is checking "does x hold a value at the top of every iteration?" It's flow analysis, not a runtime check, so it costs nothing.

You can't move out of indexed content

This one catches you constantly.

let mut v = Vec::new();
for i in 101..106 {
    v.push(i.to_string());
}

// let third = v[2];   // error: cannot move out of index of Vec

Why not? Moving v[2] out would leave the vector with a hole at index 2 — and a vector has no way to represent that state, so it couldn't know whether to free that slot later. Getting a value out of a collection therefore means not leaving a hole:

// 1. pop from the end
let fifth = v.pop().expect("vector empty!");
assert_eq!(fifth, "105");

// 2. take it out and move the last element into its spot
let second = v.swap_remove(1);
assert_eq!(second, "102");

// 3. swap in a replacement
let third = std::mem::replace(&mut v[2], "substitute".to_string());
assert_eq!(third, "103");

assert_eq!(v, vec!["101", "104", "substitute"]);

All three preserve the "no holes" invariant. std::mem::replace in particular turns out to be a tool you reach for often.

To consume the whole collection, iterate by value:

let v = vec!["liberté".to_string(), "égalité".to_string(), "fraternité".to_string()];

for mut s in v {    // all of v moves into the loop
    s.push('!');
    println!("{s}");
}
// v is unusable from here on

This is where the three loop forms finally separated in my head: for s in &v (borrow), for s in &mut v (mutable borrow), for s in v (move).

Copy types: the exception to moves

Moves have an exception.

// assigning a String "moves"
let string1 = "somnambulance".to_string();
let string2 = string1;      // string1 is now unusable

// assigning an i32 "copies"
let num1: i32 = 36;
let num2 = num1;            // num1 is still valid

A Copy type survives being passed. The condition is simple: a bitwise copy must produce a complete, independent value. So integers, bool, char, floats, and tuples/arrays made only of Copy types qualify. Anything owning heap resources — String, Vec, Box — cannot.

Structs and enums are not Copy by default, even when all their fields are.

struct Label { number: u32 }

fn print(l: Label) { println!("STAMP: {}", l.number); }

let l = Label { number: 3 };
print(l);
// println!("{}", l.number);   // error: value used after move

You opt in explicitly:

#[derive(Clone, Copy)]
struct Label { number: u32 }

let l = Label { number: 3 };
print(l);
println!("My label number is: {}", l.number);   // OK

Defaulting to "move" is the right call, because being Copy is a promise in your public API. Add a String field later and you must drop Copy, which is a breaking change. Better not to hand it out automatically.

Rc and Arc: shared ownership

Some relationships don't fit a tree: several places need to own the same value, and which one goes last isn't knowable at compile time. That's what reference counting is for.

use std::rc::Rc;

let s: Rc<String> = Rc::new("shirataki".to_string());
let t: Rc<String> = s.clone();
let u: Rc<String> = s.clone();

assert!(s.contains("shira"));
assert_eq!(t.find("taki"), Some(5));
println!("{u} are quite chewy, almost bouncy, but lack flavor");

Rc::clone doesn't copy the string — it bumps a reference count. The value is freed when the last Rc is dropped.

There's a price:

// a value owned by an Rc is immutable
// s.push_str(" noodles");   // error

With multiple owners, letting any of them mutate would break the sharing rules, so Rc<T> only hands out &T. Mutating the inside means composing with interior mutability, e.g. Rc<RefCell<T>> — which moves the borrow check to runtime (and panics on violation).

And the classic reference-counting trap: cycles never get freed. Two Rcs pointing at each other keep the count above zero and leak; you break the cycle with Weak. It's a good illustration that what Rust prevents is memory unsafety (use-after-free, double free) — a leak is classified as safe behavior.

The only difference between Rc and Arc is whether the counter is atomic.

use std::sync::Arc;

let arc1 = Arc::new("Hyundai".to_string());
let arc2 = arc1.clone();

thread::spawn(move || {
    let _ = arc2.clone();   // OK
});

Rc uses a non-atomic counter — faster, but it can't cross a thread boundary (it isn't Send). Arc uses an atomic one — slightly slower, shareable across threads. And there's no way to mix them up, because handing an Rc to a thread doesn't compile.

Takeaways

  • ownership forms a tree; dropping the root cleans up everything beneath it
  • assignment, argument passing, returning, and collection inserts are all moves, and deep copies show up as .clone()
  • across branches, "moved at most once on every path" is checked statically
  • collections can't have holes, hence pop, swap_remove, mem::replace
  • bitwise-copyable → Copy; multiple owners needed → Rc/Arc

Next: references — the other side, where you borrow a value instead of transferring ownership.