Rust has emerged for the fifth year in a row as the most loved programming language in a developer survey carried out by Stack Overflow. There are various reasons why developers love Rust, one of which is its memory safety guarantee.
Rust guarantees memory safety with a feature called ownership. Ownership works differently from a garbage collector in other languages because it simply consists of a set of rules that the compiler needs to check at compile time. The compiler will not compile if ownership rules are not followed. The borrow checker is a compiler component that ensures your code follows ownership.
For languages that don’t have a garbage collector, you need to explicitly allocate and free memory space. This can quickly become tedious and challenging when it involves large codebases.
Thankfully, memory management is handled by the Rust compiler using the ownership model. A Rust compiler will automatically insert a drop statement to free the memory. It uses the ownership model to decide where to free memory; when the owner goes out of scope, the memory is freed.
fn main(){ { let x =5; // x is dropped here since it goes out of the scope } }
The stack and heap are both memory storage segments that are available for your code to use at runtime. For most programming languages, developers are not typically concerned about how memory allocation goes on the stack and heap. However, because Rust is a system programming language, how values are stored (in the stack or heap) is essential to how the language behaves.
Here’s an example of how memory is stored in a stack: let’s think of a stack of books on a table. These books are arranged in a way that the last book is placed on top of the stack and the first book is at the bottom. Ideally, we wouldn’t want to slide the bottom book out from under the stack, it would be easier to pick a book on top of to read.
This is exactly how memory is stored in the stack; it uses the last in, first out method. Here, it stores values in the order it gets them but removes them in the opposite order. It’s also important to note that all data stored in the stack have a known size at compile time.
Memory allocation in the heap is different from how memory is allocated in the stack. Think of going to buy a shirt for a friend. You don’t know the exact size shirt your friend wears, but seeing him frequently, you think he might be a medium or large. While you aren’t completely sure, you buy the large because he will still be able to physically fit into it, even if he’s a medium. This is how memory allocation in the heap works. When you have a value (your friend) for which you don’t know the exact amount of memory it will require (size of t-shirt), you request for a specific amount of space for the value. The allocator finds a spot in the heap that is big enough and marks that spot as in use. This is an important difference between the stack and the heap: we don’t need to know the exact size of the value being stored in the heap.
There is no organization in the heap as compared to the stack. It is easy to push data into and out of the stack, because everything is organized and follows a specific order. The system understands that when you push a value into the stack it stays on top, and when you need to take out a value from the stack, you are retrieving the last value that was stored.
This is, however, not the case in the heap. Allocating on the heap involves searching for an empty space big enough to match the amount of memory you requested, and returning an address to the location which will be stored in the stack. Retrieving a value from the heap requires you to follow a pointer to the place where the value is stored in the heap.
Allocating on the heap looks like book indexing, where a pointer for a value stored in the heap is stored in the stack. However, the allocator also needs to search for an empty space that is big enough to contain the value.
Local variables of a function are stored on the function stack, whereas data types, such as String
, Vector
, Box
, etc., are stored on the heap. It is important to understand the memory management of Rust to ensure the app behaves as intended.
Ownership has three basic rules that predict how memory is stored in the stack and in the heap:
let x = 5; // x is the owner of the value "5"
fn main() { {// scope begins let s = String::from("hello"); // s comes into scope }// the value of s is dropped at this point, it is out of scope }
Feel free to play around with this code sample.
In our introduction, we established a fact that ownership isn’t like the garbage collector system and, in fact, Rust doesn’t deal with a garbage collector system. Most programming languages either use a garbage collector or require the developer to allocate and free up memory themselves.
In ownership, we request memory for ourselves, and when the owner goes out of scope the value will be dropped and memory freed. This is exactly what the third ownership rule explains. To get a better understanding of how this works, let’s look at an example:
<// memory allocation in the stack fn main() { { // a is not valid here let a = 5; // a is valid here // do stuff with a }// println!("{}", a)a is no longer valid at this point, it is out of scope }
This example is pretty straightforward; this is how memory allocation in the stack works. A blob of memory (a
) is allocated on the stack since we know the exact space its value 5
will occupy. However, this isn’t always the case. Sometimes, you will need to allocate memory space for a growable value for which you don’t know its size at compile time.
For this case, the memory is allocated on the heap and you first have to request for memory as shown in the example below:
fn main() { { let mut s = String::from("hello"); // s is valid from this point forward s.push_str(", world!"); // push_str() appends a literal to a String println!("{}", s); // This will print `hello, world!` }// s is no longer valid here }
We can append as much of the string as we want to s
because it is mutable, making it difficult to know the exact size required at compile time. Therefore we will require a memory space the size of a string in our program:
let mut s = String::from("hello") // requesting for space in the heap, the size of a String.
When the variable goes out of scope, the Rust ownership feature allows for the memory to be returned (freed).
In this section, we will be looking at how ownership affects certain features in Rust, starting with the clone
and copy
feature.
For values that have a known size like integers
, it is easier to copy the value into another value. For example:
fn main() { let a = "5"; let b = a; // copy the value a into b println!("{}", a) // 5 println!("{}", b) // 5 }
Because a
is stored in the stack, it is easier to copy its value to make another copy for b
. This is not the case for a value stored in the heap:
fn main() { let a = String::from("hello"); let b = a; // copy the value a into b println!("{}", a) // This will throw an error because a has been moved or ownership has been transferred println!("{}", b) // hello }
When you run the command, you’ll get an error error[E0382]: borrow of moved value: "a"
. Earlier, I explained how values in the heap are stored like an indexing process, where the pointer is stored in the stack. In Rust terminology move
means the ownership of the memory is transferred to another owner.
When you copy a value that is stored on the heap, the system automatically copies only the pointer, leaving out the heap data. Since data can have one owner, ownership of string is transferred from a
to b
.
This makes Rust render a
as no longer valid, so the double free error
won’t occur. The error occurs when you try to free up a memory that has already been freed. Since a
and b
are accessing one memory in the heap with their pointers, it is likely that when a
goes out of scope and clears the memory, b
would want to clear the same memory too, hence double free error
.
To access both a
and b
, you have to use a feature called the clone
method:
fn main() {
let a = String::from("hello");
let b = a.clone(); // creates a copy of data on the heap and return pointer to it
println!("a = {}, b = {}", a, b);// a=hello, b=hello
}
Passing values to a function follows the same ownership rules, meaning they can only have one owner at a time, and free up memory once out of scope. Let’s look at this example:
fn main() { let s1 = givesOwnership(); // givesOwnership moves its return // value into s1 let s2 = String::from("hello"); // s2 comes into scope let s3 = takesAndGivesBack(s2); // s2 is moved into // takesAndGivesBack, which also // moves its return value into s3 } // Here, s3 goes out of scope and is dropped. s2 goes out of scope but was // moved, so nothing happens. s1 goes out of scope and is dropped. fn givesOwnership() -> String { // givesOwnership will move its // return value into the function // that calls it let someString = String::from("hello"); // someString comes into scope someString // someString is returned and // moves out to the calling // function } // takesAndGivesBack will take a String and return one fn takesAndGivesBack(aString: String) -> String { // aString comes into // scope aString // aString is returned and moves out to the calling function }
The second ownership rule (each value can have only one owner at a time) makes writing functions overly verbose, as you need to return ownership of functions whenever you want to use them as seen in the example above.
It is very memory-inefficient to make copies of data whenever passing to a function. The best method is to use the Rust references feature and borrow the data.
With references, you can use a function that has a reference to an object as a parameter instead of taking ownership of the value.
With ampersands (&
) you can refer to a value without taking ownership of it. Our example function can now be written this way:
fn main() { let s1 = &givesOwnership(); // moves its return value into s1 that borrows the value let s2 = String::from("hello"); // s2 comes into scope let s3 = takesAndGivesBack(s2); // s2 is moved into s3 println!("{}", s1); println!("{}", s3); // takesAndGivesBack, which moves its return value into s3 } // Here, s3 goes out of scope and is dropped. s2 goes out of scope but was // moved, so nothing happens. s1 goes out of scope and is dropped. fn givesOwnership() -> String { // givesOwnership will transfer ownership of its // return value to the caller of the function let someString = String::from("hello"); // someString comes into scope someString // someString is returned and // moves out to the calling // function } // takesAndGivesBack will take a String and return one fn takesAndGivesBack(aString: String) -> String { // aString comes // scope aString // aString is returned and moves out to the calling function }
Another good example of how to use references is this example shown in the Rust documentation:
fn main() { let s1 = String::from("hello"); let len = calculate_length(&s1); println!("The length of '{}' is {}.", s1, len); } fn calculate_length(s: &String) -> usize { s.len() }
Instead of referencing a whole collection, you can reference elements that are next to each other in a sequence. To do this, you can use the slice type
in Rust. However, this feature doesn’t have ownership like referencing and borrowing.
Let’s look at the example below. In this example we will use the slice type
to reference elements of a value that is in a contiguous sequence:
fn main() { let s = String::from("Nigerian"); // &str type let a = &s[0..4]; // doesn't transfer ownership, but references/borrow the first four letters. let b = &s[4..8]; // doesn't transfer ownership, but references/borrow the last four letters. println!("{}", a); // prints Nige println!("{}", b); // prints rian let v=vec![1,2,3,4,5,6,7,8]; // &[T] type let a = &v[0..4]; // doesn't transfer ownership, but references/borrow the first four element. let b = &v[4..8]; // doesn't transfer ownership, but references/borrow the last four element. println!("{:?}", a); // prints [1, 2, 3, 4] println!("{:?}", b); // prints [5, 6, 7, 8] }
Ownership is an important feature in Rust. The more a Rust developer understands ownership, the easier it becomes for him or her to write scalable code. The reason why many developers love Rust is because of this feature, and once you master it, you can write efficient code and predict the outcome without having Rust pull a fast one on you!
In this article, we have seen the basics of ownership, its rules, and how to apply them in our programs. We have also looked at some of Rust’s features that don’t have ownership and how to use them flawlessly. To get more tips on Rust’s ownership feature, check out their documentation.
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.
Would you be interested in joining LogRocket's developer community?
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#. […]