Predict the Move on Line 4 Before You Run the Compiler
The interviewer writes eight lines on the shared editor, does not run them, and asks: "what does the compiler say?" Freezing at the error message is the failure mode; the pass comes from tracing moves on paper with a rule set precise enough to predict the error before it appears.
Start with the mechanism. Assignment of a non-Copy value moves ownership: the source binding becomes unusable, not merely temporarily borrowed. Copy types (integers, bool, char, &T, arrays and tuples of Copy members) are duplicated instead. The full trigger list you should rehearse:
| Situation | Moves? |
|---|---|
let b = a; where a is String, Vec, or any struct without Copy |
Yes |
Passing by value: takes_ownership(s) |
Yes, into the function |
| Returning a value | Moved to the caller (implicitly, always) |
Reading from a struct field (self.name) |
Moves the field only — a partial move |
Method taking self or self.by_value |
Moves self / that field |
Anything behind & or &mut |
No — only the reference moves, which is fine |
Now trace a real snippet. The interviewer's classic:
fn main() {
let name = String::from("Ada");
let greeting = format!("hello, {name}");
println!("{greeting}");
let moved = name;
println!("{moved}");
let borrowed = &name;
println!("{borrowed}");
}
Walk it. Line 2 builds a String — heap-allocated, not Copy, one owner. Line 3's format! borrows name through the format argument, so name is still usable at line 4. Line 4 moves name into moved: legal, but name is dead from here. Line 6 is the compile error — borrow of moved value: name — and the compiler's help text points back to line 4 as "move occurs because name has type String, which does not implement the Copy trait." Line 7 never matters; it would be a use-after-move too. The correct answer: compilation fails at the first println! after line 4, and the fix depends on intent — name.clone() if both strings are needed, a borrow (let moved = &name;) if not, or reordering the use before the move.
The shape interviewers loop on is the "borrow after the function call" trap:
fn eat(snack: String) { println!("eating {snack}"); }
fn main() {
let cookie = String::from("oatmeal");
eat(cookie);
println!("saved: {cookie}"); // error here
}
eat takes String by value, so ownership crosses the call boundary and the cookie is gone when eat returns. Two idiomatic repairs, and naming both shows judgment: change the signature to eat(snack: &String) (better: &str, which accepts both String and &str) so the caller keeps ownership, or pass &cookie at the call site. The deeper pattern is that move errors are almost always signature errors: a function that does not consume its argument should not take it by value.
Drill method that works under pressure: mark every binding with its type, box every non-Copy value, and draw an arrow wherever the box changes hands. When the arrows stop and a name is still referenced, that line is the error. The reflex this builds is the same one Production Crate Baseline demands of your tooling — know the failure before it happens — but here the tool is your head, not clippy. Practice on the move-heavy material in Move Semantics and Partial Moves until the trace takes fifteen seconds — the interviewer is testing whether ownership is reflex, and a paper trace at that speed says it is.
Why a Struct Holding a Reference Needs a Lifetime Parameter
Here is the question that separates people who memorized lifetime syntax from people who understand it: "this struct fails with missing lifetime specifier — why does the definition need a parameter when functions often do not?"
The failing code:
struct Extractor {
source: &str,
pos: usize,
}
The error is missing lifetime specifier, help text suggesting &'a str. The reason is that a function's arguments can have their lifetimes related by elision rules; a struct's fields declare a relationship that no elision rule can infer, because the struct is a bare type used anywhere, with no surrounding function signature to anchor it.
Unpack both halves. The reference inside Extractor is a borrow, and every borrow has a lifetime — the span during which the referent is guaranteed alive. A reference type without a lifetime annotation still has one; elision just fills it in. The three elision rules (input lifetimes → output lifetime: exact rule order matters) apply to fn signatures: one input lifetime takes it for the return, &self takes the receiver's lifetime for methods. fn first_word(s: &str) -> &str compiles because elision proves the returned slice borrows from the single argument.
A struct defeats all of that. Write the generic form and the problem is visible:
struct Extractor<'a> {
source: &'a str,
pos: usize,
}
impl<'a> Extractor<'a> {
fn new(source: &'a str) -> Self { Extractor { source, pos: 0 } }
fn next_token(&mut self) -> Option<&'a str> {
let rest = &self.source[self.pos..];
let end = rest.find(' ')? + self.pos;
let start = self.pos;
self.pos = end + 1;
Some(&self.source[start..end])
}
}
fn main() {
let line = String::from("tokenize me please");
let mut ex = Extractor::new(&line);
while let Some(tok) = ex.next_token() {
println!("{tok}");
}
}
What does <'a> on the struct mean? Every Extractor value is tagged with the lifetime of the string it points into, and the borrow checker will not let that string die while the Extractor lives. If line were dropped before ex, the struct would hold a dangling pointer — exactly the use-after-free the whole language design exists to prevent. The annotation makes the guarantee explicit and checkable.
The interview-grade follow-up is the variance question: what stops someone from writing Extractor<'static> over a local String and then dropping it? Nothing stops the declaration — &'static str is a real type — but Extractor::new(&line) requires the borrow to live for 'static, and a local String does not, so the constructor call is rejected. Lifetimes on structs are constraints on construction, not flags you set.
Two supporting rules candidates should state without prompting. First, a type that owns its data needs no lifetime: replacing source: &str with source: String (or Cow<'a, str> when both are wanted) erases the parameter and is the right design when the struct should outlive the original buffer — the compiler error on the borrowed version is exactly the nudge toward that redesign. Second, &'a self in a method means the returned borrow is tied to the struct's lifetime parameter, not to the &self borrow — which is why next_token can return &'a str that outlives the &mut self call itself, and why a &'a self method can safely hand back a slice of the original source even though the &mut self guard is released the moment the call returns. That distinction (&self's anonymous lifetime vs the struct's 'a) is the exact concept tested by Lifetime Syntax, Lifetime Elision, and Struct Lifetime Params. Answer with the guarantee — "the struct cannot outlive its borrow" — then the mechanism, and the question is closed.
Box, Rc, Arc, RefCell: Answer the Four-Way Comparison Cold
"Compare Box, Rc, and Arc" is the ownership question with the highest signal-per-minute in Rust interviews: one table demonstrates heap allocation, shared ownership, thread safety, and interior mutability in ninety seconds. Deliver it as a decision procedure, not a list.
| Type | Owners | Threads? | Extra cost | Reach for it when |
|---|---|---|---|---|
Box<T> |
exactly one | T: Send ⇒ yes |
none (allocation only) | One owner, fixed size at compile time, or trait object behind dyn Trait |
Rc<T> |
many, count in a control block | no (!Send) |
non-atomic count, no barriers | Single-threaded shared ownership — ASTs, caches, parent pointers |
Arc<T> |
many, same control block | yes (T: Send + Sync) |
atomic refcount, contention on clone/drop | Cross-thread shared ownership — thread pools, connection registries |
RefCell<T> |
one | !Sync |
flag check on every borrow | Mutation through a shared reference when you know the borrows never overlap |
The axis that matters: Box moves the value to the heap but keeps single ownership; Rc/Arc move it to the heap and share it via a count in the control block. When the count hits zero the value drops — which is why Rc's count cannot be atomic: Rc declares itself single-threaded (!Send) precisely so it can use cheap non-atomic increments. Arc uses AtomicUsize (Relaxed ordering suffices — the count synchronizes nothing) and pays for it with cache-line traffic on every clone and drop. That cost is the honest answer to "when would you not use Arc?": in a hot single-threaded loop, Rc clones measurably faster, and you pay for Arc you cannot benefit from.
Shared mutation is the second half, and neither Rc nor Arc provides it — they hand out shared references only. RefCell<T> supplies the missing piece by moving the borrow check from compile time to runtime: it stores a borrow flag (an isize counting active shared borrows, negative for a unique borrow) and its &self methods check it on every access.
use std::cell::RefCell;
use std::rc::Rc;
let shared = Rc::new(RefCell::new(vec![1, 2, 3]));
let a = Rc::clone(&shared);
let b = Rc::clone(&shared);
a.borrow_mut().push(4);
b.borrow_mut().push(5); // fine — sequential, not overlapping
println!("{:?}", shared.borrow()); // [1, 2, 3, 4, 5]
This is the canonical single-threaded shared-mutability pattern: Rc<RefCell<T>> — Rc for many owners, RefCell for mutation through those owners. The cross-thread counterpart is Arc<Mutex<T>>: Mutex's lock is the runtime check that replaces RefCell's flag.
Then name the cost of runtime checking — the question that separates strong answers: overlapping borrows panic instead of failing to compile. borrow_mut() while another borrow_mut() is live panics with already mutably borrowed: BorrowMutError; a borrow() during an active mutable borrow panics with already borrowed: BorrowMutError. The panic messages literally name the operation that lost the race. Panic is the right default — corrupting memory is not an option — but it converts a design bug into a production crash, which is why the borrow must be short: hold borrow() across one expression, never across an await or an untrusted callback, and prefer try_borrow() when overlap is a reachable condition you want to handle.
Close by connecting the axes: Rc fails Send, RefCell fails Sync, so Rc<RefCell<T>> is confined to one thread by construction — the type system encodes the restriction the runtime check assumes. The cross-thread service world where those guarantees get exercised is exactly what Async and Concurrency Interview Drills builds on.
Quiz
1. ```rust fn main() { let v = vec![1, 2, 3]; let n = v.len(); let w = v; println!("{n} {}", v.len()); } ``` What does the compiler report, and why?
2. Why does `struct Extractor { source: &str }` fail to compile while `fn first_word(s: &str) -> &str` succeeds without annotations?
3. A single-threaded cache needs many owners of one value, and code must also mutate it through those owners. Which combination is correct, and what is its residual risk?
4. What is the primary performance difference between Rc<T> and Arc<T> when shared across one thread's code?
Flashcards
Question
What is the complete trigger list for a move (vs a copy or a borrow)?
Click to reveal answer
Answer
Assignment of a non-Copy value (`let b = a;`), passing by value into a function, returning a value (moved to caller), reading a struct field by value (partial move), and methods taking self by value. Copy types (integers, bool, char, &T, aggregates of Copy) are duplicated instead, and anything behind & or &mut only moves the reference.
Question
Why does a struct containing a reference require a lifetime parameter, and what does it guarantee?
Click to reveal answer
Answer
Elision rules can only relate lifetimes present in a fn signature; a struct used standalone has no signature to infer from, so it must declare `<'a>` (e.g. `struct Extractor<'a> { source: &'a str }`). The guarantee: every value of that type is tagged with the lifetime of its borrow, and the borrow checker refuses to let the referenced data be dropped while the struct is alive — preventing dangling pointers by construction.
Question
Compare Box, Rc, and Arc across owner count, thread safety, and cost.
Click to reveal answer
Answer
Box<T>: one owner, Send if T is Send, zero overhead beyond allocation — for single ownership or dyn Trait. Rc<T>: many owners via a non-atomic count, !Send — single-threaded sharing. Arc<T>: many owners via an atomic count, Send+Sync when T is — cross-thread sharing, paying atomic operations on every clone and drop.
Question
How does RefCell shift borrow checking, and what do its two panic messages mean?
Click to reveal answer
Answer
RefCell stores a borrow flag checked at runtime by its &self methods, allowing mutation through a shared reference when borrows provably don't overlap. Calling borrow_mut() during any active borrow panics 'already mutably borrowed: BorrowMutError'; calling borrow() during an active mutable borrow panics 'already borrowed: BorrowMutError'. try_borrow()/try_borrow_mut() return Result instead of panicking.
Question
Why is Rc<RefCell<T>> confined to one thread, and what is its cross-thread counterpart?
Click to reveal answer
Answer
Rc is !Send (non-atomic count, no synchronization) and RefCell is !Sync (its borrow flag is not atomic), so the combination cannot leave the thread that built it — the type system encodes the restriction the runtime check assumes. The cross-thread equivalent is Arc<Mutex<T>>: atomic counting plus a lock as the runtime borrow check.
Revision Notes
Key Takeaways
- 1. Tracing moves means marking every non-Copy value and every hand-off (let, call, return, field read); the first use after the last hand-off is the compiler error you must predict
- 2. A struct holding a reference needs a lifetime parameter because elision only works on fn signatures; the parameter is the compiler's guarantee the struct cannot outlive its borrow
- 3. Box is single ownership on the heap; Rc and Arc share ownership via a control-block count — Arc's count is atomic, which is the entire cost difference and the reason Rc is !Send
- 4. RefCell moves the borrow check to a runtime flag: conflicting borrows panic (BorrowMutError) instead of failing to compile, so keep borrows short and use try_borrow when overlap is reachable
- 5. Rc<RefCell<T>> and Arc<Mutex<T>> are the two shared-mutability patterns; the first is confined to one thread by !Send + !Sync, the second pays atomics and lock contention to cross threads
Interview Tips
- • "What does this snippet print?" — trace on paper before answering: box every non-Copy value, arrow every hand-off, and state the failing line plus whether the fix is a clone, a borrow, or a signature change; interviewers watch the method more than the verdict
- • "Why does the struct need a lifetime but the function doesn't?" — answer with elision: fn signatures provide lifetimes to relate (one input → output, &self → output); a bare struct has none, so it declares `<'a>` explicitly
- • "Compare Box, Rc, Arc" — deliver the table from memory, then add the why: Rc skips atomics because it is !Send by design, and the count lives in the heap control block, not with the value
- • "What can RefCell not protect against?" — it catches conflicts at runtime as panics, so overlapping borrows that would be compile errors become production crashes; mention try_borrow and holding borrows across one expression as the mitigations
Cheat Sheet
Ownership Interview Drills
Move tracing rule set:
| Situation | Moves? |
|---|---|
let b = a; non-Copy |
Yes |
| Pass by value | Yes (into callee) |
| Return | Yes (to caller) |
| Read field by value | Partial move of that field |
& / &mut everywhere |
Only the reference moves |
Move errors are usually signature errors: a function that does not consume its argument should take &str, not String.
Struct + reference: struct Extractor<'a> { source: &'a str } — the parameter ties the struct's validity to the borrow; elision can't infer it because there is no fn signature. Own the data (String, Cow<'a, str>) to drop the parameter.
Four-way comparison:
| Type | Owners | Thread | Cost |
|---|---|---|---|
Box<T> |
1 | yes if T: Send |
allocation only |
Rc<T> |
many | no (!Send) |
non-atomic count |
Arc<T> |
many | yes | atomic count |
RefCell<T> |
1 | !Sync |
runtime borrow flag |
Patterns: Rc<RefCell<T>> = single-thread shared mutation (panics BorrowMutError on overlap). Arc<Mutex<T>> = cross-thread shared mutation.