What Is Ownership?

Ownership is Rust’s memory management system with compile-time rules that prevent memory safety issues without runtime overhead. Unlike garbage-collected languages, Rust determines memory cleanup at compile time.

Stack vs Heap

Stack: LIFO structure for fixed-size data. Fast allocation/deallocation, automatic cleanup. Heap: Runtime allocation for dynamic-size data. Returns pointer, requires explicit management.

Stack access is faster due to locality. Heap access requires pointer dereferencing. Ownership primarily manages heap data.

Ownership Rules

  • Each value has exactly one owner
  • Only one owner at a time
  • Value is dropped when owner goes out of scope

Variable Scope

let s = "hello";

String literals are immutable and stack-allocated. The variable s is valid from declaration until scope end.

fn main() {
    {                      // s is not valid here, since it's not yet declared
        let s = "hello";   // s is valid from this point forward

        // do stuff with s
    }                      // this scope is now over, and s is no longer valid
}

The String Type

String manages heap-allocated, mutable text data:

let s = String::from("hello");

Unlike string literals, String can be mutated and has dynamic size:

fn main() {
    let mut s = String::from("hello");

    s.push_str(", world!"); // push_str() appends a literal to a String

    println!("{s}"); // this will print `hello, world!`
}

Memory and Allocation

String requires heap allocation for dynamic content. Rust automatically calls drop when the owner goes out of scope—similar to RAII in C++.

fn main() {
    {
        let s = String::from("hello"); // s is valid from this point forward

        // do stuff with s
    }                                  // this scope is now over, and s is no
                                       // longer valid
}

Variables and Data Interacting with Move

Simple stack values are copied:

fn main() {
    let x = 5;
    let y = x;
}

Heap-allocated values are moved:

fn main() {
    let s1 = String::from("hello");
    let s2 = s1;
}

String contains three parts on the stack: pointer, length, capacity. Assignment copies these metadata but not heap data. Rust invalidates the original variable to prevent double-free errors.

fn main() {
    let s1 = String::from("hello");
    let s2 = s1;

    println!("{s1}, world!");
}

Error: value used after move. This prevents the double-free memory safety issue.

Scope and Assignment

Assigning a new value immediately drops the previous value:

fn main() {
    let mut s = String::from("hello");
    s = String::from("ahoy");

    println!("{s}, world!");
}

Variables and Data Interacting with Clone

For explicit deep copying:

fn main() {
    let s1 = String::from("hello");
    let s2 = s1.clone();

    println!("s1 = {s1}, s2 = {s2}");
}

clone() is expensive—it copies heap data.

Stack-Only Data: Copy

Types implementing Copy trait are duplicated instead of moved:

fn main() {
    let x = 5;
    let y = x;

    println!("x = {x}, y = {y}");
}

Copy types include:

  • Integer types (u32, i32, etc.)
  • Boolean (bool)
  • Floating-point types (f64)
  • Character (char)
  • Tuples of Copy types

Types with Drop trait cannot implement Copy.

Ownership and Functions

Function calls behave like assignment—values are moved or copied:

fn main() {
    let s = String::from("hello");  // s comes into scope

    takes_ownership(s);             // s's value moves into the function...
                                    // ... and so is no longer valid here

    let x = 5;                      // x comes into scope

    makes_copy(x);                  // Because i32 implements the Copy trait,
                                    // x does NOT move into the function,
                                    // so it's okay to use x afterward.

} // Here, x goes out of scope, then s. However, because s's value was moved,
  // nothing special happens.

fn takes_ownership(some_string: String) { // some_string comes into scope
    println!("{some_string}");
} // Here, some_string goes out of scope and `drop` is called. The backing
  // memory is freed.

fn makes_copy(some_integer: i32) { // some_integer comes into scope
    println!("{some_integer}");
} // Here, some_integer goes out of scope. Nothing special happens.

Return Values and Scope

Functions can transfer ownership via return values:

fn main() {
    let s1 = gives_ownership();        // gives_ownership moves its return
                                       // value into s1

    let s2 = String::from("hello");    // s2 comes into scope

    let s3 = takes_and_gives_back(s2); // s2 is moved into
                                       // takes_and_gives_back, which also
                                       // moves its return value into s3
} // Here, s3 goes out of scope and is dropped. s2 was moved, so nothing
  // happens. s1 goes out of scope and is dropped.

fn gives_ownership() -> String {       // gives_ownership will move its
                                       // return value into the function
                                       // that calls it

    let some_string = String::from("yours"); // some_string comes into scope

    some_string                        // some_string is returned and
                                       // moves out to the calling
                                       // function
}

// This function takes a String and returns a String.
fn takes_and_gives_back(a_string: String) -> String {
    // a_string comes into
    // scope

    a_string  // a_string is returned and moves out to the calling function
}

Returning ownership for every function parameter is verbose. Rust provides references for this use case:

fn main() {
    let s1 = String::from("hello");

    let (s2, len) = calculate_length(s1);

    println!("The length of '{s2}' is {len}.");
}

fn calculate_length(s: String) -> (String, usize) {
    let length = s.len(); // len() returns the length of a String

    (s, length)
}