Skip to content

Search is only available in production builds. Try building and previewing the site to test it out locally.

7.4 Borrowing and Lifetimes

While ownership effectively manages resource deallocation, passing ownership of data around can be inefficient or impractical for many common programming patterns, such as simply reading a value in a function. Rust’s borrowing mechanism allows temporary access to data without taking ownership, conceptually similar to passing references or pointers in C++.

A reference in Rust is a handle to data, analogous to a C++ reference or pointer, but with crucial compile-time rules enforced by the borrow checker. A binding that borrows a resource does not deallocate it when it goes out of scope, allowing the original owner to retain and reuse the resource after the borrowing function or block completes.

References can be immutable, or mutable:

Immutable References (&T): By default, references in Rust are immutable. Multiple immutable references to a piece of data can exist simultaneously. These references permit reading the data but strictly prohibit its modification. This behaviour is akin to passing by constant reference (const T&) in C++.

fn calculate_length(s: &String) -> usize { // an immutable reference
s.len()
}
fn main() {
let s1 = String::from("hello");
let len = calculate_length(&s1); // Pass immutable reference
println!("The length of '{}' is {}", s1, len); // s1 still usable after call
}

This gives the output:

The length of 'hello' is 5

Mutable References (&mut T): To modify data through a reference, a mutable reference is required. The variable being borrowed must also be explicitly declared mut. This is similar to passing by reference (a non-const T&) in C++ or calling a non-const method on an object.

fn modify_string(s: &mut String) { // s is a mutable reference
s.push_str(" world");
}
fn main() {
let mut s = String::from("hello"); // s must be mutable here
modify_string(&mut s); // Pass a mutable reference
println!("{}", s); // Output: hello world
}

This gives the output:

Hello world

The Borrowing Rules: “One Mutable OR Many Immutable”

Section titled “The Borrowing Rules: “One Mutable OR Many Immutable””

This principle is the cornerstone of Rust’s compile-time data race prevention and memory safety. At any given time, for a specific piece of data, you can have either:

  1. One or more immutable references (&T): This is safe because multiple readers accessing the same data concurrently will not cause issues.
  2. Exactly one mutable reference (&mut T): This ensures exclusive write access, preventing any other access (read or write) to the data while it is being modified, thereby eliminating data races.

Crucially, you cannot have both mutable and immutable references to the same data simultaneously within the same scope. For example, here are some invalid and valid examples.

Invalid Example (Multiple Mutable References):

fn main() {
let mut s = String::from("hello");
let s1 = &mut s;
let s2 = &mut s; // ERROR: cannot borrow s as mutable more than once at a time
println!("{}, {}", s1, s2);
}

This will result in an error as there are two mutable references to the same data:

cannot borrow `s` as mutable more than once at a time second mutable borrow occurs here

Valid Example (Multiple Mutable References in Different Scopes): The borrow checker understands scope and so creating a new block of scope for s1 there is no conflict.

fn main() {
let mut s = String::from("hello");
{
let s1 = &mut s; // s1 is valid only within this inner scope
s1.push_str(" world");
} // s1 goes out of scope here, releasing the mutable borrow
let s2 = &mut s; // s2 can now be created as s1 is no longer active
s2.push_str("!");
println!("{}", s2); // Output: hello world!
}

This gives the simple output:

hello world!

The following code demonstration give you some valid and invalid examples of borrowing.

Rust concept demo

Immutable & Mutable Borrows in Rust

src/main.rs
1fn main() {
2 let s = String::from("hello");
3 let r1 = &s;
4 let r2 = &s;
5 let r3 = &s;
6 println!("{r1} / {r2} / {r3}");
7}
terminal — cargo run
$ cargo run
hello / hello / hello
What just happened

A shared borrow `&s` gives read-only access to `s` without taking ownership. As many `&s` references can coexist as you like — they all see the same value, none can change it, and `s` itself stays usable too. Read-only access is safe to share, so Rust doesn't restrict it.

The strict borrowing rules directly prevent data races, which are a common source of bugs in concurrent C/C++ programs. A data race occurs when two or more pointers or references access the same memory concurrently, at least one of them is writing, and the operations are not synchronised. By enforcing exclusive mutable access, Rust guarantees data race freedom in safe code at compile time.

Stop Sign

Figure 1.  illustration of the Knights of C++ fighting the Borrow Dragon (a beast summoned from the depths of Rustonia). Sir Lvalue of Reference drew his mighty sword &Excalibur. “Hold steady, all!” he shouted. “The dragon is fearsome but fickle — its rules are strict but fair!” The Borrow Dragon narrowed its eyes, approving the code at last: “Compiling… finished successfully.” A great cheer went up. The knights sheathed their swords, their template metaprogramming scars glowing faintly. They had not slain the Borrow Dragon — for it cannot be slain — but they had earned its respect. You will be that knight!

The strictness of Rust’s borrowing rules, particularly the “one mutable OR many immutable” principle and scope limitations, is a significant ergonomic and design constraint. This can be particularly challenging for complex data structures like graphs or self-referential objects. The often-cited “fight with the borrow checker” is a direct consequence of this. However, this perceived burden is an explicit trade-off for the strong compile-time guarantees; it is a deliberate design choice prioritising static safety and performance over runtime flexibility.

Table 5: Borrowing Rules and Concurrency (C++ vs. Rust) This table directly contrasts the core borrowing rules and their implications for concurrency and memory safety. It illustrates how Rust’s compile-time guarantees fundamentally differ from C++‘s reliance on manual enforcement and the potential for subtle, hard-to-debug undefined behaviour. This comparison is central to understanding how Rust prevents common pitfalls that C/C++ developers routinely encounter.

FeatureC++Rust
Reference Typesconst T& (immutable), T& (mutable)&T (immutable), &mut T (mutable)
Multiple Immutable ReferencesAllowedAllowed (any number)
Multiple Mutable ReferencesAllowed (via raw pointers/aliasing, leads to unreliable behaviour/data races)Not allowed simultaneously in same scope (compile-time error)
Mixing Mutable/Immutable ReferencesAllowed (via raw pointers/aliasing, leads to unreliable behaviour/data races)Not allowed simultaneously in same scope (compile-time error)
Data Race PreventionManual synchronisation (mutexes, locks); const correctness; __restrict__ hintCompile-time Borrow Checker enforcement (guaranteed in safe Rust)
Knowledge Check

Which of the following is true regarding immutable references (&T) in Rust?

Only one immutable reference can exist for a specific piece of data at any given time.
Multiple immutable references can exist simultaneously, allowing multiple readers.
Immutable references allow you to modify the data if the owner is mutable.
An immutable reference automatically takes ownership of the data.
Knowledge Check

What happens if you attempt to create a mutable reference (&mut T) while immutable references (&T) are still active in the same scope?

Knowledge Check

How does Rust's borrowing system prevent data races in concurrent code?

A common and dangerous form of bug in C/C++ is the “dangling pointer” or “use-after-free” errors. These occur when a pointer or reference points to memory that has already been deallocated or gone out of scope, leading to unpredictable program behaviour, crashes, or severe security vulnerabilities. This is particularly common with references to stack-allocated variables that outlive their scope. For example:

C++ Example:

int* dangle_cpp() {
int x = 5; // 'x' is stack-allocated
return &x; // Returning the address of a stack-allocated variable
} // 'x' is deallocated here as the function returns.
// The returned pointer is now dangling.
int main() {
int* ptr = dangle_cpp();
// std::cout << *ptr; // Undefined: dereferencing a dangling pointer
return 0;
}

The reason this code is so problematic is that when dangle_cpp() is called, int x = 5; allocates space for x on the function’s call stack. This memory is only valid within the scope of dangle_cpp(). As soon as dangle_cpp() finishes executing and returns, the memory occupied by x on the stack is automatically deallocated. It’s now considered free and can be overwritten by subsequent function calls. The pointer &x is returned from dangle_cpp() to main().

Although ptr in main() holds the address that x used to occupy, that memory is no longer valid or owned by x. The pointer ptr now points to memory that is no longer guaranteed to contain 5 and could be used by other parts of the program. Attempting to dereference *ptr results in undefined behaviour, as you are accessing memory that is no longer valid for x. This is a classic dangling reference problem.

Rust’s lifetime system is precisely designed to prevent dangling references by ensuring that all references remain valid for their entire usage. Lifetimes are a compile-time concept that tracks the valid scope for which a reference is guaranteed to point to valid data. They are not runtime constructs and do not incur any runtime overhead.

The compiler uses lifetimes to check that the “lifetime of a reference” (how long the reference is used) does not exceed the “lifetime of the value” it points to (how long the value remains valid and in memory). If this fundamental rule is violated, the code will not compile, preventing the creation of dangling references.

Rust Example (Preventing Dangling References):

// Need to comment out the dangle_rust() function for this code to work
fn dangle_rust() -> &String { // Would cause a compile-time error
let s = String::from("hello"); // s is created here
&s // Attempting to return a reference to s
} // ERROR: s does not live long enough.
// s is dropped here, making the returned reference dangling.
fn no_dangle_rust() -> String { // Corrected: Return ownership
let s = String::from("hello");
s // Ownership of s is moved out of the function, preventing dangling
}
fn main() {
let s = no_dangle_rust(); // s in main() now owns the String
println!("{}", s); // Valid: Output: hello
}

We haven’t yet covered the syntax of function calls, so don’t spend much time on the syntax — focus on the concept only. If you comment out the first function, you get the output:

hello

If the dangle_rust() function is included you would get the following error:

error[E0106]: missing lifetime specifier
--> src\main.rs:2:22
|
2 | fn dangle_rust() -> &String { // This function would cause a compile-time error
| ^ expected named lifetime parameter

Lifetime Elision and Explicit Annotations: For Reference Only (Advanced)

Section titled “Lifetime Elision and Explicit Annotations: For Reference Only (Advanced)”

While lifetimes are an intrinsic part of every reference in Rust, the compiler can often infer them automatically through “lifetime elision rules” for common, unambiguous patterns. For instance, a function taking one input reference and returning one output reference will typically assume they have the same lifetime. This significantly reduces the need for explicit lifetime annotations, making the language less verbose.

However, in more complex scenarios, such as functions with multiple input references where the output’s lifetime depends on a specific input, or struct definitions that hold references, explicit lifetime annotations using an apostrophe (e.g., 'a, 'b) are required. These annotations do not alter runtime behaviour; they are purely for guiding the borrow checker’s static analysis and ensuring correctness. The '(apostrophe) is always placed before the name of the lifetime parameter, e.g., 'a (read as “tick a” or “lifetime a”).

// Explicit lifetime annotation for a function where output lifetime depends on inputs
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() { x } else { y }
}
// Struct holding a reference requires lifetime annotation
struct ImportantExcerpt<'a> {
part: &'a str,
}
fn main() { // not relevant
}
Rust concept demo

Lifetimes in Rust

src/main.rs
1fn main() {
2 let r;
3 {
4 let x = 5;
5 r = &x;
6 }
7 println!("r = {r}");
8}
terminal — cargo build
$ cargo build
error[E0597]: `x` does not live long enough
--> src/main.rs:5:13
|
4 | let x = 5;
| - binding `x` declared here
5 | r = &x;
| ^^ borrowed value does not live long enough
6 | }
| - `x` dropped here while still borrowed
7 | println!("r = {r}");
| - borrow later used here
What the compiler is telling you

Every value has a **lifetime** — the span of code during which it is alive. `x` is declared inside the inner block, so its lifetime ends at the closing `}`. But `r` is declared in the outer scope and is still in use on the `println!` line. A reference cannot outlive what it points at, so the compiler refuses. This is the classic dangling-pointer bug — caught at compile time, before the program ever runs.

Rust’s lifetime system can be seen as an evolution of C++‘s RAII (Resource Acquisition Is Initialisation) principle. RAII ties resource management, such as memory allocation and deallocation, to the lifetime of an object, ensuring resources are acquired in constructors and released in destructors when the object goes out of scope.

While C++ RAII primarily focuses on deallocation, for example, std::unique_ptr automatically freeing heap memory, Rust’s lifetimes extend this by formally guaranteeing the validity of references at compile time. This means Rust provides a compile-time “proof of correctness” that references will not outlive the data they point to, a critical guarantee that C++ compilers cannot provide without additional static analysis tools or meticulous manual effort. The difficulty of retrofitting this comprehensive lifetime system into C++‘s existing type system underscores how deeply integrated it is into Rust’s fundamental design. Rust’s compiler can statically verify that a reference will always point to valid, allocated memory, preventing an entire class of subtle and dangerous bugs (use-after-free, dangling pointers) that C++ programmers must manually guard against. The fact that adding such a system to C++ would “break all code written in the last 35 years” highlights how deeply integrated this concept is into Rust’s type system and compiler design, rather than being an optional add-on. It represents a proactive, static approach to reference safety, unlike C++‘s reactive, runtime-dependent model.

The concept of “scope” is strongly linked to lifetimes. Lifetimes are essentially the compiler’s strict way of tracking the valid scope of a reference relative to its owner, even when that scope is not explicitly delineated by curly braces. Lifetime elision is the compiler’s intelligent ability to infer these relationships in common scenarios, making the language less verbose while maintaining its strict safety guarantees.

Concept Match

Match Lifetime Concepts

Drag each definition into its matching concept slot, then click Submit. Tap × to return a placed card to the pool.

Dangling Reference
drag a definition here…
Lifetimes
drag a definition here…
Lifetime Elision
drag a definition here…
Explicit Annotation
drag a definition here…
RAII Evolution
drag a definition here…

Definition Pool

Rules that allow the compiler to automatically infer lifetimes, making code less verbose.
A compile-time system that ensures references remain valid for their entire usage.
Syntax (e.g., 'a) used in complex scenarios to guide the borrow checker's analysis.
How Rust extends C++ resource management by formally proving reference validity at compile time.
A reference that points to memory that has been deallocated or gone out of scope.

The Slice Type: Efficient Views into Collections

Section titled “The Slice Type: Efficient Views into Collections”

The Rust slice type provides a powerful and safe mechanism for referencing contiguous sequences of elements within a collection without taking ownership of the entire collection.

What are Slices? Slices, denoted by &, are essentially references to a portion of an existing collection, such as an array or a Vec. As a type of reference, slices do not own the underlying data; they merely provide a “view” into it. Internally, a slice is represented as a pointer to the beginning of the data segment and a length, indicating the number of elements it encompasses.

Array Slices (&) The general slice type & can be used with any array or vector, providing a flexible way to work with sub-sections of these collections.

fn main() {
let a = [10, 20, 30, 40, 50]; // Define an array of five integers
let slice = &a[1..3]; // Create a slice from index 1 to 2, giving [20, 30]
assert_eq!(slice, &[20, 30]); // Check at run time that the slice matches [20, 30]
println!("The slice of {:?} is {:?}", a, slice);
}

Gives the output:

The slice of [10, 20, 30, 40, 50] is [20, 30]

In C++, the C++20 standard introduced std::span<T>, which offers similar functionality as a non-owning view into a contiguous sequence of objects.

String Slices (&str) A specialised form of slice is the string slice, &str, which is used for UTF-8 encoded string data. An &str can be a reference to a part of a String (Rust’s growable, heap-allocated string type) or a string literal. Notably, string literals in Rust are inherently &str. The underlying str type is an “unsized type,” meaning it does not have a compile-time known size and must always be used behind a pointer or reference, such as &str. For example,

fn first_word(s: &str) -> &str { // Function takes a string slice
let bytes = s.as_bytes();
for (i, &item) in bytes.iter().enumerate() {
if item == b' ' {
return &s[0..i];
}
}
&s[..] // Returns slice of entire string if no space is found
}
fn main() {
let s = String::from("hello world");
let word = first_word(&s); // Pass reference to String (deref coercion allows this)
println!("First word: {}", word); // Output: First word: hello
let literal_word = first_word("another example"); // Pass str literal (&str)
println!("Literal word: {}", literal_word); // Output: Literal word: another
}

Gives the output:

First word: hello
Literal word: another

C++17’s std::string_view provides similar non-owning string slicing capabilities, analogous to Rust’s &str. A C-style string literal (const char*) also functions as a read-only view into string data.

Slices offer a powerful combination of memory safety and efficiency by allowing access to portions of data without incurring the overhead of copying. This is particularly crucial for performance-sensitive applications. Slices contribute to memory safety by tightly coupling the slice’s lifetime to that of the underlying data. If the original data is modified in a way that would invalidate the slice (e.g., by reallocating the underlying Vec) or goes out of scope while a slice is still active, the Rust compiler will produce a compile-time error. This proactive prevention eliminates a class of bugs common in C/C++, where pointers or references to parts of a collection can become invalid if the collection reallocates or is modified, leading to use-after-free or other undefined behaviours.

Slices (&, &str) are a great example of “zero-cost abstractions” in Rust. They enable functions to operate on large data buffers (e.g., image data, network packets, sensor readings) without incurring the performance penalty of copying or allocating new memory, which is essential for engineering students working on performance-sensitive applications in edge programming. Furthermore, the practice of using &str as a function parameter makes APIs more flexible and generalisable. Such functions can accept both references to String values and string literals. This reduces the API surface area and increases code reusability. This design paradigm promotes highly efficient data manipulation patterns that are statically guaranteed to be safe, a critical advantage in resource-constrained and high-performance computing environments.

Rust concept demo

Array & String Slices in Rust

src/main.rs
1fn main() {
2 let arr = [10, 20, 30, 40, 50];
3 let s: &[i32] = &arr[1..4];
4 println!("{s:?}");
5}
terminal — cargo run
$ cargo run
[20, 30, 40]
What just happened

A slice is a *view* into a contiguous run of elements — it doesn't own anything, it just borrows. The range `1..4` is half-open: it includes index 1 but stops before index 4, giving you elements at positions 1, 2, and 3. Under the hood `s` is a 16-byte fat pointer: an address (pointing at `arr[1]`) plus a length (3). No allocation, no copy — slicing is essentially free.

Concept Match

Match Slice Concepts

Drag each definition into its matching concept slot, then click Submit. Tap × to return a placed card to the pool.

Slice (&[T])
drag a definition here…
String Slice (&str)
drag a definition here…
Zero-cost View
drag a definition here…
Pointer and Length
drag a definition here…
Static Safety
drag a definition here…

Definition Pool

A specialized slice for UTF-8 data, providing a view into a String or literal.
The internal representation of a slice, pointing to a start index with a specific count.
A non-owning reference to a contiguous sequence of elements within a collection.
How the compiler prevents use-after-free if the underlying collection is modified or dropped.
Accessing data portions without copying or extra allocation, essential for edge performance.