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 fundamental types of references, which are commonly called “immutable” and “mutable” references, but are more precisely understood as providing shared access and exclusive access, respectively:
- Shared References (
&T
): Allow read-only access to the borrowed data. Multiple shared references to the same data can coexist concurrently. - Exclusive References (
&mut T
): Allow read-write access to the borrowed data. Only one exclusive reference to a given piece of data can exist at any time.
Using the terms “shared” and “exclusive” helps clarify the fundamental nature of Rust’s borrowing rules: &T
allows data to be shared among multiple readers, while &mut T
grants exclusive permission to modify the data. This distinction is crucial for Rust’s compile-time memory safety.
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) |
Access Rules | Strict compile-time rules (one exclusive XOR multiple shared) | 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
(a shared reference), the compiler automatically calls it as(&owned_string).len()
. - If
string_ref
is&String
(a shared reference) 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: 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).
- Access Exclusivity: At any given time, you can have either one exclusive reference (
&mut T
) or any number of shared references (&T
) to the same piece of data. You cannot have both types of references active to the same data simultaneously.
Rule 2 ensures that you cannot obtain an exclusive reference while any shared references exist to the same data, nor can you obtain (or keep active) multiple exclusive references simultaneously. This “one or many” rule is fundamental to preventing data races and ensuring safe mutation.
Example: Shared References (Aliasing Allowed)
You can have multiple shared (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 exclusive borrows (&mut T
) can be taken or if the owner can be directly modified, not whether shared borrows (&T
) are permitted.
fn main() { let s1 = String::from("hello"); // Owner is not mutable let r1 = &s1; // Shared borrow from immutable owner let r2 = &s1; // Another shared borrow println!("r1: {}, r2: {}", r1, r2); // OK let mut s2 = String::from("hello"); // Owner is mutable let r3 = &s2; // Shared borrow from mutable owner is fine let r4 = &s2; // Multiple shared borrows are fine println!("r3: {}, r4: {}", r3, r4); // Also OK }
This is safe because shared 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) Shared borrow starts println!("r1: {}, s1: {}", r1, s1); // (2) Last use of r1 (in the success case) s1.push('!'); // (3) Needs exclusive 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:
- A shared borrow
r1
begins. r1
is used in theprintln!
.s1.push('!')
attempts to take an exclusive borrow ofs1
. This is only allowed if no shared 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 shared borrowr1
is considered finished after that point. Therefore, the exclusive 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 shared borrowr1
must remain active until line (4). This meansr1
is still active when line (3) (s1.push('!')
) tries to take an exclusive 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: Exclusive Reference (Exclusive Access)
You can only have one exclusive (mutable) reference to a piece of data in a particular scope. Furthermore, the variable bound to the data must be declared mut
to allow exclusive borrowing.
fn main() { let mut s = String::from("hello"); // Must be `mut` to borrow exclusively let r1 = &mut s; // One exclusive borrow // The following lines would cause compile-time errors if uncommented: // let r2 = &mut s; // Error: Cannot have a second exclusive borrow. // let r3 = &s; // Error: Cannot have a shared borrow while an exclusive one exists. // s.push_str("!"); // Error: Cannot access owner directly while exclusively borrowed. r1.push_str(" world"); // Modify data through the exclusive reference println!("r1: {}", r1); } // r1 goes out of scope here. The exclusive borrow ends.
6.3.3 Why These Rules Benefit Single-Threaded Code
The borrowing rules, especially the “one exclusive (&mut
) XOR many shared (&
)” rule (Access 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]; // shared borrow occurs here v.push(4); // exclusive borrow occurs here println!("{:?} {}", v, first); // shared borrow later used here }
This code attempts to keep a shared 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 (e.g., using a dangling pointer).
Without Rust’s 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 an exclusive reference) while shared references (used by the iterator) exist. -
Data Structure Integrity: Consider an enum with variants like
Int(i32)
andText(String)
. If multiple exclusive references were allowed, one reference might be interacting with theText
variant (e.g., reading theString
’s length or characters). Simultaneously, another exclusive 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 exclusive reference can exist at a time, guaranteeing that such conflicting modifications cannot happen simultaneously and preserving data structure integrity. -
Unpredictable State: If multiple exclusive 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 an exclusive 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 exclusive 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 shared 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.