Tech Wiki

TOPICSSERIES

[Rust Zero to Production 07] Understand Rust Ownership Through Moves, Copies, Stack, and Heap

Assign a String to another variable, then use the original, and Rust reports a compile error. Write code with the same shape using a u8, and both variables remain usable. The difference is not simply whether a value lives on the stack. It depends on whether the type implements Copy and whether the expression transfers ownership.

The goal here is to predict which binding remains valid after an assignment or function call. Once that prediction becomes routine, E0382 stops prompting a reflexive clone().

fn main() {
    let name = String::from("api");
    let registered_name = name;

    println!("registered: {registered_name}");
    println!("original: {name}");
}

The last line does not compile. Ownership of the String moved to registered_name. A type such as u8 implements Copy, so assignment and argument passing copy its value implicitly and leave the earlier binding valid.

1. Ownership and Validity

Rust has three ownership rules: every value has an owner, there can be only one owner at a time, and the value is dropped when its owner leaves scope. For these examples, treat the owner as the binding currently responsible for the value.

When you encounter let y = x;, inspect the type first.

State after the operation T: Copy T: !Copy
let y = x; both x and y are valid y owns the value; x is invalid
take(x) where fn take(v: T) x remains valid ownership moves into the parameter; x is invalid
let y = x.clone(); where T: Clone both are valid both are valid, but duplication semantics and cost depend on the type
let y = make(); y receives the returned value y receives ownership of the returned value

"Invalid" does not claim that memory disappeared immediately. It means Rust will not let the program access the value through that binding again. Whether the generated machine code copied bits is a separate question. Both a move and a copy may result in bits being copied, or the optimizer may remove the copy. The language-level question is whether the old binding can still be used.

2. String Assignment and Moves

String is a growable UTF-8 string. It has three conceptual components: a buffer pointer, a length, and a capacity. The buffer that contains the string's bytes is stored on the heap.

If Rust merely copied those components and treated both String values as owners, both could try to clean up the same buffer. String therefore does not implement Copy. Assignment transfers ownership to the new binding without duplicating the buffer, and the compiler rejects later use of the old binding.

Compiling the opening example with stable rustc 1.98.1 produced this diagnostic:

error[E0382]: borrow of moved value: `name`
 --> tests/ui/use_after_assignment.rs:6:26
  |
2 |     let name = String::from("api");
  |         ---- move occurs because `name` has type `String`, which does not implement the `Copy` trait
3 |     let registered_name = name;
  |                           ---- value moved here
...
6 |     println!("original: {name}");
  |                          ^^^^ value borrowed here after move
  |
help: consider cloning the value if the performance cost is acceptable
  |
3 |     let registered_name = name.clone();
  |                               ++++++++

error: aborting due to 1 previous error

For more information about this error, try `rustc --explain E0382`.

The help text offers clone() as a possible edit. It does not know whether cloning is the right design. If only registered_name is needed, the move is correct and the final println! is the line to remove.

3. Stack and Heap Are Not the Rule

A useful model says that the stack handles fixed-size values in LIFO order, while a heap allocation asks an allocator for space and receives a pointer. Because String owns a heap buffer, it makes the cleanup problem behind ownership easy to see.

The shortcut "stack values copy; heap values move" fails quickly.

  • Box<i32> is itself a fixed-size pointer, but it is not Copy, so assigning it moves ownership.
  • [u8; 4096] is Copy because its element type is Copy. Its size does not turn it into a move-only type.
  • A shared reference such as &String is Copy even though the referenced String is not.

Nor does Rust promise that every local value occupies a particular stack slot in the final machine code. An optimizer may remove or rearrange storage and copies while preserving observable behavior. Use the documented heap buffer of String to understand the resource involved, but do not promote one debug build's memory layout into a language guarantee.

4. Copy Type Contract

Copy marks types whose values can be duplicated with a simple bitwise copy. The copy occurs implicitly during assignment or argument passing, and an implementation cannot overload its behavior. u8, bool, char, integer and floating-point types, plus tuples and arrays made entirely from Copy elements, are common examples.

#[must_use]
pub const fn doubled_retry_budget(retries: u8) -> u8 {
    retries * 2
}

let retries = 3;
let doubled = doubled_retry_budget(retries);

assert_eq!(retries, 3);
assert_eq!(doubled, 6);

The function receives a copy of retries. The original binding remains valid after the call.

A user-defined type can implement Copy only when all its fields qualify. A struct with a String field does not. A type that implements Drop cannot implement Copy either, because a bitwise copy cannot safely multiply responsibility for resource cleanup.

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Endpoint {
    name: String,
    url: String,
    retries: u8,
}

The retries field is Copy; name and url are not. The whole struct derives Clone, not Copy. A returned u8 from endpoint.retries() can be copied, while passing an Endpoint by value moves it.

5. Ownership Across Function Calls

A parameter written as endpoint: Endpoint says that the caller transfers ownership. This register function consumes the endpoint, then places it in the returned tuple so ownership goes back to the caller.

#[must_use]
pub fn register(endpoint: Endpoint) -> (Endpoint, String) {
    let summary = format!("registered {} -> {}", endpoint.name, endpoint.url);
    (endpoint, summary)
}

The caller binds the returned owner.

let endpoint = Endpoint::new("api", "https://example.com/health", 3);
let (endpoint, summary) = register(endpoint);

println!("{summary}");
println!("active owner: {}", endpoint.name());

The first endpoint binding becomes invalid at the call. A new binding shadows that name and owns the returned Endpoint. The spelling is the same, but the two bindings have different validity ranges.

Returning ownership is not automatically the best API. If a function only needs to inspect a value, accepting a reference is usually a better fit. Borrowing is the subject of the next installment. For now, the rule is enough: passing an argument by value copies it when the type is Copy and moves it otherwise.

Using the original without receiving ownership back produces E0382 again.

fn consume(value: String) {
    println!("consumed: {value}");
}

fn main() {
    let name = String::from("api");
    consume(name);
    println!("after call: {name}");
}

The compiler suggests changing consume to borrow if it does not need ownership. It may also suggest clone() if two owners are required. Those edits express different ownership contracts; they are not interchangeable fixes with different prices.

6. Intentional clone()

Clone explicitly creates another value. Unlike Copy, a Clone implementation may run arbitrary code, and its cost depends on the type. String::clone() duplicates the string buffer. Cloning an Rc or Arc increases shared ownership of the same underlying data. Calling every clone() a deep copy is therefore inaccurate.

In the next example, the active endpoint and a renamed audit snapshot must be separate owned values. Duplication matches that requirement.

let endpoint = Endpoint::new("api", "https://example.com/health", 3);
let mut audit = endpoint.clone();
audit.rename("api-audit");

assert_eq!(endpoint.name(), "api");
assert_eq!(audit.name(), "api-audit");

When E0382 appears, work through these choices:

  1. Move the value if the old owner is finished with it.
  2. Change the API to accept a reference if the function only needs temporary access.
  3. Return the value or reorganize the data flow if ownership must continue elsewhere after the operation.
  4. Call clone() when the program genuinely needs two owned values. If it needs shared ownership, consider a type designed for that contract.

The compiler's clone suggestion means option 4 is syntactically available. It cannot decide that option 4 is a better design than the first three.

7. Run Rust 2024

It has no external dependencies. The runnable example and failure cases are contained in src/lib.rs, src/main.rs, and tests/ui.

cd examples/article-07-ownership-moves
cargo run --quiet
registered api -> https://example.com/health
retry budget: 3 -> 6
independent owners: api and api-audit

Run the working code and its compile_fail documentation test with:

cargo fmt --check
cargo check --all-targets --all-features
cargo clippy --all-targets --all-features -- -D warnings
cargo test --all-features

The suite contains three library unit tests and one compile_fail documentation test; the expected failure count is 0.

The assignment and function-call failures live in tests/ui/use_after_assignment.rs and tests/ui/use_after_call.rs. Compiling each with rustc --edition 2024 should produce E0382 and a nonzero exit status.

8. Prediction

These four lines are enough to apply the rule without running anything:

let a = String::from("api");
let b = a;
let n = 3_u8;
let m = n;

b owns the String, and a is invalid. Because u8: Copy, both n and m remain valid. The answer does not require the number of bytes in either value, the addresses shown by a debugger, or a count of heap allocations. Inspect the type's traits and the expression that may transfer ownership.

Full source code

The complete runnable source for this article is available in the Chapter 07 project on GitHub.

Sources


One response

  1. […] Next articleUnderstand Rust Ownership Through Moves, Copies, Stack, and Heap […]

Leave a Reply

Your email address will not be published. Required fields are marked *

Tech Wiki

Built with WordPress · Learn in public.