Understanding Ownership in Rust
1. Overview
One of Rust's most distinctive features — and the one that sets it apart from virtually every other systems programming language — is its ownership system. Ownership is the mechanism through which Rust achieves memory safety without a garbage collector and without requiring the programmer to manually allocate and free memory.
At compile time, the Rust compiler enforces a strict set of ownership rules. If any of these rules are violated, the program simply will not compile. This means that an entire class of bugs — use-after-free, double-free, dangling pointers, and data races — are impossible in safe Rust by construction.
This post walks through the core concepts: ownership, moves, cloning, borrowing, and lifetimes, with concrete examples throughout.
2. The Three Rules of Ownership
The ownership system is built on three fundamental rules:
- Every value in Rust has exactly one owner.
- There can only be one owner at a time.
- When the owner goes out of scope, the value is dropped and its memory is freed.
These rules are simple to state, but their implications are far-reaching.
3. Ownership and Scope
The most basic ownership example involves a variable going out of scope:
fn main() {
{
let s = String::from("hello"); // s comes into scope
// s is valid here
} // scope ends — s is dropped, memory is freed automatically
// println!("{}", s); // ERROR: s is no longer valid
}
No explicit free() call is needed. The compiler inserts the deallocation automatically at the closing brace. This is known as RAII (Resource Acquisition Is Initialization), a pattern borrowed from C++, but enforced by Rust's type system rather than discipline.
4. Move Semantics
When you assign a heap-allocated value to another variable, Rust moves the ownership rather than copying the data:
fn main() {
let s1 = String::from("hello");
let s2 = s1; // ownership moves from s1 to s2
// println!("{}", s1); // ERROR: s1 has been moved
println!("{}", s2); // OK
}
This is fundamentally different from languages like Java or Python, where assignment creates a new reference to the same object. In Rust, after the move, s1 is no longer valid. The compiler catches any attempt to use it.
For stack-allocated types that implement the Copy trait (such as integers, booleans, and characters), the value is copied instead of moved:
fn main() {
let x = 5;
let y = x; // x is copied, not moved
println!("x = {}, y = {}", x, y); // both are valid
}
5. Ownership and Functions
Passing a value to a function follows the same rules as assignment — ownership is transferred:
fn main() {
let s = String::from("world");
takes_ownership(s); // s is moved into the function
// println!("{}", s); // ERROR: s has been moved
}
fn takes_ownership(some_string: String) {
println!("{}", some_string);
} // some_string is dropped here
A function can also return ownership back to the caller:
fn main() {
let s1 = gives_ownership();
println!("{}", s1); // s1 owns the returned value
}
fn gives_ownership() -> String {
String::from("hello") // ownership moves to the caller
}
Having to move values in and out of every function would be cumbersome. This is where borrowing comes in.
6. Borrowing and References
A reference allows you to refer to a value without taking ownership of it. Creating a reference is called borrowing:
fn main() {
let s = String::from("hello");
let length = calculate_length(&s); // pass a reference
println!("'{}' has length {}", s, length); // s is still valid
}
fn calculate_length(s: &String) -> usize {
s.len()
} // s goes out of scope but the value is NOT dropped — we don't own it
The & symbol creates a reference. Because the function only borrows s, the original binding in main retains ownership and remains valid after the call.
6.1 Immutable References
By default, references are immutable. You cannot modify a borrowed value:
fn change(s: &String) {
// s.push_str(", world"); // ERROR: cannot mutate through an immutable reference
}
6.2 Mutable References
To allow modification, you must explicitly request a mutable reference with &mut:
fn main() {
let mut s = String::from("hello");
change(&mut s);
println!("{}", s); // "hello, world"
}
fn change(s: &mut String) {
s.push_str(", world");
}
There is one critical constraint: you can have at most one mutable reference to a value at any point in time. This prevents data races at compile time:
fn main() {
let mut s = String::from("hello");
let r1 = &mut s;
// let r2 = &mut s; // ERROR: cannot borrow `s` as mutable more than once
println!("{}", r1);
}
Additionally, you cannot have a mutable reference while immutable references exist:
fn main() {
let mut s = String::from("hello");
let r1 = &s; // immutable borrow
let r2 = &s; // another immutable borrow — OK
// let r3 = &mut s; // ERROR: cannot borrow as mutable while immutable borrows are active
println!("{} and {}", r1, r2);
}
These rules eliminate an entire category of concurrency bugs without any runtime overhead.
7. The Slice Type
Slices are a special kind of reference that refer to a contiguous sequence of elements in a collection, without owning it:
fn main() {
let s = String::from("hello world");
let hello = &s[0..5]; // slice of the first five characters
let world = &s[6..11]; // slice of the last five characters
println!("{} {}", hello, world);
}
String slices (&str) are immutable references into a String. They are one of the most common types in Rust code and are used extensively in function signatures to accept both String values and string literals:
fn first_word(s: &str) -> &str {
let bytes = s.as_bytes();
for (i, &item) in bytes.iter().enumerate() {
if item == b' ' {
return &s[0..i];
}
}
&s[..]
}
8. Lifetimes
Lifetimes are Rust's way of ensuring that references remain valid for as long as they are used. In many cases, the compiler infers lifetimes automatically. When it cannot, you must annotate them explicitly.
A lifetime annotation does not change how long a reference lives — it is a constraint that tells the compiler the relationship between the lifetimes of multiple references:
// 'a is a lifetime parameter — both inputs must live at least as long as 'a,
// and the output will not outlive either of them
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() { x } else { y }
}
fn main() {
let s1 = String::from("long string");
let result;
{
let s2 = String::from("xyz");
result = longest(s1.as_str(), s2.as_str());
println!("Longest: {}", result); // OK — both s1 and s2 are valid here
}
}
Lifetime annotations are required whenever the compiler cannot determine from the structure of the code alone how long a returned reference will remain valid.
Comments
Post a Comment