Rust: The Stack and the Heap

Home · Blog

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

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.

The heap

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.