2024-07-07
HSPACE Rust Lectures #3 — Ownership and Moves
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.