Thomas Heartman Developer, speaker, musician, and fitness instructor.

Understanding the Rust borrow checker

7 min read 2129

Understanding the Rust Borrow Checker

You’ve heard a lot about it, you’ve bought into the hype, and the day has finally come. It’s time for you to start writing Rust!

So you sit down, hands on the keyboard, heart giddy with anticipation, and write a few lines of code. You 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. The compiler starts up, you follow the output, then, suddenly:

error[E0382]: borrow of moved value

Uh-oh. Looks like you’ve run into the dreaded borrow checker! Dun, dun, DUUUUUUN!

What is the borrow checker?

The borrow checker is an essential fixture 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.”

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.

Garbage collection vs. manual memory allocation vs. the borrow checker

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.

The stack and the heap

Your programs have access to two kinds of memory where it can store values: the stack and the heap. These differ in a number of 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.

We made a custom demo for .
No really. Click here to check it out.

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 numerous pointer tutorials out there on the web, and which one works for you may depend on your background. For a quick primer, this article by Jason C. McDonald explains C pointers pretty well.

What’s the point of having these two different memory stores? Because of the way the stack works, data access on the stack is very fast and easy but requires the data to conform to certain standards. The heap is slower but more versatile and is thus useful for when you can’t use the stack.

Garbage collection

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 goes 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 keeping track of where data is used throughout the program and by following a set of rules, the borrow checker is able to 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 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.

Other borrow checker superpowers: Paralyzed or parallelized?

In addition to handling memory allocation and freeing for the programmer, the borrow checker also prevents data races (though not general race conditions) 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.

Drawbacks

As with all good things in life, Rust’s ownership system comes with it’s fair share of drawbacks. Indeed, if not for a few drawbacks, this article probably wouldn’t exist.

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 the borrow checker. 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 super 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 a number of ways to implement a linked list 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 Reddit user dnkndnts’ 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:

  1. When passing a variable (instead of a reference to the variable) to another function, you are giving up ownership. The other function is now the owner of this variable and you can’t use it anymore
  2. When passing references to a variable (lending it out), you can have either as many immutable borrows as you want or a single mutable borrow. Once you start borrowing mutably, there can be only one

In practice

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 Copytrait 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.

Attack of the clones

Even though a Vec can’t implement the Copy trait, it can (and does) implement the Clonetrait. 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.

References

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);
}

Summary

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 for sure, 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.

: Full visibility into your web apps

LogRocket is a frontend application monitoring solution that lets you replay problems as if they happened in your own browser. Instead of guessing why errors happen, or asking users for screenshots and log dumps, LogRocket lets you replay the session to quickly understand what went wrong. It works perfectly with any app, regardless of framework, and has plugins to log additional context from Redux, Vuex, and @ngrx/store.

In addition to logging Redux actions and state, LogRocket records console logs, JavaScript errors, stacktraces, network requests/responses with headers + bodies, browser metadata, and custom logs. It also instruments the DOM to record the HTML and CSS on the page, recreating pixel-perfect videos of even the most complex single-page apps.

.
Thomas Heartman Developer, speaker, musician, and fitness instructor.

Leave a Reply