Borrowing and References in Rust: The Art of Safe Memory Sharing

10 min read 2133 words

Table of Contents

In our previous exploration of Rust’s ownership system, we established how Rust manages memory through a set of compile-time rules that track the ownership of values. While ownership provides the foundation for Rust’s memory safety guarantees, constantly transferring ownership would make code unnecessarily complex and inefficient. This is where Rust’s borrowing system comes into play—a sophisticated mechanism that allows you to use values without transferring ownership.

Borrowing, implemented through references, is what makes Rust’s ownership model practical for everyday programming. It enables multiple parts of your code to access the same data concurrently while still maintaining Rust’s strict safety guarantees. In this comprehensive guide, we’ll dive deep into Rust’s borrowing system, explore the nuances of references, and uncover advanced patterns that will elevate your Rust programming skills.


The Fundamentals of Borrowing

At its core, borrowing in Rust means gaining temporary access to a value without taking ownership of it. This is achieved through references, which are created using the ampersand (&) operator.

Creating References

A reference is like a pointer that allows you to refer to a value without owning it:

fn main() {
    let original = String::from("hello");
    let reference = &original; // Immutable reference to original
    
    println!("Original: {}", original);
    println!("Reference: {}", reference);
} // Both original and reference go out of scope, but only original is dropped

In this example, reference borrows original without taking ownership. When reference goes out of scope, nothing special happens because it doesn’t own the data it points to.

References vs. Pointers

Unlike raw pointers in languages like C or C++, Rust references are always valid—they can never be null or dangling. The compiler ensures that references always point to valid data for their entire lifetime.

fn main() {
    let reference;
    {
        let temporary = String::from("temporary value");
        // reference = &temporary; // This would cause a compile error
    } // temporary is dropped here
    
    // println!("{}", reference); // Using reference here would be unsafe
}

This code fails to compile because Rust prevents you from creating a reference to data that won’t live long enough—a common source of bugs in other languages.


Immutable and Mutable References

Rust distinguishes between two types of references: immutable and mutable.

Immutable References

Immutable references (&T) allow you to read but not modify the borrowed data:

fn main() {
    let data = String::from("hello");
    let ref1 = &data;
    let ref2 = &data;
    let ref3 = &data;
    
    // Multiple immutable references are allowed
    println!("{}, {}, {}", ref1, ref2, ref3);
}

You can have as many immutable references as you want because read-only access can’t cause data races or other memory safety issues.

Mutable References

Mutable references (&mut T) allow you to modify the borrowed data:

fn main() {
    let mut data = String::from("hello");
    let ref_mut = &mut data;
    
    ref_mut.push_str(" world"); // Modifying through the mutable reference
    
    println!("{}", ref_mut); // Prints "hello world"
}

However, mutable references come with strict rules:

  1. You can have only one mutable reference to a particular piece of data at a time
  2. You cannot have both mutable and immutable references to the same data simultaneously

These rules prevent data races at compile time:

fn main() {
    let mut data = String::from("hello");
    
    let ref_mut1 = &mut data;
    // let ref_mut2 = &mut data; // Error: cannot borrow as mutable more than once
    
    ref_mut1.push_str(" world");
    println!("{}", ref_mut1);
}

The Borrowing Rules in Detail

Let’s examine Rust’s borrowing rules more closely:

Rule 1: Any Borrow Must Last for a Scope No Greater Than That of the Owner

References cannot outlive the data they refer to:

fn main() {
    let reference;
    
    {
        let owner = String::from("hello");
        reference = &owner; // Error: owner does not live long enough
    } // owner is dropped here
    
    println!("{}", reference); // Would be using an invalid reference
}

Rule 2: One or the Other (But Not Both)

You can have either:

  • One or more immutable references (&T)
  • Exactly one mutable reference (&mut T)
fn main() {
    let mut data = String::from("hello");
    
    let immutable = &data; // First immutable borrow
    let immutable2 = &data; // Second immutable borrow - OK
    
    // let mutable = &mut data; // Error: cannot borrow as mutable while borrowed as immutable
    
    println!("{}, {}", immutable, immutable2);
    
    // Immutable borrows end here because they're no longer used
    
    let mutable = &mut data; // Now we can borrow as mutable
    mutable.push_str(" world");
    
    println!("{}", mutable);
}

Non-Lexical Lifetimes (NLL)

Modern Rust uses “non-lexical lifetimes” to determine when references are no longer used:

fn main() {
    let mut data = String::from("hello");
    
    let immutable = &data;
    println!("{}", immutable); // Immutable borrow ends here
    
    let mutable = &mut data; // This works because the immutable borrow is no longer used
    mutable.push_str(" world");
    
    println!("{}", mutable);
}

The immutable borrow ends after its last use, not at the end of its lexical scope, allowing the mutable borrow to begin earlier.


Borrowing in Function Parameters

References are commonly used in function parameters to avoid unnecessary ownership transfers:

Immutable References as Parameters

fn calculate_length(s: &String) -> usize {
    s.len()
} // s goes out of scope, but it doesn't have ownership, so nothing happens

fn main() {
    let my_string = String::from("hello world");
    let length = calculate_length(&my_string);
    
    println!("The length of '{}' is {}.", my_string, length);
}

This pattern allows functions to use values without taking ownership, which is particularly useful when you need to use the value after the function call.

Mutable References as Parameters

fn append_exclamation(s: &mut String) {
    s.push_str("!");
}

fn main() {
    let mut my_string = String::from("hello world");
    append_exclamation(&mut my_string);
    
    println!("{}", my_string); // Prints "hello world!"
}

Mutable references allow functions to modify the values they borrow.

Returning References

Functions can also return references, but they must not return references to values created within the function:

// This won't compile
fn create_and_return_reference() -> &String {
    let s = String::from("hello");
    &s // Error: returns a reference to data owned by the current function
} // s is dropped here, so the reference would be invalid

Instead, you can return owned values or references to values that were passed in:

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[..]
}

This function returns a reference to a slice of the input string, which is safe because the reference can’t outlive the original string.


Advanced Borrowing Patterns

As you become more proficient with Rust, you’ll encounter more sophisticated borrowing patterns:

Splitting Borrows

You can borrow different parts of a data structure simultaneously:

fn main() {
    let mut vector = vec![1, 2, 3, 4, 5];
    
    let first = &mut vector[0];
    let second = &mut vector[1];
    
    *first += 10;
    *second += 20;
    
    println!("Vector: {:?}", vector); // Prints [11, 22, 3, 4, 5]
}

This works because the borrow checker can determine that first and second refer to different parts of the vector.

Temporary Borrows in Method Calls

Method calls often involve temporary borrows:

fn main() {
    let mut data = String::from("hello");
    
    // These method calls create temporary borrows
    let length = data.len(); // Immutable borrow
    data.push_str(" world"); // Mutable borrow
    
    println!("'{}' has length {}", data, length);
}

Each method call creates a borrow that lasts only for the duration of the call, allowing subsequent borrows of different kinds.

Interior Mutability

Sometimes you need to mutate data even when you only have an immutable reference. Rust provides safe abstractions for this through the concept of “interior mutability”:

use std::cell::RefCell;

fn main() {
    let data = RefCell::new(String::from("hello"));
    
    {
        let mut borrowed = data.borrow_mut(); // Get a mutable reference
        borrowed.push_str(" world");
    } // mutable borrow ends here
    
    println!("{}", data.borrow()); // Prints "hello world"
}

RefCell enforces Rust’s borrowing rules at runtime instead of compile time, allowing for more flexible borrowing patterns at the cost of some runtime overhead.


Self-Referential Structs and the Pin API

One of the most challenging aspects of Rust’s borrowing system is creating data structures that contain references to their own fields:

struct SelfReferential {
    value: String,
    pointer: *const String, // Raw pointer, not a reference
}

impl SelfReferential {
    fn new(text: &str) -> Self {
        let mut result = SelfReferential {
            value: String::from(text),
            pointer: std::ptr::null(),
        };
        result.pointer = &result.value;
        result
    }
}

This pattern is unsafe and generally discouraged. For safe self-referential structs, Rust provides the Pin API, which ensures that values won’t be moved in memory once they’re pinned:

use std::pin::Pin;
use std::marker::PhantomPinned;

struct SelfReferential {
    value: String,
    pointer_to_value: *const String,
    _pin: PhantomPinned,
}

impl SelfReferential {
    fn new(text: &str) -> Pin<Box<Self>> {
        let mut boxed = Box::new(SelfReferential {
            value: String::from(text),
            pointer_to_value: std::ptr::null(),
            _pin: PhantomPinned,
        });
        
        let self_ptr: *const String = &boxed.value;
        boxed.pointer_to_value = self_ptr;
        
        Pin::new(boxed)
    }
}

The Pin API is particularly important for async/await code, where self-referential structures are common.


Common Borrowing Pitfalls and Solutions

The “Cannot Borrow as Mutable Because It Is Also Borrowed as Immutable” Error

This is one of the most common errors when working with Rust’s borrowing system:

fn main() {
    let mut data = String::from("hello");
    let reference = &data;
    data.push_str(" world"); // Error: cannot borrow as mutable because it is also borrowed as immutable
    println!("{}", reference);
}

Solution: Ensure that immutable borrows are no longer in use before creating mutable borrows:

fn main() {
    let mut data = String::from("hello");
    let reference = &data;
    println!("{}", reference); // Use the immutable reference
    
    data.push_str(" world"); // Now we can mutate data
    println!("{}", data);
}

The “Cannot Move Out of Borrowed Content” Error

This occurs when you try to move a value that’s behind a reference:

fn main() {
    let data = String::from("hello");
    let reference = &data;
    
    let moved = *reference; // Error: cannot move out of borrowed content
}

Solution: Clone the data if you need ownership:

fn main() {
    let data = String::from("hello");
    let reference = &data;
    
    let cloned = reference.clone(); // Create a new owned copy
    println!("{}", cloned);
}

The “Borrowed Value Does Not Live Long Enough” Error

This error occurs when a reference outlives the data it points to:

fn main() {
    let reference;
    {
        let data = String::from("hello");
        reference = &data; // Error: borrowed value does not live long enough
    } // data is dropped here
    println!("{}", reference);
}

Solution: Ensure that referenced data lives at least as long as the references to it:

fn main() {
    let data = String::from("hello");
    let reference = &data;
    
    println!("{}", reference);
} // Both data and reference go out of scope here

Lifetimes: Making Borrowing Explicit

Lifetimes are Rust’s way of tracking how long references are valid. Most of the time, lifetimes are implicit, but sometimes you need to make them explicit:

Function Signatures with Lifetimes

fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() {
        x
    } else {
        y
    }
}

The 'a annotation indicates that the returned reference will be valid as long as both input references are valid.

Struct Definitions with Lifetimes

struct Excerpt<'a> {
    part: &'a str,
}

fn main() {
    let novel = String::from("Call me Ishmael. Some years ago...");
    let first_sentence = novel.split('.').next().unwrap();
    let excerpt = Excerpt {
        part: first_sentence,
    };
    
    println!("{}", excerpt.part);
}

The 'a annotation on the struct indicates that an instance of Excerpt cannot outlive the reference it holds in its part field.

Lifetime Elision Rules

Rust has a set of rules that allow you to omit lifetime annotations in common cases:

  1. Each parameter that is a reference gets its own lifetime parameter
  2. If there is exactly one input lifetime parameter, that lifetime is assigned to all output lifetime parameters
  3. If there are multiple input lifetime parameters, but one of them is &self or &mut self, the lifetime of self is assigned to all output lifetime parameters

These rules cover many common patterns, reducing the need for explicit lifetime annotations.


Conclusion

Rust’s borrowing system is a cornerstone of its approach to memory safety without garbage collection. By enforcing strict rules at compile time, Rust prevents common bugs like use-after-free, data races, and null pointer dereferencing, all while maintaining high performance.

While the borrowing system may initially seem restrictive, it guides you toward writing code that is not only memory-safe but also clear about data ownership and access patterns. As you become more familiar with Rust’s borrowing rules, you’ll find that they lead to more maintainable and robust code.

The concepts we’ve explored—references, borrowing rules, lifetimes, and advanced patterns like interior mutability—form a comprehensive toolkit for effective memory management in Rust. By mastering these concepts, you’ll be well-equipped to write Rust code that is both safe and efficient, leveraging the full power of the language’s innovative approach to memory management.

Remember that the borrow checker is not your enemy but your ally. It catches potential bugs at compile time that would be difficult to track down in languages with more permissive memory models. Embrace its guidance, and you’ll write better code not just in Rust, but in any language you use.

Andrew
Andrew

Andrew is a visionary software engineer and DevOps expert with a proven track record of delivering cutting-edge solutions that drive innovation at Ataiva.com. As a leader on numerous high-profile projects, Andrew brings his exceptional technical expertise and collaborative leadership skills to the table, fostering a culture of agility and excellence within the team. With a passion for architecting scalable systems, automating workflows, and empowering teams, Andrew is a sought-after authority in the field of software development and DevOps.

Tags