Mutable vs Immutable Borrowing in Rust

Borrowing, in its mutable and immutable forms, is a cornerstone of Rust’s promise for memory safety without a garbage collector. While it…

Mutable vs Immutable Borrowing in Rust

Borrowing, in its mutable and immutable forms, is a cornerstone of Rust’s promise for memory safety without a garbage collector. While it might seem daunting at first, getting a firm grasp on this concept is essential for any budding Rust developer.

In this article, we’ll break down the basics, provide clear examples, and help you navigate the intricacies of borrowing.

Let’s get started!

Ownership in Rust

Before we delve into borrowing, it's crucial to understand ownership. In Rust:

  • Each value has a unique owner.
  • The value will be dropped (i.e., its memory is freed) when its owner goes out of scope.
  • Ownership can be transferred from one variable to another with the move operation.
fn main() { 
    let s1 = String::from("hello"); 
    let s2 = s1; // s1 is moved to s2, s1 is no longer valid here 
    println!("{}", s2); // This is fine 
    // println!("{}", s1); // This would throw a compile-time error 
}

Basics of Moving Ownership

When you assign a value from one variable to another or pass a value to a function, the “ownership” of that value is moved. After the move, the original variable can no longer be used.

Here’s a simple example:

let s1 = String::from("hello"); 
let s2 = s1; 
 
println!("{}", s1);  // This will cause a compile-time error!

In the code above, the ownership of the string "hello" is initially with s1. But once we assign s1 to s2, the ownership is moved to s2. This means that s1 is no longer valid, and attempting to use it will cause a compile-time error.

Why Move Instead of Copy?

Rust’s default behavior is to move rather than copy data. This might seem counterintuitive, especially if you come from languages where data is copied implicitly. So why does Rust do this?

  1. Efficiency: Moving ownership is faster than making a copy of data, especially for complex types like String. It involves just transferring the pointer, length, and capacity without copying the actual data.
  2. Avoiding Double Free Errors: If Rust copied the data implicitly for types that manage resources (like String), there could be two variables responsible for freeing the same memory space, leading to double free errors.

Copy Trait

For simple types like integers, copying the data is straightforward and without risk. Rust provides a special trait called Copy for such types. When a type implements the Copy trait, it will be copied instead of moved.

For instance:

let x = 5; 
let y = x; 
 
println!("x = {}, y = {}", x, y);  // This works because integers are Copy.

It’s important to note that you cannot implement both the Copy and Drop traits for a type. If a type requires custom logic to release resources (like memory) when it goes out of scope, it cannot be Copy.

Implications for Functions

The move operation also comes into play when passing values to functions:

fn take_ownership(s: String) {} 
 
let s1 = String::from("hello"); 
take_ownership(s1); 
println!("{}", s1);  // Compile-time error! Ownership was moved to the function.

The function take_ownership takes ownership of the string. Once the function is called, s1 no longer has ownership, and thus it can't be used.

Returning Values and Ownership

Functions can also transfer ownership back to the caller:

fn give_ownership() -> String { 
    String::from("hello") 
} 
 
let s2 = give_ownership(); 
println!("{}", s2);  // This works! s2 now owns the string.

In the code above, the function give_ownership creates a string and transfers its ownership to s2.

Borrowing

Instead of transferring ownership, often, we just want to access the data without owning it. This is where borrowing comes into play.

Immutable Borrowing

You can borrow a value immutably using the & operator. This allows multiple references to read from the same data, but none of them can modify it.

fn main() { 
    let s = String::from("hello"); 
    let len = calculate_length(&s); // We are borrowing s immutably here 
 
println!("The length of '{}' is {}.", s, len); 
} 
fn calculate_length(s: &String) -> usize { 
    s.len() 
}

In this example, the s string is borrowed by the calculate_length function, which then returns the length without taking ownership.

Mutable Borrowing

There are situations where you want to modify the borrowed data. This is where mutable borrowing comes into play. You can borrow a value mutably using the &mut operator.

fn main() { 
    let mut s = String::from("hello"); 
    append_world(&mut s); // We are borrowing s mutably here 
    println!("{}", s); // prints "hello, world" 
} 
 
fn append_world(s: &mut String) { 
    s.push_str(", world"); 
}

The append_world function borrows the string s mutably and appends ", world" to it.

Rules of Borrowing

  1. Either one mutable borrow or multiple immutable borrows: At any given time, you can either have one mutable reference or any number of immutable references to a particular data, but not both. This ensures data races can’t occur.
let mut s = String::from("hello"); let r1 = &mut s; // let r2 = &mut s;  
// This is not allowed!

2. You can’t mix immutable and mutable borrows: Once something has been mutably borrowed, you can’t borrow it immutably until the mutable borrow ends.

let mut s = String::from("hello"); let r1 = &s; // let r2 = &mut s;  
// This is not allowed!

3. Dangling references are prevented: The Rust compiler ensures that references never outlive the data they point to.

Why These Rules?

The rules around borrowing in Rust are built to ensure memory safety without sacrificing performance. By enforcing these rules at compile-time:

  • Concurrency becomes safer: Since Rust guarantees that there’s either a single mutable reference or multiple immutable ones, you won’t run into issues of data races.
  • No garbage collector is needed: Rust’s ownership and borrowing system allow it to manage memory efficiently without needing a garbage collector, resulting in predictable performance.

Beyond the Basics

Once you grasp the fundamental concepts of borrowing in Rust, it’s essential to explore more advanced aspects of the language. These provide additional context and depth to the borrowing mechanism.

Lifetimes

Lifetimes are a way to express the scope of validity of references. They ensure that references don’t outlive the data they point to, preventing dangling references.

For example:

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

Here, the 'a is a lifetime annotation that denotes the scope of the reference. It indicates that the returned reference from the longest function will not outlive either of its input references.

Pattern Matching & Borrowing

When using pattern matching, you can borrow parts of data structures.

let mut tuple: (i32, String) = (5, String::from("hello")); 
 
match tuple { 
    (x, ref mut y) => { 
        *y += " world"; 
        println!("Tuple contains: {} and {}", x, *y); 
    } 
}

In the example above, x takes ownership of the integer, while y mutably borrows the string. This way, we can modify the string without consuming it.


Download Now!


Interior Mutability

In some cases, you may want to mutate data even if you have an immutable reference. Rust offers patterns like RefCell<T> to achieve "interior mutability", allowing you to bypass the usual borrowing rules in a controlled manner.

For instance:

use std::cell::RefCell; 
 
let data = RefCell::new(5); 
{ 
    let mut mutable_reference = data.borrow_mut(); 
    *mutable_reference += 1; 
} 
println!("data = {}", data.borrow());

Keep in mind that while RefCell<T> allows runtime borrowing checks, breaking the borrowing rules at runtime will panic.

Referencing and Dereferencing

In Rust, method call syntax often requires references to work correctly. To make this more ergonomic, Rust has a feature called “automatic referencing and dereferencing.” This feature allows the compiler to automatically add or remove &, &mut, or * so that methods can be called on values or their references seamlessly.

Dereferencing

Dereferencing is the process of converting a reference back into its underlying value. In Rust, the dereference operator is *.

For instance:

let x = 5; 
let y = &x; 
 
assert_eq!(5, *y);

Here, y is a reference to x, and to get the actual value of x through y, we use the * operator.

Automatic Referencing

When you call a method in Rust, the compiler automatically adds & or &mut to match the method signature. This means that if a method takes a reference, you can call that method with a value. Conversely, if a method takes a value, you can call it with a reference, and the compiler will automatically dereference it for you.

Consider a simple example:

fn main() { 
    let s = String::from("hello"); 
    let len = s.len();  // Behind the scenes, Rust is turning s into &s for this method call. 
}

The method len on String is defined with &self:

impl String { 
    fn len(&self) -> usize { /*...*/ } 
}

When we call s.len(), Rust automatically references s for us, as if we wrote (&s).len().

Automatic Dereferencing

If the compiler sees that types mismatch, it will check if dereferencing the type fulfills the expected type. If it does, the compiler will automatically dereference it for you.

For example, consider the following code:

fn takes_string(s: String) {} 
 
let s = String::from("hello"); 
takes_string(s);

If we accidentally use a reference:

takes_string(&s); // Error!

The compiler won’t automatically dereference it in this case because it could lead to unexpected behaviors and data races.

Deref Trait

For the automatic dereferencing to work on custom types, Rust provides the Deref trait. Implementing the Deref trait allows you to customize the behavior of the dereference operator * for your own types.

Here’s a simple example:

use std::ops::Deref; 
 
struct MyBox<T>(T); 
impl<T> Deref for MyBox<T> { 
    type Target = T; 
    fn deref(&self) -> &T { 
        &self.0 
    } 
} 
fn main() { 
    let x = 5; 
    let y = MyBox(x); 
    assert_eq!(5, *y); 
}

In this example, the *y expression will be equivalent to *(y.deref()) due to our implementation of the Deref trait.

Performance Aspects

It’s also worth mentioning that Rust’s borrowing and lifetime rules have a negligible runtime cost. All checks are performed at compile time, so there’s no overhead during execution. This ensures that Rust programs are not only safe but also efficient.

Tips for Effective Borrowing

  1. Embrace the borrow checker: It’s easy to get frustrated with the borrow checker when starting with Rust. Instead of fighting it, try to understand the errors. The borrow checker is there to help you write safe code.
  2. Reduce scope: If you’re facing borrowing issues, consider reducing the scope of your borrows. The shorter the duration of a borrow, the less likely it is to conflict with others.
  3. Refactoring: Sometimes, the best way to resolve borrowing conflicts is by restructuring your code. Functions, for instance, can be split, or data can be cloned when it’s more efficient to do so.

Okay, we’ve covered quite a bit about Rust’s ownership and move operation. It can seem a bit overwhelming at first, but remember, it’s all designed to help you write safer and more efficient code.

As you keep working with Rust, these concepts will become more familiar, and their benefits clearer. Stick with it, practice often, and soon enough, the intricacies of Rust’s ownership model will become second nature.

Keep going, and happy coding!


Download Now!


Check out some interesting hands-on Rust articles!

🌟 Developing a Fully Functional API Gateway in Rust — Discover how to set up a robust and scalable gateway that stands as the frontline for your microservices.

🌟 Implementing a Network Traffic Analyzer — Ever wondered about the data packets zooming through your network? Unravel their mysteries with this deep dive into network analysis.

🌟 Building an Application Container in Rust — Join us in creating a lightweight, performant, and secure container from scratch! Docker’s got nothing on this. 😉

🌟 Crafting a Secure Server-to-Server Handshake with Rust & OpenSSL — 
If you’ve been itching to give your servers a unique secret handshake that only they understand, you’ve come to the right place. Today, we’re venturing into the world of secure server-to-server handshakes, using the powerful combo of Rust and OpenSSL.

🌟 Building a Function-as-a-Service (FaaS) in Rust: If you’ve been exploring cloud computing, you’ve likely come across FaaS platforms like AWS Lambda or Google Cloud Functions. In this article, we’ll be creating our own simple FaaS platform using Rust.

🌟 Implementing a P2P Database in Rust: Today, we’re going to roll up our sleeves and get our hands dirty building a Peer-to-Peer (P2P) key-value database.

🌟 Implementing a VPN Server in Rust: Interested in understanding the inner workings of a VPN? Thinking about setting up your own VPN server? Today, we’re taking a straightforward look at how to set up a basic VPN server using Rust.

Read more articles about Rust in my Rust Programming Library!

Visit my Blog for more articles, news, and software engineering stuff!

Follow me on Medium, LinkedIn, and Twitter.

Leave a comment, and drop me a message!

All the best,

Luis Soares

CTO | Tech Lead | Senior Software Engineer | Cloud Solutions Architect | Rust 🦀 | Golang | Java | ML AI & Statistics | Web3 & Blockchain

Read more