Lesson 02 / 14
Ownership and Moves
Moving a value into a function, into an inner scope, and into a struct field all fall into the same class (where it was moved), and in all three the value ends exactly once; that the old binding cannot be used after a move is a rule the compiler tests at compile time, while the new owner can use the value without any problem.
Published Updated
In this lesson
- Shows by running it that moving a value into a function, an inner scope, and a struct field all fall into the where-it-was-moved class
- Explains why the old binding cannot be used after a move and proves that the new owner can use the value
- Verifies by measurement that a move is not a copy — the ledger gets exactly one record per value
Contents
The previous lesson measured how a value ends at scope end and only touched on the where-it-was-moved class through three examples. This lesson deepens that class: when ownership passes from one place to another, exactly what happens to the ending point? The question the Concurrency course asks — how a goroutine stops — looks similar but is separate: there, a running unit stops; here, a value ends.
The Programming Fundamentals course established as a concept how a value passes to a function — passing by value; that course explained this form of passing through Python, and showed that a value passed into a function does not affect the name in the caller’s hands. What this lesson measures is that Rust loads a completely different meaning onto the same syntax (writing a value as an argument to a function): a value passed to a function now belongs to the function too, and the name in the caller’s hands loses its validity. This change of ownership is tested at compile time, not at runtime. Accessing a value through a reference without giving up ownership is a separate path — that path is taken up in the Borrowing lesson.
Three Forms of Moving
The same class (where it was moved) can be entered through three separate paths. A value can be passed as an argument to a function; a value can be rebound to a new name in an inner scope opened inside the same scope; a value can be placed into a struct field. What all three have in common is that the old binding no longer holds the value — the value stays the same, only which name reaches it changes.
OR4. Binding a value to a new name in an inner scope (let new_owner = inner;)
should not be confused with the shadowing from the previous lesson. Shadowing created a
new value under the same name, and the old one went on living separately until its
own scope end. Here the exact opposite happens: the same value moves to a different
name, and the old name can no longer reach that value. Both look like a kind of
“rebinding,” but one starts a new life, while the other changes the owner of a life
that already exists.
Although the three forms appear with different syntax, they are three use sites of one
single mechanism: in Rust, writing a value of a type not marked Copy anywhere — into a
parameter, a variable, a field — moves that value from there to there. The syntax
always looks the same (an = sign, a call), but the meaning behind it is the same every
time: the bit pattern at the old location moves to the new location, and the old
location no longer counts as valid. This lesson measures that all three separate places
(a parameter, an inner-scope binding, a struct field) reduce to this one single
mechanism — not three different rules, but three faces of one rule.
In practice, the three forms also correspond to different design decisions. Moving a value into a function is preferred when the function is meant to have the final word over that value — once the function ends, the value ends too, and the caller has no more business with it. Rebinding a value in an inner scope shows up when the same value needs to be kept under a different name, usually for a narrower, temporary operation. Placing a value into a struct field, on the other hand, permanently makes the value part of a larger whole — from then on, the value’s life is tied not to its own scope but to that struct’s life. All three use the same mechanism, but the choice depends on which unit (function, scope, struct) will draw the value’s new boundary.
This choice mostly falls out of the data model itself. A program processing a record that moves it into a function that validates it (moving into a function) expects the record to stay there once validation ends — the caller side will not use the record again anyway. If the same program wants to hold a record temporarily in a transformed form (moving into an inner scope), the transformation stays valid only in that narrow scope. If a record is going to be part of a persistent structure (a list or a cache, for example — moving into a struct field), ownership is handed to that structure from the start. The choice among the three forms is determined, while writing code, by the answer given to the question “who does this value belong to now.”
The Measurement Excerpt
Only the Trace type, the Drop implementation, and the observation-ledger excerpt are
taken from the previous lesson’s core; sections A, C, and D are not needed in this
lesson. The helper part is placed in a separate file so that the three forms of moving
can be seen side by side in a single flow.
The helper file does not compile on its own — trace.rs has no entry point, so it
cannot be run; it is only written to the working directory. The mod trace; line at the
very top of moves.rs pulls this file into the main compilation; use trace::{Trace, new_ledger, read_log}; then states which names from it will be used directly. The
Trace struct’s name field and the new/new_ledger/read_log functions are marked
pub so they can be reached from outside the module; the ledger field is not marked,
because only Trace’s own methods reach it. Marking visibility this way, item by item,
was already established in the Setup and the Package Manager lesson; here it is only one
application of it.
// trace.rs — a measurement helper that records a value's ending
use std::cell::RefCell;
use std::rc::Rc;
pub type Ledger = Rc<RefCell<Vec<String>>>;
pub struct Trace {
pub name: String,
ledger: Ledger,
}
impl Trace {
pub fn new(name: &str, ledger: &Ledger) -> Trace {
Trace { name: name.to_string(), ledger: Rc::clone(ledger) }
}
}
impl Drop for Trace {
fn drop(&mut self) {
self.ledger.borrow_mut().push(self.name.clone());
}
}
pub fn new_ledger() -> Ledger {
Rc::new(RefCell::new(Vec::new()))
}
pub fn read_log(ledger: &Ledger) -> Vec<String> {
ledger.borrow().clone()
}
// moves.rs — three forms of moving: into a function, into an inner scope, into a struct field
mod trace;
use trace::{Trace, new_ledger, read_log};
struct Container {
content: Trace,
}
fn consume(received: Trace) {
println!("name read inside the function: {}", received.name);
}
fn main() {
let ledger = new_ledger();
let moved_in = Trace::new("into-function", &ledger);
consume(moved_in);
println!("ledger after the function returns: [{}]", read_log(&ledger).join(", "));
let inner = Trace::new("inner-scope", &ledger);
{
let new_owner = inner;
println!("name read in the inner scope: {}", new_owner.name);
}
println!("ledger after the inner scope ends: [{}]", read_log(&ledger).join(", "));
let field_value = Trace::new("into-struct", &ledger);
{
let container = Container { content: field_value };
println!("name read in the struct field: {}", container.content.name);
}
println!("ledger after the struct scope ends: [{}]", read_log(&ledger).join(", "));
let records = read_log(&ledger);
println!();
println!("total records: {}", records.len());
for name in ["into-function", "inner-scope", "into-struct"] {
let count = records.iter().filter(|k| k.as_str() == name).count();
println!("{:<16}{} record", name, count);
}
}
name read inside the function: into-function ledger after the function returns: [into-function] name read in the inner scope: inner-scope ledger after the inner scope ends: [into-function, inner-scope] name read in the struct field: into-struct ledger after the struct scope ends: [into-function, inner-scope, into-struct] total records: 3 into-function 1 record inner-scope 1 record into-struct 1 record
All three lines follow the same pattern: the value is first read in its new owner
(received.name, new_owner.name, container.content.name), then drops into the
ledger once the new owner’s own scope closes. The moment consume‘s body ends,
into-function is written to the ledger right away — the function’s own scope becomes
the moved value’s new and final scope. The same holds for new_owner in the inner
scope and container.content in the struct field: both end the moment their own narrow
scope closes, without waiting for the outer scope to close.
The manner of access also differs across the three forms. The value moved into a
function is reached directly by the parameter name (received), the value moved into
an inner scope by the new binding’s name (new_owner), but the value moved into a
struct field no longer has a name of its own — it can only be reached through
container.content, through the struct that owns it. This third form shows that a move
is not just “copying one variable into another”: once a value becomes part of a
struct, it loses its own independent name and becomes visible only through the field
path of the struct enclosing it.
Why the Old Binding No Longer Works
The code never reads the old name (moved_in, inner, field_value) again after these
three moves — because it cannot. If a line trying to reach moved_in.name were added
after the consume(moved_in) call, the compiler would reject it: ownership passed to
consume’s parameter, and moved_in no longer points to a valid value. The same rule
applies to inner and field_value.
OR5. This rejection is not a runtime error, it is a rule tested at compile time. Source code trying to read a moved name again does not compile at all; the program never reaches that line, so the wrong usage costs nothing at runtime. This also explains why the accepted code above (the three blocks) never reads the old name again anywhere — correct code already stays inside this constraint.
This rejection comes from the same place as the previous lesson’s single-owner observation: two separate names being able to reach one value at the same time would make it unclear which name’s scope closing would end that value. The compiler prevents this by treating the old name as invalid the moment the move happens — the old name still sits in the source, but it can no longer be used.
A single thought experiment is enough to make concrete what this constraint prevents: if
moved_in stayed valid after the move, both moved_in and received could reach the
same Trace instance. Both would try to end that instance once their own scope closed,
and the same value would end up being dropped twice — the same name would be written to
the ledger twice. The compiler never lets this situation arise; at the moment of the
move, it makes sure only one name stays valid, and leaves the rest (when the ending
happens) to the previous lesson’s rules.
Moves Can Be Chained
The three forms each showed a single move, but moving can also happen one after
another: a value can enter a function and be handed from there to another function,
even back to the caller. The example below measures the same Trace value crossing two
separate function boundaries and ending in a third scope.
// chain.rs — a move can be chained: ownership can be handed from one function to another
mod trace;
use trace::{Trace, new_ledger, read_log};
fn second_stage(value: Trace) -> Trace {
println!("name read in the second stage: {}", value.name);
value
}
fn first_stage(value: Trace) {
let handed_off = second_stage(value);
println!("name back in the first stage: {}", handed_off.name);
}
fn main() {
let ledger = new_ledger();
let value = Trace::new("chained", &ledger);
first_stage(value);
println!("ledger after the chain ends: [{}]", read_log(&ledger).join(", "));
}
name read in the second stage: chained name back in the first stage: chained ledger after the chain ends: [chained]
The value here passes through the hands of four separate names: value in main,
first_stage’s parameter, second_stage’s parameter, and handed_off, which holds the
value second_stage returns. Each pass is a move, and after each move the previous name
is no longer valid. At the end of the chain the value belongs to handed_off, and it
ends there once first_stage’s body ends — not in main. Four names, one value, one
ending: only chained appears in the ledger, regardless of how many times it was moved.
This shows why the where-it-was-moved class is defined not by a count but by a
position. How many times the value changed hands does not matter; what is measured
is only the final owner’s scope. second_stage returning the value is a move too — the
return value moves into a new binding on the caller’s side, which is why handed_off
is itself the result of a move that falls to its own share.
A function returning a value it owns is a pattern this course will meet again. When a function wants to hand back to its caller a value it built inside itself or received as a parameter, the option is not a reference but an ownership transfer — the value is written as the function’s return type, and the return counts as a move in its own right. This pattern is also needed to understand, in the Borrow Checker lesson, why a function returning a reference to a value it built inside itself gets rejected: what gets rejected is exactly this — returning a reference; what gets accepted is what is seen here — returning the value itself.
Boundary Measurement: A Move Is Not a Copy
The output’s last three lines show this directly: into-function, inner-scope, and
into-struct each appear in the ledger exactly once. Even if a value moved through
three separate names in sequence (it does not here, but suppose it did), each move
would not create a copy — the same Trace instance, the same memory location, only the
access name changes.
OR6. All three forms of moving produced exactly one record in the ledger; this shows that the same value being dropped twice is impossible at the source level. Because ownership always sits in a single name, the ending point is always triggered in a single place too — a move does not break this singularity, it only changes which name is responsible.
That a move does not shorten a life is also read from the same measurement.
into-function is the same Trace instance both before and after being moved; a move
is not a re-creation, it is a re-naming. This is why “where it was moved,” out of the
four classes seen in the previous lesson, is a class in its own right: the value
neither waits for scope end nor receives an explicit call, it only inherits its owner’s
own ending.
Thinking through the opposite case makes the difference clear. If Trace values
implemented Clone and the three lines had been written as
consume(moved_in.clone()), let new_owner = inner.clone();, the ledger would have six
records: the three original values ending in their own scopes, and three copies ending
separately in theirs. clone was never called in this lesson — a move does not build a
new Trace instance, it hands the existing one from hand to hand. The ledger’s three
records, not six, are direct proof of this difference.
This difference shows up not only in the record count but also in the nature of the
work. A copy requires building the copy’s own data separately — for Trace, allocating
a new String; a move, in contrast, amounts to no more than assigning a new owner to
existing data, with no new allocation at all. This is why the four changes of hands
seen in the chaining example all happened on a single String — not four separate
copies, but four separate owners of the same data.
Summary
- A value can be moved three ways — into a function, into an inner scope, into a struct field — and all three fall into the same class (where it was moved).
- A moved value can be used in its new owner; the old name, in contrast, can no longer be read after the move, because it no longer points to a valid value.
- The old name becoming unusable is a rule tested at compile time; it has no cost at runtime.
- A move is not a copy: the ledger gets exactly one record per value, and the same value is never dropped twice.
- A move can be chained — a value can cross several function boundaries one after another — but no matter how many times it changes hands, there is only one ending point at the end.
Next Step
What the three forms of moving had in common was that the old name lost the value — ownership went exactly from one place to one place each time. The next question investigates exactly the opposite: is it possible to reach a value without ever giving up ownership? The Borrowing lesson measures that the name lending a value out does not lose the value, and that a borrowed value never ends anywhere during the borrow — unlike a move, here the old owner remains the owner.
To keep your progress and take notes, Log in
My notes
Log in to take notes.