Rust: stack and heap, moves and copies

Why does the first code snippet compiles, while the second won’t?

Yerachmiel Feltzman
Israeli Tech Radar
7 min readJul 24, 2023

--

This program compiles:

let x = 5;
let y = x; // assigning x value to y
println!("x = {}, y = {}", x, y); // asking for both x and y
>> cargo run
x = 5, y = 5

While this won’t:

let s1 = String::from("hello");
let s2 = s1; // assigning s1 value to s2
println!("s1 = {}, s2 = {}", s1, s2); // asking for both s1 and s2
>> cargo run
error[E0382]: borrow of moved value: `s1`
--> src/main.rs:188:34
|
186 | let s1 = String::from("hello");
| -- move occurs because `s1` has type `String`, which does not implement the `Copy` trait
187 | let s2 = s1;
| -- value moved here
188 | println!("s1 = {}, s2 = {}", s1, s2);
| ^^ value borrowed here after move
|
help: consider cloning the value if the performance cost is acceptable
|
187 | let s2 = s1.clone();
|

Why does the first code snippet compiles, while the second doesn’t?

Photo by Juan Rumimpunu on Unsplash

Short answer

Rust memory management follows these three rules, amongst others:

  1. Stack objects are copied.
  2. Heap objects are moved.
  3. A moved object has its pointer invalidated.

The variable let x = 5 is of type i32, while the variable let s1 = String::from(“hello”) is of type String. Hence, x sits on the stack, while s1 is allocated on the heap.

As such, let y = x; copies x to y, maintaining both on the stack, while let s2 = s1; moves s1 values ownership to s2. Since s1 no longer owns the String “hello”, it can no longer be referenced.

If you want to understand it better, let’s dive a bit.

Image from here

The stack and the heap in Rust

Stack and heap are concepts common to several language memory management systems. As such, they are abstractions that help you determine when to allocate and deallocate memory.

In general terms, we can state that:

  • The stack is faster and used for known fixed-size memory allocation.
  • The heap is slower and used when the memory allocation size is unknown.

The above definitions are simplistic. Probably also not always strictly exact. I know. For further reading, I suggest this book chapter.

For the purposes of being able to write working code in Rust, these explanations are a good start.

Let’s break it down.

The stack

In let x = 5, x is an i32. Rust compiler knows at compile time the bit size of x. It can allocate it on the stack.

let x = 5;

Stack data is automatically copied

What then happens when we assign x to y?

Rust copies x value to y. Which is, it adds a new variable to the stack.

let y = x;

The heap

In Rust, the String type data is allocated on the heap. Why?

From Rust documentation (bold added for emphasis):

A String is made up of three components: a pointer to some bytes, a length, and a capacity. The pointer points to an internal buffer String uses to store its data. The length is the number of bytes currently stored in the buffer, and the capacity is the size of the buffer in bytes. As such, the length will always be less than or equal to the capacity.

This buffer is always stored on the heap.

Think about the following example where the String type is used to read data from the stdin:

fn read_user_input() -> io::Result<(String)> {
let mut buffer = String::new();
let stdin = io::stdin();
stdin.read_line(&mut buffer)?;
Ok((buffer))
}

let user_input: String = read_user_input().unwrap();
println!("input = {} ", user_input);

(I assume you can forgive me for not handling the error properly in a blog post. Can you? 😊)

Can the Rust compiler know beforehand the actual size of the user input? It can’t, right? That’s why the String is stored on the heap.

let s1 = String::from(“hello”);

Heap data is automatically moved

What then happens when we assign s1 to s2?

Let’s read what the Rust Programming Language book says about it (bold added for emphasis):

“If you’ve heard the terms shallow copy and deep copy while working with other languages, the concept of copying the pointer, length, and capacity without copying the data probably sounds like making a shallow copy. But because Rust also invalidates the first variable, instead of being called a shallow copy, it’s known as a move. In this example, we would say that s1 was moved into s2”

So:

  1. The pointer is copied from s1 to s2 (on the stack);
  2. s1 is invalidated;
  3. We call this a move.
let s2 = s1;

Well… it’s now clear why println!(“s1 = {}, s2 = {}”, s1, s2) won’t work. s1 is not valid anymore!

Let’s recapitulate

Photo by Owen Michael Grech on Unsplash

When reassigned, any data stored on the stack is copied, while data stored on the heap is moved and its previous pointer is invalidated.

Thus, this program compiles:

let x = 5; // x = 5 is stored on the stack
let y = x; // so is y = 5, since x value is **copied** to y
println!("x = {}, y = {}", x, y); // we can use both at the same time

While this doesn’t:

let s1 = String::from("hello"); // "hello" is stored on the heap, with a pointer for s1 on the stack
let s2 = s1; // "s2 pointer pointing to the same "hello" is created on the stack, while s1 pointer is invalidated
println!("s1 = {}, s2 = {}", s1, s2); // WE CANNOT USE s1 ANYMORE!

If we take a closer look at the compilation error we got before, that’s exactly what cargo is telling us:

>> cargo run
error[E0382]: borrow of moved value: `s1`
--> src/main.rs:188:34
|
186 | let s1 = String::from("hello");
| -- move occurs because `s1` has type `String`, which does not implement the `Copy` trait
187 | let s2 = s1;
| -- value moved here
188 | println!("s1 = {}, s2 = {}", s1, s2);
| ^^ value borrowed here after move
|
help: consider cloning the value if the performance cost is acceptable
|
187 | let s2 = s1.clone();
|

My personal opinion

I recently started learning Rust. I’m a senior Big Data Engineer working at Tikal — a company composed of tech experts, where we provide hands-on consultancy, scaling R&D teams with cutting-edge technologies. As such, we are always exploring new technologies. So, even though my experience is with Python, Scala, and some Java, I’m learning Rust. You can read about my “why” on picking Rust as a new language to learn and how this reflects the 90–25 principle.

I’m so far enjoying learning Rust. I do. Rust is super strict in terms of possible data races, and, hence, it forces safe coding habits and patterns. It does it by blowing my code in my face even before I can run it. Amazing! 🔥

I’m still yet to learn about its concurrency features, but the benefits are clear. (well, that’s what I liked so much in Scala’s immutability by default, and Rust is way more strict than Scala).

However, I do have a question

Why the same code pattern generates two different results? Why?

Rust's answer is (bold added for emphasis):

The reason is that types such as integers that have a known size at compile time are stored entirely on the stack, so copies of the actual values are quick to make. That means there’s no reason we would want to prevent x from being valid after we create the variable y.

I can understand the design principle behind it.

Rust designers could have decided to copy the value of s1 to s2. But they took a different path. They decided that the lighter operation should be the default operation, and “if you need to copy, please be explicit”.

let s2 = s1.clone();

Furthermore, they decided that where this trade-off is irrelevant in terms of performance the behavior will be different.

They decided to default for performance over to default for easier developer thinking. That’s a reasonable decision — it gives you a powerful language, that requires you to be aware of its powerfulness.

To quote Uncle Ben: “With great power comes great responsibility”.

Image from here

Happy Rust learning!

--

--