13 January 2023
Rust, like most languages, has two ways of allocating memory at runtime: on the stack and on the heap. In this article I'll take a look at each. This mostly just works the way you’d expect, so you might say it doesn’t warrant an article, but I think having a solid foundation here will be useful for more advanced topics, especially traits and lifetimes.
The stack is, of course, how functions work: when a function is called, its arguments are put on the stack, then local variables are put on the stack; when the function returns, the stack is used to pass the return value. In Rust we can use the Drop trait to show exactly when a variable is deallocated:
#[derive(Debug)]
struct Data(usize);
impl Drop for Data {
fn drop(&mut self) {
println!("dropping d{}", self.0);
}
}
The drop function will be called when the variable is deallocated. If we run this code:
fn stack_simple(d1: Data) {
let d2 = Data(2);
let d3 = Data(3);
println!("in stack_simple");
}
fn main() {
stack_simple(Data(1));
}
Then we get this output:
in stack_simple
dropping d3
dropping d2
dropping d1
As you might have expected, the variables are deallocated in reverse order.
What if you have a nested scope within a function? Let’s find out:
fn stack_nested_scope() {
let d1 = Data(1);
{
let d2 = Data(2);
println!("nested scope");
}
println!("outer scope");
}
This prints:
nested scope
dropping d2
outer scope
dropping d1
So the variable is deallocated at the end of the scope. That makes sense: once the variable goes out of scope, we can’t use it anymore, so there’s no reason to keep it around. Which makes me wonder: if a variable is shadowed (i.e. another variable with the same name is defined in the same scope), does the first one get deallocated?
fn stack_shadowed() {
let d1 = Data(1);
println!("d1 is {:?}", d1);
let d1 = Data(111);
println!("d1 is {:?}", d1);
}
This prints:
d1 is Data(1)
d1 is Data(111)
dropping d111
dropping d1
So the variable is deallocated when the function returns. Shadowing does’t change that.
When the size of data isn’t known at compile time, it needs to go on the heap instead of the stack. The most common way to allocate memory on the heap is probably through library types such as Vec and String, which take care of memory management for their users. To do it manually, you can use the Box type. This function shows the basic usage:
fn box_simple() {
let d1 = Box::new(Data(1));
println!("d1 is {:?}", d1);
}
When you call Box::new
, it puts its argument on the heap and returns a Box value. When
that value is deallocated, the heap memory is also deallocated. In this case, that happens when
d1
goes out of scope at the end of the function:
d1 is Data(1)
dropping d1
If you declare it mutable, you can replace the value it contains:
fn box_mutable() {
let mut d1 = Box::new(Data(1));
println!("d1 is {:?}", d1);
*d1 = Data(111);
println!("d1 is {:?}", d1);
}
The old value is deallocated when it’s replaced:
d1 is Data(1)
dropping d1
d1 is Data(111)
dropping d111
Box is what’s called a smart pointer: it points to an object and manages the lifetime of that
object. There are other smart pointers in the Rust standard library, such as Rc, which uses reference counting
to determine when the referenced object can be deallocated. If you’ve worked with C++ before, those
two correspond to std::unique_ptr
and std::shared_ptr
.