7.4 Borrowing and Lifetimes

Borrowing: Safe References in Rust
Section titled “Borrowing: Safe References in Rust”Introduction to References
Section titled “Introduction to References”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 5Mutable 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 worldThe 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:
- One or more immutable references (
&T): This is safe because multiple readers accessing the same data concurrently will not cause issues. - 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 hereValid 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.
🎬Code Demo: Borrowing
Section titled “🎬Code Demo: Borrowing”Immutable & Mutable Borrows in Rust
Preventing Data Races and Aliasing
Section titled “Preventing Data Races and Aliasing”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.

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.
| Feature | C++ | Rust |
|---|---|---|
| Reference Types | const T& (immutable), T& (mutable) | &T (immutable), &mut T (mutable) |
| Multiple Immutable References | Allowed | Allowed (any number) |
| Multiple Mutable References | Allowed (via raw pointers/aliasing, leads to unreliable behaviour/data races) | Not allowed simultaneously in same scope (compile-time error) |
| Mixing Mutable/Immutable References | Allowed (via raw pointers/aliasing, leads to unreliable behaviour/data races) | Not allowed simultaneously in same scope (compile-time error) |
| Data Race Prevention | Manual synchronisation (mutexes, locks); const correctness; __restrict__ hint | Compile-time Borrow Checker enforcement (guaranteed in safe Rust) |
🧩Knowledge Check
Section titled “🧩Knowledge Check”Which of the following is true regarding immutable references (&T) in Rust?
What happens if you attempt to create a mutable reference (&mut T) while immutable references (&T) are still active in the same scope?
How does Rust's borrowing system prevent data races in concurrent code?
Lifetimes: Ensuring Reference Validity
Section titled “Lifetimes: Ensuring Reference Validity”The Problem of Dangling References
Section titled “The Problem of Dangling References”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.
Lifetimes as Compile-Time Guarantees
Section titled “Lifetimes as Compile-Time Guarantees”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:
helloIf 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 parameterLifetime 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 inputsfn longest<'a>(x: &'a str, y: &'a str) -> &'a str { if x.len() > y.len() { x } else { y }}
// Struct holding a reference requires lifetime annotationstruct ImportantExcerpt<'a> { part: &'a str,}
fn main() { // not relevant}🎬Code Demo: Lifetimes
Section titled “🎬Code Demo: Lifetimes”Lifetimes in Rust
Relation to RAII
Section titled “Relation to RAII”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.
🧩Knowledge Check
Section titled “🧩Knowledge Check”Match Lifetime Concepts
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: helloLiteral word: anotherC++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.
🎬Code Demo: Slices
Section titled “🎬Code Demo: Slices”Array & String Slices in Rust
🧩Knowledge Check
Section titled “🧩Knowledge Check”Match Slice Concepts
© 2026 Derek Molloy, Dublin City University. All rights reserved.