Core Concepts: Ownership, Borrowing, and Lifetimes
This is where Rust truly distinguishes itself. Ownership, borrowing, and lifetimes are fundamental concepts that enable Rust to provide memory safety guarantees without a garbage collector. Understanding these ideas is key to writing correct and efficient Rust code. While they can seem challenging at first, they become second nature with practice.
Ownership Rules
Every program needs to manage the memory it uses. Some languages have garbage collectors (Java, Go) that automatically clean up memory, while others require manual management (C, C++). Rust uses a unique system based on a set of rules that the compiler checks at compile time.
Here are the three ownership rules:
- Each value in Rust has an owner.
- There can only be one owner at a time.
- When the owner goes out of scope, the value will be dropped.
Let’s break these down with examples.
Rule 1 & 3: Owner and Scope
A variable’s scope is the region of the program for which it is valid. In Rust, values are dropped (memory is freed) when their owner goes out of scope.
fn main() { // `s` is not yet valid here
let s = String::from("hello"); // `s` is valid from this point onward
// do stuff with s
println!("{}", s);
} // `s` goes out of scope, and its value (String data) is dropped (memory is freed)
The String::from function creates a String on the heap, allowing it to be mutable and grow. When s goes out of scope, Rust automatically calls a drop function, which frees the memory associated with that String.
Rule 2: One Owner at a Time (Move Semantics)
This rule is critical. When a variable that owns a heap-allocated value is assigned to another variable, ownership is moved. The original variable is no longer valid.
fn main() {
let s1 = String::from("hello");
let s2 = s1; // Ownership of "hello" data is moved from s1 to s2
println!("{}", s2); // This is fine
// println!("{}", s1); // This would cause a compile-time error!
// error[E0382]: borrow of moved value: `s1`
// note: value moved here
}
If Rust allowed s1 to be used after s2 = s1, both variables would be pointing to the same memory. When they go out of scope, both would try to free the same memory, leading to a “double free” error – a common memory bug. Rust prevents this by invalidating s1 after the move.
The Copy Trait
Not all types “move” when assigned. Primitive scalar types (integers, floats, booleans, characters, fixed-size arrays where elements are Copy) implement the Copy trait. For these types, a copy of the value is made, and both variables remain valid.
A type implements the Copy trait if it doesn’t have any special “resource management” logic, like freeing memory. Simple values that can be stored entirely on the stack are typically Copy.
fn main() {
let x = 5; // `i32` implements Copy
let y = x; // A copy of 5 is made, x is still valid
println!("x = {}, y = {}", x, y); // Output: x = 5, y = 5
}
Types like String (which manages heap data) do not implement Copy (they implement Drop), so assigning them results in a move.
Cloning for Duplication
If you truly want to create a deep copy of a heap-allocated value and keep both variables valid, you can explicitly call the clone() method. This can be an expensive operation.
fn main() {
let s1 = String::from("hello");
let s2 = s1.clone(); // s2 is now a deep copy of s1's data
println!("s1 = {}, s2 = {}", s1, s2); // Both are valid
}
Borrowing (References)
The ownership system is powerful, but constantly passing ownership or cloning can be cumbersome. What if a function just needs to look at a value without taking ownership of it? That’s where references (often called “borrowing”) come in.
A reference is a pointer to a value that does not take ownership of it. References are created using &.
Immutable References
You can have multiple immutable references (or shared references) to a value at a time. This allows many parts of your code to read the data concurrently.
fn calculate_length(s: &String) -> usize { // `s` is an immutable reference to a String
s.len()
} // `s` goes out of scope, but it doesn't drop the String it refers to, because it doesn't own it.
fn main() {
let s1 = String::from("hello");
let len = calculate_length(&s1); // Pass a reference to s1
println!("The length of '{}' is {}.", s1, len); // s1 is still valid here
}
Mutable References
To change a value that you don’t own, you need a mutable reference, indicated by &mut.
There can only be one mutable reference to a particular piece of data in a given scope. This is Rust’s crucial “data race prevention” mechanism.
fn change_string(some_string: &mut String) {
some_string.push_str(", world");
}
fn main() {
let mut s = String::from("hello"); // s must be mutable to get a mutable reference to it
change_string(&mut s); // Pass a mutable reference
println!("{}", s); // Output: hello, world
}
The One Mutable Reference Rule in Action:
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 at a time.
// println!("{}, {}", r1, r2); // This would also error even if r2 was declared earlier
println!("{}", r1); // r1's scope ends here after use
let r2 = &mut s; // This is now allowed because r1 is no longer used after its last use.
println!("{}", r2);
}
This rule might seem restrictive, but it prevents data races, a very common and hard-to-debug concurrency bug where multiple threads access the same data at the same time, and at least one of them is writing.
You also cannot combine mutable and immutable references in the same scope:
fn main() {
let mut s = String::from("hello");
let r1 = &s; // immutable reference
let r2 = &s; // another immutable reference - OK!
// let r3 = &mut s; // ERROR! Cannot borrow `s` as mutable because it is also borrowed as immutable.
println!("{}, {}", r1, r2); // r1 and r2 are used here, their borrows end.
let r3 = &mut s; // This is now allowed because r1 and r2 are no longer used after their last use.
println!("{}", r3);
}
The “scope” of a borrow actually ends when the reference is no longer used, not strictly at the end of the curly brace block. This is called Non-Lexical Lifetimes (NLL) and it makes Rust much more ergonomic.
Lifetimes
Lifetimes are a Rust compiler feature that ensures all borrows are valid. Specifically, lifetimes prevent “dangling references,” where a reference points to data that has already been deallocated.
Rust mostly infers lifetimes, but in some cases, you must annotate them explicitly. This happens primarily when you’re writing functions or structs that deal with references, and the compiler needs to know how the lifetimes of input references relate to the lifetimes of output references.
Lifetime Elision Rules
For many common patterns, Rust has “lifetime elision rules” that allow you to omit lifetime annotations. For example, a function that takes one input reference will generally have its output reference’s lifetime tied to the input.
// This function implicitly has a lifetime parameter 'a (pronounced "tick-a")
// The lifetime of the output reference is tied to the lifetime of the input reference
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[..]
}
fn main() {
let my_string = String::from("hello world");
let word = first_word(&my_string);
println!("First word: {}", word);
let s = "another string literal"; // string literals have 'static lifetime
let word2 = first_word(s);
println!("First word: {}", word2);
}
Explicit Lifetime Annotations
When the compiler can’t infer the relationship between lifetimes, you need to provide explicit annotations. This often happens with functions that take multiple references and return one.
The syntax for lifetime annotations looks like 'a, 'b, etc., and they go in angle brackets after fn and before the function name, similar to generics.
// This function returns a reference that could be tied to either `x` or `y`.
// Without lifetime annotations, Rust wouldn't know which one, potentially leading
// to a dangling reference if one of `x` or `y` goes out of scope too soon.
// The `'a` annotation means the returned reference lives at least as long as 'a.
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() {
x
} else {
y
}
}
fn main() {
let string1 = String::from("abcd");
let string2 = "xyz"; // string literal has 'static lifetime
let result = longest(&string1, string2);
println!("The longest string is {}", result);
{
let string3 = String::from("long string is long");
let result2 = longest(&string1, &string3); // Both &string1 and &string3 must live as long as 'a
println!("The longest string is {}", result2);
}
// string3 is now out of scope, but `result2` (if it referred to string3)
// would correctly have its lifetime checked and compiler would warn if used here.
// In this specific case, `result2` is tied to the _intersection_ of lifetimes of string1 and string3.
// Since string1 lives longer, result2 can continue to be used as long as string1 lives.
}
The 'a in longest<'a>(x: &'a str, y: &'a str) -> &'a str means that the returned reference will be valid for the shorter of the two input references’ lifetimes. This ensures memory safety.
Structs with References
If a struct holds references, you must also annotate the lifetimes for those references:
struct ImportantExcerpt<'a> {
part: &'a str,
}
fn main() {
let novel = String::from("Call me Ishmael. Some years ago...");
let first_sentence = novel.split('.').next().expect("Could not find a '.'");
let i = ImportantExcerpt {
part: first_sentence,
};
println!("Excerpt: {}", i.part);
// If 'novel' went out of scope before 'i', the compiler would catch it!
}
Here, ImportantExcerpt cannot outlive the reference it holds (part), which in turn cannot outlive the novel String.
Ownership, borrowing, and lifetimes are the core of Rust’s safety guarantees. While initially challenging, mastering them makes you a more effective and confident Rust programmer.
Exercises / Mini-Challenges
Exercise 3.1: Ownership and Copy Semantics
Predict the output (or compiler error) for each of the following snippets, then run them to confirm your understanding. For errors, explain why it errors and suggest a fix.
Snippet 1 (Integers):
fn main() {
let num1 = 10;
let num2 = num1;
println!("num1: {}, num2: {}", num1, num2);
}
Snippet 2 (Strings):
fn main() {
let s1 = String::from("Rust is fun!");
let s2 = s1;
// println!("s1: {}", s1); // Uncomment this line to see the error
println!("s2: {}", s2);
}
Snippet 3 (Strings with clone):
fn main() {
let s1 = String::from("Rust is powerful!");
let s2 = s1.clone();
println!("s1: {}, s2: {}", s1, s2);
}
Snippet 4 (Function with Ownership Transfer):
fn takes_ownership(some_string: String) { // some_string comes into scope
println!("{}", some_string);
} // some_string goes out of scope and `drop` is called.
fn main() {
let my_string = String::from("Hello, ownership!");
takes_ownership(my_string);
// println!("{}", my_string); // Uncomment this line to see the error
}
Exercise 3.2: Borrowing and Mutability Rules
Predict the outcome for these code blocks. Identify errors, explain them, and suggest fixes to make them compile while achieving the stated goal.
Snippet 1: Multiple Mutable Borrows (Error expected)
Goal: Have two mutable references to data and try to print them.
fn main() {
let mut data = String::from("initial data");
let r1 = &mut data;
let r2 = &mut data;
println!("r1: {}, r2: {}", r1, r2);
}
Snippet 2: Mixed Mutable and Immutable Borrows (Error expected)
Goal: Read data and then modify it using a mutable reference.
fn main() {
let mut data = String::from("original");
let r1 = &data; // immutable borrow
let r2 = &data; // another immutable borrow
println!("r1: {}, r2: {}", r1, r2);
let r3 = &mut data; // mutable borrow
r3.push_str(" modified");
println!("r3: {}", r3);
}
Snippet 3: Fixing Borrowing to Allow Both Reading and Writing Goal: Print an immutable reference, then modify the original data, and then print a mutable reference.
fn main() {
let mut data = String::from("start");
// Print an immutable reference first
let r1 = &data;
println!("Before modification: {}", r1); // Last use of r1, its borrow ends here
// Now, modify the data
let r2 = &mut data; // This should be allowed now
r2.push_str(" and end");
println!("After modification: {}", r2);
}
Confirm that Snippet 3 compiles and runs as expected.
Exercise 3.3: Lifetime Annotations
Explain why the following code needs a lifetime annotation and add the necessary annotation to make it compile.
Goal: Implement a function that returns a reference to a character from one of two input strings, based on a condition.
// fn pick_char(s1: &str, s2: &str, choose_s1: bool) -> &char { // Needs annotation
// if choose_s1 {
// s1.chars().next().unwrap()
// } else {
// s2.chars().next().unwrap()
// }
// }
fn main() {
let string_a = String::from("apple");
let string_b = "banana";
let chosen_char_ref = /* Call pick_char here */;
// Example: let chosen_char_ref = pick_char(&string_a, string_b, true);
println!("The chosen character is: {}", chosen_char_ref);
{
let string_c = String::from("carrot");
// What happens if you call pick_char with `string_c` and then try to use `chosen_char_ref` outside this block?
// chosen_char_ref = pick_char(&string_a, &string_c, false);
}
// Is chosen_char_ref valid here if it potentially refers to `string_c`?
}
Solution Hint for pick_char function signature:
fn pick_char<'a>(s1: &'a str, s2: &'a str, choose_s1: bool) -> &'a char {
// ... function body ...
}
Explain what the 'a means in this context and how it solves the lifetime problem.