Skip to content
academia.sh

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.

Start typing to search.

↑↓ ↵ Esc navigate · open · close