Rust - Ownership Basics

Overview

Estimated time: 50–60 minutes

Master Rust's ownership system - the key feature that enables memory safety without garbage collection. Learn ownership rules, move semantics, stack vs heap allocation, and how Rust prevents common memory bugs at compile time.

Learning Objectives

Prerequisites

What is Ownership?

Ownership is Rust's most unique feature. It enables Rust to make memory safety guarantees without needing a garbage collector. Understanding ownership is crucial to writing effective Rust code.

The Three Rules of Ownership

  1. Each value in Rust has an owner.
  2. There can only be one owner at a time.
  3. When the owner goes out of scope, the value will be dropped.

Stack vs Heap Memory

Stack Memory

fn main() {
    let x = 42;        // Stored on stack
    let y = 3.14;      // Stored on stack  
    let arr = [1, 2, 3]; // Fixed array on stack
    
    println!("x: {}, y: {}, arr: {:?}", x, y, arr);
}

Expected Output:

x: 42, y: 3.14, arr: [1, 2, 3]

Heap Memory

fn main() {
    let s = String::from("hello");  // Stored on heap
    let mut v = Vec::new();         // Stored on heap
    v.push(1);
    v.push(2);
    
    println!("String: {}, Vector: {:?}", s, v);
}

Expected Output:

String: hello, Vector: [1, 2]

Ownership with Stack Data

Copy Semantics

Simple types implement the Copy trait and are copied rather than moved:

fn main() {
    let x = 5;
    let y = x;  // x is copied to y
    
    // Both x and y are valid
    println!("x: {}, y: {}", x, y);
    
    // Function calls also copy
    print_number(x);
    println!("x is still valid: {}", x);
}

fn print_number(n: i32) {
    println!("Number: {}", n);
}

Expected Output:

x: 5, y: 5
Number: 5
x is still valid: 5

Types that Implement Copy

fn main() {
    let tuple = (5, true, 'a');
    let tuple_copy = tuple;  // Copied because all elements are Copy
    
    println!("Original: {:?}", tuple);
    println!("Copy: {:?}", tuple_copy);
}

Expected Output:

Original: (5, true, 'a')
Copy: (5, true, 'a')

Ownership with Heap Data

Move Semantics

Heap-allocated data is moved, not copied, to prevent double-free errors:

fn main() {
    let s1 = String::from("hello");
    let s2 = s1;  // s1 is moved to s2
    
    println!("s2: {}", s2);
    // println!("s1: {}", s1);  // Error: s1 no longer valid
}

Expected Output:

s2: hello

What Happens During a Move

fn main() {
    // Memory layout visualization
    let s1 = String::from("hello");
    // s1: [ptr | len | capacity] -> heap: "hello"
    
    let s2 = s1;  // Move occurs
    // s1: [invalid]
    // s2: [ptr | len | capacity] -> heap: "hello"
    
    // Only s2 owns the heap data now
    println!("s2: {}", s2);
}

Preventing Use After Move

Rust prevents use-after-move at compile time:

fn main() {
    let s = String::from("hello");
    let t = s;  // s is moved to t
    
    // This would cause a compile error:
    // println!("s: {}", s);  // Error: value borrowed here after move
    
    println!("t: {}", t);  // This works fine
}

Ownership and Functions

Passing Values to Functions

Passing a value to a function moves or copies it:

fn main() {
    let s = String::from("hello");  // s comes into scope
    
    takes_ownership(s);             // s's value moves into the function
                                    // s is no longer valid here
    
    let x = 5;                     // x comes into scope
    
    makes_copy(x);                 // x would move into the function,
                                   // but i32 is Copy, so it's okay to still
                                   // use x afterward
    
    println!("x is still valid: {}", x);
}

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

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

Expected Output:

hello
5
x is still valid: 5

Returning Values

Returning values also transfers ownership:

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
    
    println!("s1: {}, s3: {}", s1, s3);
    // s2 is no longer valid, but s1 and s3 are
}

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
}

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
}

Expected Output:

s1: yours, s3: hello

The Problem with Taking Ownership

Ownership Transfer Issues

Taking ownership in functions can be inconvenient:

fn main() {
    let s1 = String::from("hello");
    
    let len = calculate_length_bad(s1);  // s1 is moved
    
    // println!("{}", s1);  // Error: s1 no longer valid
    println!("Length: {}", len);
}

fn calculate_length_bad(s: String) -> usize {
    s.len()
}  // s goes out of scope and is dropped

Solution: Return Ownership

We can return the ownership along with results:

fn main() {
    let s1 = String::from("hello");
    
    let (s2, len) = calculate_length_with_return(s1);
    
    println!("The length of '{}' is {}.", s2, len);
}

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

Expected Output:

The length of 'hello' is 5.

Note: This is tedious! Rust provides references to solve this problem, which we'll cover in the next tutorial.

Scope and Dropping

Automatic Cleanup

Rust automatically calls drop when variables go out of scope:

fn main() {
    {
        let s = String::from("hello"); // s comes into scope
        
        // do stuff with s
        println!("Inside block: {}", s);
    }                                  // s goes out of scope and is dropped
    
    // println!("{}", s);  // Error: s is not in scope
    
    println!("s has been dropped");
}

Expected Output:

Inside block: hello
s has been dropped

Multiple Variables and Drop Order

Variables are dropped in reverse order of creation:

fn main() {
    let _a = String::from("first");
    let _b = String::from("second");
    let _c = String::from("third");
    
    println!("All strings created");
    // When main ends, variables are dropped in reverse order: _c, _b, _a
}

Expected Output:

All strings created

Clone for Deep Copying

Explicit Cloning

Use clone() to create a deep copy of heap data:

fn main() {
    let s1 = String::from("hello");
    let s2 = s1.clone();  // Deep copy - both are valid
    
    println!("s1: {}, s2: {}", s1, s2);
    
    // Both s1 and s2 can be used
    use_string(s1.clone());
    use_string(s2.clone());
    
    // Original strings still valid
    println!("Still valid - s1: {}, s2: {}", s1, s2);
}

fn use_string(s: String) {
    println!("Using: {}", s);
}

Expected Output:

s1: hello, s2: hello
Using: hello
Using: hello
Still valid - s1: hello, s2: hello

When to Use Clone

Ownership Patterns

Builder Pattern with Ownership

struct Config {
    name: String,
    value: i32,
}

impl Config {
    fn new(name: String) -> Self {
        Config {
            name,
            value: 0,
        }
    }
    
    fn with_value(mut self, value: i32) -> Self {
        self.value = value;
        self
    }
    
    fn build(self) -> Self {
        self
    }
}

fn main() {
    let config = Config::new(String::from("my_config"))
        .with_value(42)
        .build();
    
    println!("Config: name={}, value={}", config.name, config.value);
}

Expected Output:

Config: name=my_config, value=42

Option and Ownership

fn main() {
    let mut maybe_string = Some(String::from("hello"));
    
    // Take ownership from Option
    if let Some(s) = maybe_string.take() {
        println!("Got string: {}", s);
        // s is owned here and will be dropped
    }
    
    // maybe_string is now None
    println!("maybe_string is now: {:?}", maybe_string);
}

Expected Output:

Got string: hello
maybe_string is now: None

Memory Safety Benefits

Preventing Double Free

Rust prevents double-free errors at compile time:

fn main() {
    let s1 = String::from("hello");
    let s2 = s1;  // s1 moved to s2
    
    // If this were allowed, both s1 and s2 would try to free the same memory
    // drop(s1);  // Error: use of moved value
    drop(s2);     // OK: s2 owns the value
}

Preventing Use After Free

fn main() {
    let s = String::from("hello");
    drop(s);  // Explicitly free s
    
    // println!("{}", s);  // Error: use after free prevented
}

Preventing Memory Leaks

Automatic cleanup prevents most memory leaks:

fn create_and_forget() {
    let _s = String::from("This will be automatically cleaned up");
    // No need to manually free memory
} // _s is automatically dropped here

fn main() {
    create_and_forget();
    println!("Memory was automatically cleaned up");
}

Expected Output:

Memory was automatically cleaned up

Common Ownership Mistakes

Using Moved Values

fn main() {
    let s = String::from("hello");
    let t = s;  // s is moved
    
    // println!("{}", s);  // Error: value used after move
    println!("{}", t);     // OK
}

Moving in Loops

fn main() {
    let strings = vec![
        String::from("hello"),
        String::from("world"),
    ];
    
    for s in strings {  // strings is moved into the loop
        println!("{}", s);
    }
    
    // println!("{:?}", strings);  // Error: strings was moved
}

Expected Output:

hello
world

Partial Moves

struct Point {
    x: String,
    y: String,
}

fn main() {
    let p = Point {
        x: String::from("1"),
        y: String::from("2"),
    };
    
    let x = p.x;  // Partial move of p.x
    
    // println!("{}", p.x);  // Error: p.x was moved
    println!("{}", p.y);     // OK: p.y wasn't moved
    // println!("{:?}", p);  // Error: can't use p because part was moved
}

Expected Output:

2

Best Practices

Design for Ownership

Minimize Cloning

Understand Move Semantics

Next Steps

Ownership is fundamental to Rust, but it can make some tasks inconvenient. The next topics will show you how to work with data without taking ownership:

Common Pitfalls

Checks for Understanding

  1. What are the three rules of ownership in Rust?
  2. What happens when you assign a String to another variable?
  3. Why can you use an i32 after passing it to a function, but not a String?
  4. When is data stored on the stack vs the heap?
  5. How do you create a deep copy of heap-allocated data?

Answers

  1. Each value has an owner; only one owner at a time; when owner goes out of scope, value is dropped
  2. The String is moved to the new variable, making the original invalid
  3. i32 implements Copy (copied), String doesn't (moved)
  4. Stack: fixed size known at compile time; Heap: dynamic size, allocated at runtime
  5. Use the clone() method

← PreviousNext →