Editor’s note: This article was last updated by Eze Sunday on 25 March 2024 to cover advanced borrowing patterns in Rust, including mutable and immutable borrows, insights into the workings of the borrow checker within Rust’s concurrency model, and a brief comparison of memory management in garbage-collected languages and Rust.
You’ve heard a lot about it, you’ve bought into the hype, you’ve seen a couple of video tutorials and articles and now you’re finally ready to give Rust a try!
So you sit down, hands on the keyboard, write a few lines of code, and run the cargo
run command, excited to see whether the program works as expected. You’ve heard that Rust is one of those languages that simply works once it’s compiled, giving you peace of mind. The compiler starts up, you follow the output, and then, suddenly you get the following error:
error[E0382]: borrow of moved value
Okay, I guess it’s not that simple. We’ve got one more concept to learn: the borrow checker — I sometimes refer to it as the memory police 😀.
The borrow checker is an essential feature of the Rust language and part of what makes Rust Rust. It helps you (or forces you) to manage ownership. As chapter four of “The Rust Programming Language” puts it, “Ownership is Rust’s most unique feature, and it enables Rust to make memory safety guarantees without needing a garbage collector.”
In the last few sentences, we’ve mentioned ownership, borrow checker, and garbage collectors. There’s a lot to unpack there, so let’s break it down a bit. In this guide, we’ll look at what the borrow checker does for us (and what it stops us from doing), what guarantees it gives us, and how it compares to other forms of memory management.
I’ll assume that you have some experience writing code in higher-level languages such as Python, JavaScript, and C#, but not necessarily that you’re familiar with how computer memory works.
Let’s talk about memory and memory management for a minute. In most popular programming languages, you don’t need to think about where your variables are stored. You simply declare them and the language runtime takes care of the rest via a garbage collector. This abstracts away how the computer memory actually works and makes it easier and more uniform to work with. This is a good thing.
However, we need to peel back a layer to show how this compares to the borrow checker. We’ll start by looking at the stack and the heap.
Your programs have access to two kinds of memory where they can store values: the stack and the heap. These differ in several ways, but for our sake, the most important difference is that data stored on the stack must have a known, fixed size. Data on the heap can be of any arbitrary size.
What do I mean by size? Size refers to how many bytes it takes to store the data. In broad terms, certain data types, such as Booleans, characters, and integers, have a fixed size. These are easy to put on the stack. On the other hand, data types such as strings, lists, and other collections can be of any arbitrary size. As such, they cannot be stored on the stack. We must instead use the heap.
Because data of arbitrary size can be stored on the heap, the computer needs to find a chunk of memory large enough to fit whatever we’re looking to store. This is time-consuming, and the program doesn’t have direct access to the data as with the stack. Instead, it’s left with a pointer to where the data is stored.
A pointer is pretty much what it says on the tin: it points to some memory address on the heap where the data you’re looking for can be found. There are several pointer tutorials available on the web, and which one works for you depends on your background.
What’s the point of having these two different memory stores? Because of the way the stack works, data access on the stack is fast and easy but requires the data to conform to certain standards. The heap is slower but more versatile and is thus useful when you can’t use the stack.
In garbage-collected languages, you don’t need to worry about what goes on the stack and what goes on the heap. Data that goes on the stack gets dropped once it is out of scope. Data that lives on the heap is taken care of by the garbage collector once it’s no longer needed.
In languages such as C, on the other hand, you need to manage memory yourself. Where you might simply initialize a list in higher-level languages, you need to manually allocate memory on the heap in C. And when you’ve allocated memory, you should also free the memory once you’re done with it to avoid memory leaks. But be careful: memory should only be freed once.
This process of manual allocation and freeing is error-prone. In fact, a Microsoft representative revealed that 70 percent of all of Microsoft’s vulnerabilities and exploits are memory-related.
So why would you use manual memory management? Because it allows for more control and offers better performance characteristics than garbage collection. The program doesn’t need to stop what it’s doing and spend time finding out what it needs to clean up before cleaning it up.
Rust’s ownership model feels like something in between. By monitoring where data is used throughout the program and following a set of rules, the borrow checker can determine where data needs to be initialized and where it needs to be freed (or dropped, in Rust terms). It’s like it auto-inserts memory allocations and frees it for you, giving you the convenience of a garbage collector with the speed and efficiency of manual management.
In practice, this means that, when passing variables around, you can do one of three things. You can move the data itself and give up ownership in the process, create a copy of the data and pass that along, or pass a reference to the data and retain ownership, letting the recipient borrow it for a while. The most appropriate approach depends entirely on the situation.
At a glance, here is a table that summarizes the comparison between garbage-collected languages and the Rust memory management with the ownership model:
Feature | Rust ownership model | Garbage collection |
---|---|---|
Memory deallocation | Deterministic at compile time. Values are dropped when they go out of scope | Non-deterministic, typically during runtime pauses |
Memory safety | Prevents memory errors (use-after-free, dangling pointers, etc.) at compile time | Relies on runtime checks to prevent memory errors |
Performance overhead | Minimal runtime overhead. No background garbage collection processes | Can introduce runtime pauses and overhead due to tracing and collection |
Predictability | Predictable memory usage and deallocation timing | Less predictable memory usage patterns, potential for runtime pauses |
Development complexity | Requires understanding ownership and borrowing, can have a steeper learning curve | Often simpler for developers, as memory management is abstracted away |
A data race occurs when two or more threads try to access and potentially modify the same memory location simultaneously, without any synchronization mechanisms.
In addition to handling memory allocation and freeing for the programmer, the borrow checker also prevents data races through its set of sharing rules. These same borrowing rules also help you work with concurrent and parallel code without having to worry about memory safety, enabling Rust’s fearless concurrency.
Here is a simple example of one way Rust handles that concurrency:
use std::sync::Mutex; use std::thread; let counter = Mutex::new(0); fn increment() { for _ in 0..10000 { let mut data = counter.lock().unwrap(); *data += 1; } } fn main() { let t1 = thread::spawn(increment); let t2 = thread::spawn(increment); t1.join().unwrap(); t2.join().unwrap(); println!("Counter: {}", counter.lock().unwrap()); }
In the above code:
counter
variable is protected within a Mutex
. A Mutex is a lock that allows only one thread to access a shared resource at a timelock()
: Before each thread in the for—loop can modify counter
, it must acquire a lock using counter.lock()
. This ensures that only one thread has mutable access to the counter at any given momentAs with all good things in life, Rust’s ownership system comes with its fair share of drawbacks.
The borrow checker can be tricky to understand and work with — so much so that it’s pretty common for newcomers to the Rust community to get stuck fighting it. I’ve personally lost many hours of my life to this struggle.
For instance, sharing data can suddenly become a problem, especially if you need to mutate it at the same time. Certain data structures that are easy to create from scratch in other languages are hard to get right in Rust. For a good example, check out the book “Learn Rust With Entirely Too Many Linked Lists.” It goes through several ways to implement a linked list in Rust and details all the issues the author ran into on the way there. It’s both informative and very entertaining — well worth a read.
Once you get on board with the borrow checker, things start to improve. I quite like this Reddit user’s explanation:
[The borrow checker] operates by a few simple rules. If you don’t understand or at least have some intuition for what those rules are, then it’s going to be about as useful as using a spell checker to help you write in a language you don’t even know: it’ll just reject everything you say. Once you know the rules the borrow checker is based on, you’ll find it useful rather than oppressive and annoying, just like a spell checker.
And what are these rules? Here are the two most important ones to remember concerning variables that are stored on the heap:
Next, let’s take a closer look at the mutable and immutable borrows.
By default, Rust variables are immutably borrowed — simply put, this means you can’t change the value of a variable after its initial assignment unless you explicitly mark it as mutable, which makes it a mutable borrow.
Here is a simple example of an immutable borrow attempt:
fn main(){ let x = 5; x = 6; // Error! Immutable variable println!("X is: {}", x); }
The above will throw an error because we are attempting to update a memory location that has been marked unchangeable (immutable). The error message will look like this:
error[E0384]: cannot assign twice to immutable variable `x` --> src/main.rs:4:5 | 3 | let x = 5; | - | | | first assignment to `x` | help: consider making this binding mutable: `mut x` 4 | x = 6; // Error! Immutable variable | ^^^^^ cannot assign twice to immutable variable For more information about this error, try `rustc --explain E0384`.
The interesting thing about Rust is that it gives you suggestions on how to fix the problems the compiler throws at you. From the code above, it’s suggesting we add mut
to the variable, which is exactly how to make a value mutable in Rust.
So, we’ll make an update to our code by adding mut
to the X variable as shown below:
fn main(){ let mut x = 5; x = 6; println!("X is: {}", x); }
Now, the result for the code above will be X is: 6
, the value of X has changed from 5
to the new value assigned to it — 6
.
Now that you have some understanding of what the borrow checker is and how it works, let’s examine how it affects us in practice. We’ll be working with the Vec<T>
type, which is Rust’s version of a growable list (analogous to Python’s lists or JavaScript’s arrays). Because it doesn’t have a fixed size, a Vec
needs to be heap-allocated.
The example may be contrived, but it demonstrates the basic principles. We’ll create a vector, call a function that simply accepts it as an argument, and then try and see what’s inside.
Note: This code sample does not compile:
fn hold_my_vec<T>(_: Vec<T>) {} fn main() { let v = vec![2, 3, 5, 7, 11, 13, 17]; hold_my_vec(v); let element = v.get(3); println!("I got this element from the vector: {:?}", element); }
When trying to run this, you’ll get the following compiler error:
error[E0382]: borrow of moved value: `v` --> src/main.rs:6:19 | 4 | let v = vec![2, 3, 5, 7, 11, 13, 17]; | - move occurs because `v` has type `std::vec::Vec<i32>`, which does not implement the `Copy` trait 5 | hold_my_vec(v); | - value moved here 6 | let element = v.get(3); | ^ value borrowed here after move
The above message tells us that Vec<i32>
doesn’t implement the Copy
trait and, as such, must be moved (or borrowed). The Copy
trait is only implementable by data types that can be put on the stack, and because Vec
must go on the heap, it cannot implement Copy
. We need to find another way around this.
Clone
traitEven though a Vec
can’t implement the Copy
trait, it can (and does) implement the Clone
trait. In Rust, cloning is another way to make duplicates of data. But while copying can only be done on stack-based values and is always very cheap, cloning also works on heap-based values and can be very expensive.
So if the function takes ownership of the value, why don’t we just give it a clone of our vector? That’ll make it happy, right? Indeed, the below code works just fine:
fn hold_my_vec<T>(_: Vec<T>) {} fn main() { let v = vec![2, 3, 5, 7, 11, 13, 17]; hold_my_vec(v.clone()); let element = v.get(3); println!("I got this element from the vector: {:?}", element); }
However, we’ve now done a lot of extra work for nothing. The hold_my_vec
function doesn’t even use the vector for anything; it just takes ownership of it. In this case, our vector (v
) is pretty small, so it’s not a big deal to clone it. In the just-getting-things-to-work stage of development, this may be the quickest and easiest way to see results.
However, there is a better, more idiomatic way to do this. Let’s have a look.
As mentioned previously, rather than giving away our variable to the other function, we can lend it to them. To do this, we need to change the signature of hold_my_vec
to instead accept a reference by changing the type of the incoming parameter from Vec<T>
to &Vec<T>
.
We also need to change how we call the function and let Rust know that we’re only giving the function a reference — a borrowed value. This way, we let the function borrow the vector for a little bit but ensure that we get it back before continuing the program:
fn hold_my_vec<T>(_: &Vec<T>) {} fn main() { let v = vec![2, 3, 5, 7, 11, 13, 17]; hold_my_vec(&v); let element = v.get(3); println!("I got this element from the vector: {:?}", element); }
This is only a very brief overview of the borrow checker, what it does, and why it does it. A lot of the finer details have been left out to make this tutorial as easy to digest as possible.
Often, as your programs grow, you’ll find more intricate problems that require more thinking and fiddling with ownership and borrows. You may even have to rethink how you’ve structured your program to make it work with Rust’s borrow checker.
It’s a learning curve, but if you stick around and make your way to the top, you’re sure to have learned a thing or two about memory along the way.
To further solidify your knowledge of the Rust borrow checker, I recommend the following articles:
Happy hacking!
Debugging Rust applications can be difficult, especially when users experience issues that are hard to reproduce. If you’re interested in monitoring and tracking the performance of your Rust apps, automatically surfacing errors, and tracking slow network requests and load time, try LogRocket.
LogRocket is like a DVR for web and mobile apps, recording literally everything that happens on your Rust application. Instead of guessing why problems happen, you can aggregate and report on what state your application was in when an issue occurred. LogRocket also monitors your app’s performance, reporting metrics like client CPU load, client memory usage, and more.
Modernize how you debug your Rust apps — start monitoring for free.
Hey there, want to help make our blog better?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowThe useReducer React Hook is a good alternative to tools like Redux, Recoil, or MobX.
Node.js v22.5.0 introduced a native SQLite module, which is is similar to what other JavaScript runtimes like Deno and Bun already have.
Understanding and supporting pinch, text, and browser zoom significantly enhances the user experience. Let’s explore a few ways to do so.
Playwright is a popular framework for automating and testing web applications across multiple browsers in JavaScript, Python, Java, and C#. […]