Loading…

How Rust manages memory using ownership and borrowing

Garbage collection? Manual allocation? When it comes to allocating memory for variables, Rust goes its own way.

Article hero image

One of the major selling points of the Rust programming language is its low-level expressiveness and memory safety. Unlike programming languages that use garbage collectors like Haskell, Ruby, and Python, Rust provides express functionality for developers to use and manage memory as efficiently as they please in a unique fashion.

Rust achieves memory management by managing memory using the borrow checker, and concepts known as ownership and borrowing to manage and ensure memory safety across the stack and the heap.

This article discusses the Rust borrow checker, Rust’s memory management in comparison to other languages like Go and C, and the drawbacks of the Rust borrow checker.

A quick refresher on how memory works

Before we get into how Rust manages memory, let’s review how computer memory works.

The computer memory allocated to running programs is divided into the stack and the heap.

The stack is a linear data structure that stores local variables in order without worrying about memory allocation and reallocation. Every thread has its own stack and every stack is deallocated when that thread stops running. Data is stored in a last-in-first-out (LIFO) fashion—new values are stacked on the old ones.

The heap is a hierarchical data structure used to store global variables randomly, and memory allocation and reallocation can be an issue of concern.

When a literal is pushed onto the stack, there is a definite memory location; this makes allocation and reallocation (pushing and popping) easy. However, the random process of allocating memory on the heap makes it expensive to use memory, making it slower to reallocate due to the complex book keeping involved in memory allocation on the heap.

Local variables, functions, and methods reside on the stack, and everything else resides on the heap; because the stack has a fixed limited size.

Rust handles memory efficiently by storing literals (integers, booleans, etc) on the stack. Variables like structs and enums that do not have a fixed size at compile-time are stored on the heap.

What is ownership?

Ownership is a concept that helps ensure memory safety in Rust without a garbage collector. Rust enforces the following ownership rules:

  • A variable owns every value,
  • Each value can have one and only one owner,
  • If a variable is assigned to a new owner, then the original value gets dropped, as it would now have two owners.

On program compilation, the Rust compiler checks if the program obeys these ownership rules before the program is compiled. If the program follows the rules of ownership, the program executes successfully; else, compilation fails.

Rust verifies ownership rules using the borrow checker. The borrow checkerverifies the ownership model and whether a value in memory (stack or heap) is out of scope. If the value is out of scope, the memory is deallocated.

But that doesn’t mean that the only way to access a value is through the original owner. That’s where borrowing comes in.

What is borrowing?

To allow programs to reuse code, Rust offers the concept of borrowing, which is similar to pointers.

Ownership can be borrowed from the owner temporarily and returned once the borrowing variable is out of scope.

A value can be borrowed by passing a reference to the owner variable using the ampersand (&) symbol. This can be very useful in functions.

Here’s an example:

fn list_vectors(vec: &Vec<i32>) {
    for element in vec {
        println!("{}", element);
    }
}

Functions can also modify borrowed variables using mutable references to the variable. Unlike regular variables that can be set as mutable using just the mut keyword, mutable references must be prefixed with the ampersand symbol. Before making mutable references, the variable itself must be mutable.

fn add_element(vec: &mut Vec<i32>) -> &mut Vec<i32> {
    vec.push(4);

    return vec
}

The concept of ownership and borrowing may not seem flexible until you understand copying, cloning, moving, and how they work together.

Copying ownership

Copying duplicates values by copying bits. Copying is only possible on types that implement the Copy trait. Some built-in types implement the Copy trait by default.

Unlike in the stack, where it’s easy to access variables and change ownership, it’s not easy to make copies in the heap because bit manipulation involves bit shifting and bit operations, and the stack is more organized for such operations.

Here’s an example of copying values in the heap.

fn main(){
    let initial = 6;
    let later = initial;
    println!("{}", initial);
    println!("{}", later);

}

The variables initial and later are declared in the same scope, and then the value of initial is copied into later by assignment.

Although the variables are in the same scope, initial would no longer exist. This is in cases where variables have to be reassigned.

Output:

Trying to print initial will cause the compiler to panic once the borrow checker notices the transfer of ownership.

What if you want to retain the value? Rust provides the ability to clone a variable.

Cloning variables

You can assign a value to a new owner while retaining the value in the old owner using the clone method. However, the type you’re cloning has to implement the clone trait.

fn main(){
    let initial = String::from("Showing Ownership ");
    let later = initial.clone();
    println!("{} ==  {} [showing successful cloning] ", initial, later)
}

Output:

The variable initialis cloned in the declaration of later, and the two variables reside on the heap. If this were borrowing, the two variables would reference the same object; however, in this case, the two variables are new declarations on the heap and occupy separate memory addresses.

Moving ownership

Rust provides functionality to change the ownership of a variable across scopes. When a function takes in an argument by value, the variable in the function becomes the new owner of the value. If you do not choose to move the ownership, you can pass the arguments by reference instead.

Here’s an example of how the ownership of a variable can be transferred from one variable to another.

fn change_owner(val: String) {

    println!("{} was moved from its owner and can now be referenced as val", val)
}

fn main() {

    let value = String::from("Change Ownership Example");
    change_owner(value);
}

The change_owner function gains ownership of the string previously declared and owned by the value variable when it takes in the variable as an argument. An attempt to print out the value variable will result in an error.

Drawbacks of Rust’s borrow checker

If everything was perfect with Rust’s borrow checker, other systems programming languages might switch or offer releases with an implementation of a borrow checker. On the subject of memory management, it’s a trade-off between user experience and convenience.

Languages that use garbage collectors make it easier to manage memory while reducing the flexibility of memory management, and languages like Rust and C give developers express access to use memory however they please as long as it follows certain rules like the ownership rules for Rust and how memory management is left to the developer in C.

The borrow checker can be complex and restrictive. As program size grows, it may be difficult to self-ensure the ownership rules, and making changes may be expensive.

While the Rust compiler prevents mistakes like dangling references by performing checks, Rust also provides an unsafe keyword for developers to leave the block unchecked. This could be detrimental to code security if external dependencies use the unsafe keyword.

Many developers, from beginners to experts, get ownership errors from the borrow checker, with more errors coming from implementing complex data structures and algorithms in Rust.

Comparing Rust’s and C’s memory management

The C programming language is a popular system programming language that doesn’t use garbage collection or a borrow checker to manage memory; instead, C makes developers manage memory manually and dynamically as they please.

C developers have functions like malloc(), realloc, free, and calloc defined in the standard library for memory management in the heap, while the memory in the stack is automatically freed once it’s out of scope.

Which method is better generally depends on what’s getting built. While developers may find the Rust borrow checker to be restrictive, it makes developers more efficient when managing memory without being an expert at memory management. Rust developers can also choose to use Rust without the standard library and get something similar to the C experience where all memory management is manual.

Rust, with the standard library and borrow checker, would be better to use in building resource-intensive applications that need handling.

Comparing Rust and Go memory management

Rust and Go are fairly new, powerful languages often compared in many terms including memory management.

Go employs a non-generational concurrent, tri-color mark and sweep garbage collector to manage memory in a different fashion allowing developers to allocate memory manually using the new and make functions while the garbage collector takes care of memory deallocation.

Go’s garbage collection is composed of a mutator that executes code and allocates objects to the heap, and a collector that helps deallocate memory. Go also allows developers to access and manage memory manually by turning off the garbage collector using the unsafe and/or runtime packages. The debug package of the runtime module provides functionality for debugging programs by setting garbage collector parameters using methods like theSetGCPercent method which helps with setting the garbage collector target percentage.

Go’s garbage collector has gotten its own fair share of criticism from the Go developer community, and has been changed and improved upon over the years. Go developers might want to manage memory manually and get more out of the language, and by default, the garbage collector doesn’t allow for the flexibility that languages like C that allow manual memory management provides.

Go and Rust are quite incomparable when discussing memory management since they have different unrelated ways of managing memory that come with tradeoffs between flexibility and memory safety, especially with developers of each of the languages wanting what the other uses.

Developers have resorted to choosing Go for building services and applications that require simplicity and flexibility, and Rust for building applications that require low-level interaction where performance and memory safety is paramount.

Is fighting the Borrow Checker worth it ?

I personally appreciate the borrow checker, as it’s an integral part of my Rust experience. However, the steep learning curve and the unending hassle to please the borrow checker have posed a question among Rust admirers with a background in languages like Python and JavaScript: “Is fighting the borrow checker worth it”

Fighting the borrow checker isn’t worth it; it’s a duel you’ll never win. Think of the borrow checker as a disciplinarian teaching you to write memory-efficient Rust code, and you’ll have to play the borrow checker’s game by learning more about how to write safer, memory-efficient Rust code.

As you write more Rust code, you’ll figure out the best ways to ways to prevent borrow checker errors. You can check out this blog by a senior Rust developer at Adobe on how she learned to stop fighting the borrow checker.

Conclusion

Rust is unquestionably a language that will exist and be widely used in the coming years. We’ve seen companies like Discord and Microsoft rewriting some of their codebases in Rust since it is able to interoperate with numerous languages like C and C++ using a foreign function interface (FFI), and many other companies use Rust in various parts of production.

Ownership and borrowing are essential concepts in Rust, and as you write more Rust programs, there’s a high probability that you’ll get an error from the borrow checker. It’s important that you use the right tools for the job; you can consider using Go for programs where memory management isn’t a big deal and you care about performance.

Login with your stackoverflow.com account to take part in the discussion.