6.3 Borrowing: Access Without Ownership Transfer
Often, you need to access data without taking ownership. Rust allows this through borrowing, using references. A reference is like a pointer that provides access to a value owned by another variable, but unlike C pointers, references come with strict compile-time safety guarantees enforced by the borrow checker.
There are two types of references:
- Immutable References (
&T
): Allow read-only access to the borrowed data. - Mutable References (
&mut T
): Allow read-write access to the borrowed data.
6.3.1 References vs. C Pointers
While similar in concept to C pointers (*T
), Rust references have key differences:
Feature | Rust References (&T , &mut T ) | C Pointers (*T ) |
---|---|---|
Nullability | Guaranteed non-null | Can be NULL |
Validity | Guaranteed to point to valid memory (via lifetimes) | Can be dangling (point to freed memory) |
Mutability Rules | Strict compile-time rules (one &mut XOR multiple & ) | No compile-time enforcement |
Arithmetic | Generally not allowed (use slice methods) | Pointer arithmetic is common |
Dereferencing | Often automatic (e.g., method calls) | Explicit (*ptr or ptr->member ) |
Because of these guarantees, Rust references are sometimes called “safe pointers” or “managed pointers.”
Method Calls and Automatic Referencing/Dereferencing
You might notice you can call methods like .len()
directly on both an owned String
and a reference &String
(or &str
):
fn main() { let owned_string = String::from("hello"); let string_ref = &owned_string; // Both calls work: println!("Owned length: {}", owned_string.len()); println!("Ref length: {}", string_ref.len()); }
This convenience is enabled by Rust’s method call syntax and automatic referencing and dereferencing. When you use the dot operator (object.method()
), the compiler automatically adds necessary &
, &mut
, or *
operations to make the method call match the method’s signature regarding self
, &self
, or &mut self
.
- If
owned_string
isString
and.len()
expects&self
, the compiler automatically calls it as(&owned_string).len()
. - If
string_ref
is&String
and.len()
expects&self
, the compiler uses it correctly. (It might also involve dereferencing&String
to&str
first via theDeref
trait, then callinglen
on&str
).
This mechanism significantly cleans up code, avoiding manual (&value).method()
or (*reference).method()
calls in most situations. The Deref
trait (covered later) plays a key role in this process for types like String
and smart pointers.
6.3.2 The Borrowing Rules
The borrow checker enforces these core rules at compile time:
- Scope and Validity (Lifetimes): A reference cannot outlive the data it refers to. References are always guaranteed to point to valid data of the expected type (no dangling or null references). (This is primarily enforced by lifetimes, detailed in Section 6.6).
- Mutability Exclusivity: At any given time, you can have either one mutable reference (
&mut T
) or any number of immutable references (&T
) to the same piece of data.
Rule 2 ensures that you cannot obtain a mutable reference while any immutable references exist to the same data, nor can you obtain (or keep active) multiple mutable references simultaneously.
Example: Immutable References (Aliasing Allowed)
You can have multiple immutable references to the same data concurrently. Crucially, this is allowed whether the owner variable itself was declared with mut
or not. The mut
status of the owner primarily determines if mutable borrows (&mut T
) can be taken or if the owner can be directly modified, not whether immutable borrows (&T
) are permitted.
fn main() { let s1 = String::from("hello"); // Immutable owner let r1 = &s1; let r2 = &s1; println!("r1: {}, r2: {}", r1, r2); // OK let mut s2 = String::from("hello"); // Mutable owner let r3 = &s2; // Immutable borrow from mutable owner is fine let r4 = &s2; // Multiple immutable borrows are fine println!("r3: {}, r4: {}", r3, r4); // Also OK }
This is safe because immutable references guarantee the underlying data won’t change unexpectedly while they are active.
Non-Lexical Lifetimes (NLL) Example
The following example demonstrates how the compiler precisely tracks borrow durations:
fn main() { let mut s1 = String::from("hello"); let r1 = &s1; // (1) Immutable borrow starts println!("r1: {}, s1: {}", r1, s1); // (2) Last use of r1 (in the success case) s1.push('!'); // (3) Needs mutable borrow of s1 println!("s1: {}", s1); // println!("r1: {}", r1); // (4) Potential later use of r1 -> uncommenting causes compile error }
This code highlights how precisely Rust’s borrow checker analyzes borrow durations, thanks to a feature called Non-Lexical Lifetimes (NLL). Introduced formally in the Rust 2018 Edition, NLL means that borrows are typically considered active only until their last actual point of use within a scope, rather than necessarily lasting for the entire lexical scope (code block) they are declared in.
Let’s trace this example:
- An immutable borrow
r1
begins. r1
is used in theprintln!
.s1.push('!')
attempts to take a mutable borrow ofs1
. This is only allowed if no immutable borrows (liker1
) are currently active.- The commented-out line represents a potential later use of
r1
.
- When line (4) is commented out: The compiler sees that
r1
’s last use is on line (2). Due to NLL, the immutable borrowr1
is considered finished after that point. Therefore, the mutable borrow needed fors1.push('!')
on line (3) is permitted becauser1
is no longer active. The code compiles. - When line (4) is uncommented: The compiler sees
r1
is used again on line (4). NLL determines that the immutable borrowr1
must remain active until line (4). This meansr1
is still active when line (3) (s1.push('!')
) tries to take a mutable borrow. This violates the rule (‘cannot borrows1
as mutable because it is also borrowed as immutable’), and compilation fails, typically with an error message pointing to line (3).
This NLL behavior allows more code to compile than older versions of the borrow checker while still strictly preventing errors caused by conflicting borrows.
Example: Mutable Reference (Exclusive Access)
You can only have one mutable reference to a piece of data in a particular scope. Furthermore, the variable bound to the data must be declared mut
to allow mutable borrowing.
fn main() { let mut s = String::from("hello"); // Must be `mut` to borrow mutably let r1 = &mut s; // One mutable borrow // The following lines would cause compile-time errors if uncommented: // let r2 = &mut s; // Error: Cannot have a second mutable borrow. // let r3 = &s; // Error: Cannot have an immutable borrow while a mutable one exists. // s.push_str("!"); // Error: Cannot access owner directly while mutably borrowed. r1.push_str(" world"); // Modify data through the mutable reference println!("r1: {}", r1); } // r1 goes out of scope here. The mutable borrow ends.
6.3.3 Why These Rules Benefit Single-Threaded Code
The borrowing rules, especially the “one &mut
XOR multiple &
” rule (Mutability Exclusivity), might seem overly strict if you’re only thinking about multi-threaded data races. However, they are fundamental to Rust’s safety and predictability guarantees even in single-threaded code.
Consider the following example, which Rust refuses to compile:
fn main() { let mut v = vec![1, 2, 3]; let first = &v[0]; // immutable borrow occurs here v.push(4); // mutable borrow occurs here println!("{:?} {}", v, first); // immutable borrow later used here }
This code attempts to keep an immutable reference to an element of a vector while later modifying the vector. Rust rejects this pattern because changes to the vector, such as inserting a new element, may require reallocating its internal memory buffer. Such reallocation would move the elements in memory and make existing references invalid, potentially leading to undefined behavior.
Without Rust strict aliasing rules, several subtle but serious problems could arise:
-
Iterator Invalidation: Imagine iterating over a
Vec<T>
while simultaneously holding another reference that adds or removes elements from it. This could lead to skipping elements, processing garbage data, or crashing. C++ programmers are familiar with similar issues where modifying a container invalidates its iterators. Rust’s rules prevent modifying theVec
(via&mut
) while immutable references (used by the iterator) exist. -
Data Structure Integrity: Consider an enum with variants like
Int(i32)
andText(String)
. If multiple mutable references were allowed, one reference might be interacting with theText
variant (e.g., reading theString
’s length or characters). Simultaneously, another mutable reference could change the enum’s variant toInt(42)
. This would overwrite the memory that the first reference assumes holds validString
metadata (like its pointer, length, and capacity). Attempting to use theString
through the first reference after this change would lead to accessing invalid data or memory corruption. Rust’s borrowing rules prevent this entirely by ensuring only one mutable reference can exist at a time, guaranteeing that such conflicting modifications cannot happen simultaneously and preserving data structure integrity. -
Unpredictable State: If multiple mutable references (
&mut T
) could alias the same data, calling methods through one reference could unexpectedly change the state observed through another, leading to complex, hard-to-debug logic errors. The exclusivity rule ensures that when you modify data through a mutable reference, you have sole permission during that borrow’s lifetime. -
Ambiguity and Undefined Behavior: Consider how C handles aliased mutable pointers:
#include <stdio.h> void modify(int *a, int *b) { *a = 42; // Write through pointer a *b = 99; // Write through pointer b // If a and b point to the same location, what is the final value? } int main() { int x = 10; modify(&x, &x); // Pass the same address twice // The C standard considers this potentially undefined behavior depending // on optimizations. The compiler might assume a and b don't alias. printf("x = %d\n", x); // Could print 42 or 99? return 0; }
The C compiler might optimize based on the assumption that
a
andb
point to different locations. If they alias, the result becomes unpredictable. Rust’s borrow checker forbids creating such ambiguous aliased mutable references in safe code, preventing this class of errors at compile time.
In summary, the borrowing rules eliminate many potential pitfalls familiar from C/C++, ensuring data consistency and predictable behavior even without considering threads. They also enable the compiler to perform more aggressive optimizations safely.
Invalid Reference Example (Dangling Pointer Prevention)
Rust also prevents references from outliving the data they point to:
fn main() { let reference_to_nothing = dangle(); } fn dangle() -> &String { // Tries to return a reference to a String let s = String::from("hello"); // s is created inside dangle &s // Return a reference to s } // s goes out of scope and is dropped here. Its memory is freed. // The returned reference would point to invalid memory!
The compiler rejects this code because the reference &s
would outlive the owner s
. This is handled by Rust’s lifetime system, ensuring references are always valid.