The Rusty Gopher’s Guide to Ownership, Borrowing, and Lifetimes in Rust

A mental backstop for remembering the rules of Rust

If you’re coming from a background in a compiled language like I did from Go, you’ll notice that some of the memory semantics of Rust are pretty similar. However, what’s happening under the hood in Rust is a bit different and arguably more clever. Somewhere between Go, which is garbage collected (memory is managed for you) and C which is not (you manage the memory with and ) is Rust. According to the source Rust manages memory without the use of a garbage collector but that comes with some rules and a bit of a learning curve for you.

Resource Allocation is Initialization (RAII)

To start, let’s look at how memory was managed in C++ using destructors and what happens when things fall off the scope. Rust, like responsibly written C++ relies on the notion of resource allocation is initialization (RAII) meaning that when an item goes out of scope, it’s resources (memory) are released. In C++ we can see this pattern in the use of destructors. In this example, we have a destructor method on the class annotated with which gets called when the object falls off the scope (code goes beyond the in which the object was defined).

// C++ Class Codeclass MyData {
public:
MyData(size_t size){
m_data = malloc(size);
}

~MyData(){ // destructor code
if( m_data != nullptr ){
free(m_data);
}
}

private:
void * m_data = nullptr;
}
int main() { MyData md = MyData();
} // md goes out of scope and the memory is released via free

In Rust, Every time an object goes out of scope, the memory the object was using is freed in a similar fashion.

Reference or Copy & Ownership

The next building block to understanding Rust memory management is relatively more simple. Like in C or Go, some things are copy by value while others are copy by reference. This is an optimization to manage large objects like arrays, or structs without copying every single piece.

In Rust, most primitives, like int, bool or char, are copy by value (or as Rust calls it, a “copy type”) while things like strings, arrays, and structs, are copy by reference (or “reference type”).

fn stuff(n: i32, s: String) {
// value copied to n (copy type) and ref pointer "moved" to s
println!("{} {}", n, s);
}
fn main() {
let i = 2; // copy type - i "owns" the memory for 2
let t = String::from("tx"); // reference type - t "owns" a pointer
stuff(i, t); // reference to "text" allocation 'moved' to s
}

In this example, calling the function and passing in and would result in creating a copy of (because it’s a copy type) and moving the reference to the string from to .

Once the function exits, the memory used to hold the copy of is freed ( no longer exists outside the function, which should be familiar to most of you). However, since is a reference to some heap memory, that heap memory is freed as the reference goes out of scope. Attempting to pass to a function via borrowing will result in the error.

Rust moves the reference from one variable to another to prevent multiple variables holding the same pointer to a spot in memory. Having multiple pointers to a spot in memory leads to some weird bugs. For instance, given how Rust reclaims memory after function parameters go out of scope, if we pass and some variable which holds the same pointer to 2 function calls, each will try to free the memory after the function exits. That leads to double free errors, and/or race conditions and other fun hard to find bugs.

The compiler is smart enough to stop us from shooting ourselves in the foot, however, we still want to be able to use in multiple places without freeing it for a single function call.

Enter Borrowing

To borrow, is essentially to take a reference. In our example above, I can borrow values like so.

fn stuff(n: i32, s: &String) { // accept a i32 and a borrowed string
println!("{} {}", n, s);
}
fn main() {
let i = 2;
let text = String::from("text");
stuff(i, &text); // borrow (make reference) to text
}

If you’re from C++ this looks like passing references to functions. The way I look at borrowing, is like passing around references, or references to references.

Int his example, now that is a ( is borrowed) the function is effectively getting a reference to a reference. We don’t have to worry about de-referencing either (similar to Go) which is also pretty nice. So when we want to look at the value, there’s no extra work to do here.

When the value (again, a reference to a reference to some heap memory) goes out of scope, only the memory used to store the reference to the reference gets freed. Which means we can use later and it’ll still point to a valid memory location.

Lifetimes

We’ve seen so far how memory gets freed as things fall off the scope, how moving references work to prevent accidental double free errors, and how borrowing lets us pass references to references to functions so we can access the data without freeing the backing memory after one use. Now we’ll discuss a more complex and somewhat uncommon facet of memory management — lifetimes.

Lifetimes come up whenever you have a function returning a reference to something, as is the case with returning strings. That function also accepts a number of reference types (like 2 strings).

fn stuff<'a, 'b>(i: &'a str, j: &'b str) -> &'a str {
println!("{} {}", i, j); // does something with i & j
return "stuff"; // returns a &str
}
fn main() {
let line = "lang:en=Hello World!";
let lang = "en";
let v;
{ // scope change
let p = format!("lang:{}=", lang);
v = stuff(line, p.as_str()); // mixed scope inputs / output
}
println!("{}", v);
}

Consider the example above. One way I like reading is to say:

this function spans over 2 lifetimes (scopes) (a and b is just a Rust convention; lifetimes are named a-z). The parameter comes from lifetime and the parameter comes from lifetime and the result can only be stored in a variable in the same lifetime as the parameter i.e.

What that means exactly is that the output of in this example, would have to be stored one scope up, since thats where lives.

Since we don’t care about what happens to lifetime i.e. the output isn’t being assigned to a variable of the inner scope, we can actually omit it in the definition like so

Sometimes the compiler is smart enough to figure out which variables belong to which lifetime via the lifetime elison rules.

Generally, if you’re passing in more than one reference type and returning a reference type, you’ll need to annotate which lifetime is which.

Bringing it Together

Hopefully, you come away with a decent mental model of how memory works in Rust and how/why the compiler makes and enforces rules to how you pass data around between variables and functions. We saw a brief overview of how Rust frees memory after items fall out of scope, why moving is important to prevent double free errors, and how borrowing helps us use referenced data on the heap without freeing it prematurely. Finally we touched on lifetimes and how to specify to the compiler to which lifetime a reference output should belong, when disambiguating between inputs that span multiple lifetimes.

I’ll be the first to admit, I’m no expert in Rust or memory management and the impetus behind this article is to learn via teaching. So take how I’m going to explain this with a grain of salt and if I’m off base, I’d love to hear from you.

References

The Book

Rust By Example

Software Engineer | Product Leader