Skip to content

Rust - Ownership

In modern systems programming, managing memory correctly is crucial for performance and safety. Rust approaches this with a unique concept called Ownership. To understand Ownership, we must first understand how a program manages memory, typically through the Stack and the Heap.

The Stack is for data with a known, fixed size at compile time. It’s extremely fast because it’s a simple ‘Last-In, First-Out’ (LIFO) structure. Think of it as a stack of plates: you add new plates to the top and take plates from the top. All scalar types like i32, f64, bool, and char live on the stack.

The Heap is for data whose size might change or is unknown at compile time. This includes types like String or Vec<T> that can grow. When you need space on the heap, you ask the operating system for it, and it returns a pointer (a memory address) that you store on the stack. This process is called ‘allocation’ and is slower than pushing to the stack. The heap is less organized, like finding a free spot in a parking lot.

Ownership is Rust’s solution to memory management, replacing the need for a garbage collector or manual memory management (malloc/free). It’s governed by a simple set of rules enforced by the compiler:

  • One Owner: Each value in Rust has exactly one variable that is its ‘owner’.
  • One Place at a Time: A value can only have one owner at any given time.
  • Drop it When it’s Over: When the owner goes out of scope, the value it owns is automatically dropped (i.e., its memory is freed).

Let’s see these rules in action. When we write let s = String::from("hello");, s is the owner of the heap-allocated string data “hello”.

When we assign a heap-allocated value from one variable to another, Rust performs a move. It’s not a shallow copy; ownership is transferred.

fn main() {
// s1 owns the String data allocated on the heap.
let s1 = String::from("hello");
// Ownership of the String data is moved from s1 to s2.
// s1 is no longer valid and cannot be used.
let s2 = s1;
// This would cause a compile-time error: borrow of moved value: `s1`
// println!("s1 is: {}", s1);
println!("s2 is: {}", s2); // This is valid.
}

This prevents a ‘double free’ error, where two variables might try to free the same memory when they go out of scope. By invalidating s1, Rust guarantees memory safety at compile time.

Passing a value to a function also moves ownership. The same rules apply.

fn main() {
let s = String::from("world"); // s comes into scope
// s's value is moved into the function...
// ... and is no longer valid here.
takes_ownership(s);
let x = 5; // x comes into scope
// x is a stack-only type, so it's copied, not moved.
makes_copy(x);
println!("x is still accessible: {}", x); // This is fine!
} // Here, x goes out of scope. `s` was already moved.
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.

Similarly, returning a value from a function transfers ownership to the variable that receives the result.

So why could we still use x after assigning it to the function? It’s because simple scalar types implement the Copy trait. If a type implements Copy, variables are copied instead of moved. This is possible because they have a known size and live entirely on the stack, making copying very cheap.

  • All integer types, like u32.
  • The boolean type, bool.
  • All floating-point types, like f64.
  • The character type, char.
  • Tuples, if they only contain types that also implement Copy.

Constantly passing ownership back and forth can be tedious. Rust has a powerful feature called borrowing, which allows you to use a value without taking ownership of it. We create a reference to the value using the ampersand & symbol.

fn main() {
let s1 = String::from("Rust is powerful!");
// We pass a reference to s1, so we don't move ownership.
let len = calculate_length(&s1);
// s1 is still valid here!
println!("The length of '{}' is {}.", s1, len);
}
// The function signature takes a reference to a String.
fn calculate_length(s: &String) -> usize {
s.len()
} // Here, s goes out of scope. But because it does not have ownership,
// nothing happens. The original `s1` is not dropped.

This concept of ownership, moves, and borrows is the cornerstone of Rust’s ability to provide memory safety without a garbage collector, enabling unparalleled performance and reliability.