Disclaimer: I'm not a rust expert... yet! I'm just chronicling things I'm learning as I go along
Overview
Let's talk about how the stack and heap work within the context of Rust. Each refer to different ways in which memory is allocated when programming. For many languages you don't really need to worry about it. The programming language itself has garbage collection and determines how you utilize the stack and the heap. When using low-level programming languages such as Rust, you need to have at least a fundamental level of understanding about how the stack and the heap work.
The Stack
Starting Simple
Everything (at least partially - more on that later) lives on the stack. The stack is the default allocation within Rust. Everything on the stack must have a fixed size. When you declare any variable in rust it will be placed on the stack.
The most common analogy I've seen for the stack is a stack of plates.
It's organized, it's fast, but it's in a rigid order. You can only really access the plates that you just added to the top of the stack. If you have 20 plates you can't just pull one out from the bottom.
Let's take a look at a simplified entry on the stack:
- Address on the stack
- Name
- Value
So if you declare a simple variable:
let var_1 = 1
Then on the stack you'll have:
Address | Name | Value |
---|---|---|
0 | var_1 | 1 |
Declaring another variable:
let var_2 = 2
Adds it to the stack and you'll have:
Address | Name | Value |
---|---|---|
1 | var_2 | 2 |
0 | var_1 | 1 |
We can see this in action by running some code in the online rust playground.
Open a rust playground and run the following code
fn print_memory_address_as_decimal(mem_type: String, message: String, address: String) {
let without_prefix = address.trim_start_matches("0x");
let z = i64::from_str_radix(without_prefix, 16);
println!("{} {:?}; {}", mem_type, z.unwrap(), message);
}
fn main() {
// Put a simple variable on the stack
let var_1 = 1;
// Put another simple variable on the stack
let var_2 = 2;
print_memory_address_as_decimal("STACK:".to_string(), "The address for var 1".to_string(), format!{"{:p}", &var_1});
print_memory_address_as_decimal("STACK:".to_string(), "The address for var 2".to_string(), format!{"{:p}", &var_2});
}
Note: The top function is just a way for us to easily read the address in the memory.
When I ran the above code, I got the following addresses:
STACK: 140730456178728; The address for var 1
STACK: 140730456178732; The address for var 2
So my table would look like:
Address | Name | Value |
---|---|---|
140730456178732 | var_2 | 2 |
140730456178728 | var_1 | 1 |
You can see that var_2 is located 4 bytes after the location of var_1 on the stack.
Stack Scope
How does this work when we call multiple functions?
Replace the code in your Rust playground with the following:
fn print_memory_address_as_decimal(mem_type: String, message: String, address: String) {
let without_prefix = address.trim_start_matches("0x");
let z = i64::from_str_radix(without_prefix, 16);
println!("{} {:?}; {}", mem_type, z.unwrap(), message);
}
fn main() {
// Put a simple variable on the stack
let var_1 = 1;
// Put another simple variable on the stack
let var_2 = 2;
print_memory_address_as_decimal("STACK:".to_string(), "The address for var 1".to_string(), format!{"{:p}", &var_1});
print_memory_address_as_decimal("STACK:".to_string(), "The address for var 2".to_string(), format!{"{:p}", &var_2});
new_section_of_memory();
}
fn new_section_of_memory() {
// Put a simple variable on the stack
let var_3 = 3;
// Put another simple variable on the stack
let var_4 = 4;
print_memory_address_as_decimal("STACK:".to_string(), "The address for var 3".to_string(), format!{"{:p}", &var_3});
print_memory_address_as_decimal("STACK:".to_string(), "The address for var 4".to_string(), format!{"{:p}", &var_4});
}
Run this and take a look at the memory.
Using our simplified example of the stack from earlier, we would expect something along the lines of:
Address | Name | Value |
---|---|---|
3 | var_4 | 4 |
2 | var_3 | 3 |
1 | var_2 | 2 |
0 | var_1 | 1 |
But if you look at our memory addresses you'll see something like:
STACK: 140730034217448; The address for var 1
STACK: 140730034217452; The address for var 2
STACK: 140730034217016; The address for var 3
STACK: 140730034217020; The address for var 4
Wait, that's not right! The memory address for var_3 and var_4 are smaller than the address for var_1 and var_2! I would have thought that it would continually increase.
Rust stores things on the stack in chunks for each scope. If you think of the stack as being at the "top" of your available memory, then rust will allocate starting at the top and work it's way down.
For the first function the compiler can tell "I need x amount of memory to handle the scope of the main function". So it starts at the top of the available memory space (let's call this t), subtracts x bytes of memory (t-x) and then starts that scopes stack right there. Within it's own scope it acts exactly as expected, stacking new values on top of the old ones.
When we call the next function, new_section_of_memory, the compilers knows "I need y amount of memory for this new function. I also know that I have already allocated t - x in memory, so I'll start this scopes memory allocation at (t-x)-y.
If we place our actual addresses from our rust playground in our stack table, we get:
Address | Name | Value |
---|---|---|
140730034217452 | var_2 | 2 |
140730034217448 | var_1 | 1 |
140730034217020 | var_4 | 4 |
140730034217016 | var_3 | 3 |
And you'll notice that within their local stacks, var_2 is 4 bytes after var_1. and var_4 is 4 bytes after var_3.
Something else you might have noticed is that the two scopes are hundreds of bytes apart. This is because each time we are printing the addresses, we're calling to the formatting function at the top of the file. This function needs it's own space in memory to run things too! If you could remove all the printing then the variables addresses would be printed significatly closer to each other.
Now keep in mind, this is a representation of the PHYSICAL locations of the addresses in our memory. From a chronological order things are happening as we would have expected:
Order added to the stack | Name | Value |
---|---|---|
3 | var_4 | 4 |
2 | var_3 | 3 |
1 | var_2 | 2 |
0 | var_1 | 1 |
Make sure to note the differences. In order of operations, the stack is behaving exactly as you would expect. As new variables are declared, they are added to the stack AFTER the ones the happened before them. This is much more important for understanding how the stack works. The physical location on the stack is simply a fun fact.
When thinking about the stack its much more important to think about how variables are added from a chronological stand point, because that will help with what we'll cover next. Deallocating memomry.
Memory Deallocation
How is memory removed from the stack? When each scope ends, the variables are no longer available. Sounds simple right? It's easy to understand at the basic level with our rust playground.
Rust handles the stack in a First In, Last Out method. This means that the first object added to the stack will be the last removed. Conversly, the most recently added object will be the first object to be removed.
So taking a look at our stack table we add our first two variable to the stack:
Order added to the stack | Name | Value |
---|---|---|
1 | var_2 | 2 |
0 | var_1 | 1 |
Our scope hasn't ended yet because we now call our next function and add two more variables to the stack:
Order added to the stack | Name | Value |
---|---|---|
3 | var_4 | 4 |
2 | var_3 | 3 |
1 | var_2 | 2 |
0 | var_1 | 1 |
The new_section_of_memory prints the addresses, its now done doing everything it needs to do so it deallocates var_4 from the stack first:
Order added to the stack | Name | Value |
---|---|---|
2 | var_3 | 3 |
1 | var_2 | 2 |
0 | var_1 | 1 |
The then deallocates var_3 and we return to main. Our table now, once again, looks like:
Order added to the stack | Name | Value |
---|---|---|
1 | var_2 | 2 |
0 | var_1 | 1 |
Main has nothing else it needs to do, it will clean up the rest of what is in memory. It deallocates var_2:
Order added to the stack | Name | Value |
---|---|---|
0 | var_1 | 1 |
Next we deallocate var_1, and our program is finished.
Reallocate what was just deallocated
Here's a cool example of what happens when you deallocate a scope, and allocate another. Replace the playground code with the following:
fn print_memory_address_as_decimal(mem_type: String, message: String, address: String) {
let without_prefix = address.trim_start_matches("0x");
let z = i64::from_str_radix(without_prefix, 16);
println!("{} {:?}; {}", mem_type, z.unwrap(), message);
}
fn main() {
// Put a simple variable on the stack
let var_1 = 1;
// Put another simple variable on the stack
let var_2 = 2;
print_memory_address_as_decimal("STACK:".to_string(), "The address for var 1".to_string(), format!{"{:p}", &var_1});
print_memory_address_as_decimal("STACK:".to_string(), "The address for var 2".to_string(), format!{"{:p}", &var_2});
new_section_of_memory();
new_section_of_memory2();
}
fn new_section_of_memory() {
// Put a simple variable on the stack
let var_3 = 3;
// Put another simple variable on the stack
let var_4 = 4;
print_memory_address_as_decimal("STACK:".to_string(), "The address for var 3".to_string(), format!{"{:p}", &var_3});
print_memory_address_as_decimal("STACK:".to_string(), "The address for var 4".to_string(), format!{"{:p}", &var_4});
}
fn new_section_of_memory2() {
// Put a simple variable on the stack
let var_5 = 5;
// Put another simple variable on the stack
let var_6 = 6;
print_memory_address_as_decimal("STACK:".to_string(), "The address for var 5".to_string(), format!{"{:p}", &var_5});
print_memory_address_as_decimal("STACK:".to_string(), "The address for var 6".to_string(), format!{"{:p}", &var_6});
}
We've duplicated our new_section_of_memory function and created new_section_of_memory2. Because it is an exact copy, it requires the exact same memory, and we can see with our memory addresses that it uses the exact same memory addresses.
STACK: 140721173831304; The address for var 1
STACK: 140721173831308; The address for var 2
STACK: 140721173830872; The address for var 3
STACK: 140721173830876; The address for var 4
STACK: 140721173830872; The address for var 5
STACK: 140721173830876; The address for var 6
Notice how var_3 and var_5 have the exact same address? And also var_4 and var_6?
Let's take a look at our tables again.
We start with the same workflow as before:
Order added to the stack | Name | Value |
---|---|---|
1 | var_2 | 2 |
0 | var_1 | 1 |
Our scope hasn't ended yet because we now call new_section_of_memory and add two more variables to the stack:
Order added to the stack | Name | Value |
---|---|---|
3 | var_4 | 4 |
2 | var_3 | 3 |
1 | var_2 | 2 |
0 | var_1 | 1 |
The new_section_of_memory prints the addresses, its now done doing everything it needs to do so it deallocates var_4 from the stack first:
Order added to the stack | Name | Value |
---|---|---|
2 | var_3 | 3 |
1 | var_2 | 2 |
0 | var_1 | 1 |
The then deallocates var_3 and we return to main. Our table now, once again, looks like:
Order added to the stack | Name | Value |
---|---|---|
1 | var_2 | 2 |
0 | var_1 | 1 |
Now, we are back in main and we need to call our second function, new_section_of_memory2. This adds our new variables var_5 and var_6 to the stack in the same order, and in this instance, the same location as var_3 and var_4 had just been in:
Order added to the stack | Name | Value |
---|---|---|
3 | var_6 | 6 |
2 | var_5 | 5 |
1 | var_2 | 2 |
0 | var_1 | 1 |
We then dellacate the stack exactly as he had before. Deallocating var_6 and var_5 first, then returning to main, finishing main, and deallocating the remaining memory.
This is really cool way to see that if Rust can, it will use the exact same spot on the stack that was just freed up.
However, in most cases rust will create another scope. From what I've seen it will only reuse the same memory locations if the function is within 16bytes of the size.
Changing values on the stack
When you update a value on the stack, it doesn't change it's physical location in memory. In has a fixed place on the stack, and since it has a fixed size, the value is simply updated.
If you replace the code in the playground with the following code we can see this in action:
fn print_memory_address_as_decimal(mem_type: String, message: String, address: String) {
let without_prefix = address.trim_start_matches("0x");
let z = i64::from_str_radix(without_prefix, 16);
println!("{} {:?}; {}", mem_type, z.unwrap(), message);
}
fn main() {
// Put a simple variable on the stack
let var_1 = 1;
// Put another simple variable on the stack
let mut var_2 = 2;
print_memory_address_as_decimal("STACK:".to_string(), "The address for var 1".to_string(), format!{"{:p}", &var_1});
print_memory_address_as_decimal("STACK:".to_string(), "The address for var 2".to_string(), format!{"{:p}", &var_2});
println!("value: {}", var_2);
var_2 = 7;
print_memory_address_as_decimal("STACK:".to_string(), "The address for var 2".to_string(), format!{"{:p}", &var_2});
println!("value: {}", var_2);
}
If you take a look at the output you'll see that although the value changes from 2 to 7, the address stays the same
STACK: 140731802643000; The address for var 1
STACK: 140731802643004; The address for var 2
value: 2
STACK: 140731802643004; The address for var 2
value: 7
The Heap
Now that we've covered the Stack, let's talk about the Heap. The heap is much less organized than the stack. It's slower, you need to explicitly allocate it, but it can hold large values that can we changed how ever you want.
It helps me to think of the heap as a bunch of mailboxes.
You don't know what, or how much, is any of the mailboxes. You know exactly where the data is, but you still have to go to the correct mailbox and grab the data.
When you store values in the heap, there's a few things to take into consideration.
- It lives on the stack (sort of)
- The heap is on the opposite end of memory
- You can store any object of (almost) any size
It lives on the Stack (Sort of)
To explore the heap we'll be working with a Rust Box. A Box is a way to explicitly say "I want this value to live on the heap". Replace the main function in the rust playground with the following:
fn main() {
// Put a simple variable on the stack
let var_1 = 1;
// Put a Box variable on the stack
let var_2 = Box::new(2);
print_memory_address_as_decimal("STACK:".to_string(), "The address for var 1".to_string(), format!{"{:p}", &var_1});
print_memory_address_as_decimal("STACK:".to_string(), "The address for var 2".to_string(), format!{"{:p}", &var_2});
}
Run it and check out the addresse:
STACK: 140733620245108; The address for var 1
STACK: 140733620245112; The address for var 2
So what's going on here? We can see that the addresses are right next to each other. But Box is supposed to be on the heap, and the heap is supposed to be on the opposite end of memory from the stack?
Well let's finally explain exactly what we've been printing this whole time.
When we use the &
sign in format!{"{:p}", &var_1
what we are essentially saying is "Print the address for var_1". The :p
in the curly braces is saying to print the memory location, and the &
is specifing the address of the variable, not the address of the value. This is the address on the stack. Every variable is on the stack, but not every value is on the stack. Remember our original table that looked like this:
Address | Name | Value |
---|---|---|
1 | var_2 | 2 |
0 | var_1 | 1 |
There's a big difference now that we are using Box for var_2. The value of var_2 is no longer the number 2. The value on the stack for var_2 is now the ADDRESS on the heap where the number 2 is being stored. If we remove the &
from the print statement, then we can print the address of the actual value. Test it out by using this main function:
fn main() {
// Put a simple variable on the stack
let var_1 = 1;
// Put a Box variable on the stack
let var_2 = Box::new(2);
print_memory_address_as_decimal("STACK:".to_string(), "The address for var 1".to_string(), format!{"{:p}", &var_1});
print_memory_address_as_decimal("STACK:".to_string(), "The address for var 2".to_string(), format!{"{:p}", &var_2});
print_memory_address_as_decimal(" HEAP:".to_string(), " The address for var 2's value".to_string(), format!{"{:p}", var_2});
}
We now see these addresses:
STACK: 140733620245108; The address for var 1
STACK: 140733620245112; The address for var 2
HEAP: 94569385974224; The address for var 2's value
So we can see here that the actual address of the value for var_2 is on the heap! If we update our original stack table, we'll have something like this:
Address | Name | Value |
---|---|---|
140733620245112 | var_2 | 94569385974224 |
140733620245108 | var_1 | 1 |
Rust knows that when it needs the value of var_2, that what is on the stack is a pointer, and that it needs to go to the heap, and get the value that's stored at that address. If you want further proof, try printing the address of the value of var_1 by removing the &
from the print statement:
print_memory_address_as_decimal("STACK:".to_string(), "The address for var 1".to_string(), format!{"{:p}", var_1});
You'll get the error:
^^^^^ the trait `Pointer` is not implemented for `{integer}`
Now that you know what a pointer is, the :p
in the print statement is saying "get the pointer of this value". The value 1
for var_1 doesn't HAVE a pointer. It just exists on the stack at the address of the variable. But var_2 has an address on the stack, so we can use :p
to print the pointer of the address (&var_2
) on the stack, and we can use :p
to print the pointer of the value (var_2
) on the heap. var_2's value of 2 lives on the heap.
So, this is what allows us to store anything we want in the heap. We store the pointer on the stack because that is a fixed size, but the value on the heap can be anything.
Visual Representation of Stack and Heap
Now that we have an understanding of the Stack and Heap, let's take a look at how I visualize it.
The top section shows how the stack adds each scope starting and the top down, but then within the function it increases from the bottom up.
And then at the opposite end we have the heap which is more random. It's not totally random but how the heap exactly manages the memory is something I haven't researched yet.
Working with memory management in Rust: Heap (Vec) vs Stack (Array, and Slice)
Time to take what we've learned so far, and apply it so the most common collection types in Rust. The Vector, Array, and Slice. What are the differences? Why would you use them? And how do they work in memory?
Arrays
The array is a simple one. It is a collection of values with a fixed size. Much like our simple var from before, the entirety of the array lives on the stack. Because it has a fixed size it is able to do this. You can change the values inside the array, but you can't resize it. If you have an array with a length of three, and you try to add a 4th element, you'll fail to compile.
Replace main with the following code block:
fn main() {
let mut array = [1,2,3];
print_memory_address_as_decimal("STACK:".to_string(), "The address for array".to_string(), format!{"{:p}", &array});
print_memory_address_as_decimal("STACK:".to_string(), "The address for index 0".to_string(), format!{"{:p}", &array[0]});
print_memory_address_as_decimal("STACK:".to_string(), "The address for index 1".to_string(), format!{"{:p}", &array[1]});
print_memory_address_as_decimal("STACK:".to_string(), "The address for index 2".to_string(), format!{"{:p}", &array[2]});
println!("");
println!("The value at index 1 is: {}", array[1]);
print_memory_address_as_decimal("STACK:".to_string(), "The address for index 1".to_string(), format!{"{:p}", &array[1]});
println!("");
array[1] = 7;
println!("The updated value at index 1 is: {}", array[1]);
print_memory_address_as_decimal("STACK:".to_string(), "The address for index 1".to_string(), format!{"{:p}", &array[1]});
}
If we take a look at the memory addresses, we can see that it all lives on the stack.
STACK: 140727621612596; The address for array
STACK: 140727621612596; The address for index 0
STACK: 140727621612600; The address for index 1
STACK: 140727621612604; The address for index 2
The value at index 1 is: 2
STACK: 140727621612600; The address for index 1
The updated value at index 1 is: 7
STACK: 140727621612600; The address for index 1
Not only is the address for the entire array on the stack, but you can see that the first index starts at the same exact addres, and each item in the array is 4 bytes away from the last. When we update the value at index 1, we can see that the value does change, but the address remains the same.
If you try to update the array to have a different amount of variable, less or more, you'll see get an error that says that the array must have exactly 3 elemets. Add the line array = [1,2];
at check it out.
Vectors
A vector is similar to an array, except it's value lives on the heap rather than the stack. Similar to the Box type we talked about earlier, a vector variable will be on the stack, but it's value is a pointer to where the data lives. Since the data lives on the heap it CAN be expandable. A lot of the time you're going to want to use a vector rather than an array because vectors can be dynamic. Let's update the Rust Playground to use this main function:
fn main() {
let mut vector = vec![1, 2, 3];
print_memory_address_as_decimal("STACK:".to_string(), "The address for vector".to_string(), format!{"{:p}", &vector});
print_memory_address_as_decimal(" HEAP:".to_string(), " The address for index 0".to_string(), format!{"{:p}", &vector[0]});
print_memory_address_as_decimal(" HEAP:".to_string(), " The address for index 1".to_string(), format!{"{:p}", &vector[1]});
print_memory_address_as_decimal(" HEAP:".to_string(), " The address for index 2".to_string(), format!{"{:p}", &vector[2]});
println!("");
println!("The value at index 1 is: {}", vector[1]);
print_memory_address_as_decimal(" HEAP:".to_string(), " The address for index 1".to_string(), format!{"{:p}", &vector[1]});
println!("");
vector[1] = 7;
println!("The updated value at index 1 is: {}", vector[1]);
print_memory_address_as_decimal(" HEAP:".to_string(), " The address for index 1".to_string(), format!{"{:p}", &vector[1]});
}
And our memory addresses are:
STACK: 140726824245320; The address for vector
HEAP: 94366313810384; The address for index 0
HEAP: 94366313810388; The address for index 1
HEAP: 94366313810392; The address for index 2
The value at index 1 is: 2
HEAP: 94366313810388; The address for index 1
The updated value at index 1 is:7
HEAP: 94366313810388; The address for index 1
We can see that for the values in the array themselves, it performs exactly as the array does, but it's on the heap instead of the stack. However, because the vector lives on the stack, we can add or remove values to the vec and not receive errors.
Let's add a few values and see what we get:
fn main() {
let mut vector = vec![1, 2, 3];
print_memory_address_as_decimal("STACK:".to_string(), "The address for vector".to_string(), format!{"{:p}", &vector});
print_memory_address_as_decimal(" HEAP:".to_string(), " The address for index 0".to_string(), format!{"{:p}", &vector[0]});
print_memory_address_as_decimal(" HEAP:".to_string(), " The address for index 1".to_string(), format!{"{:p}", &vector[1]});
print_memory_address_as_decimal(" HEAP:".to_string(), " The address for index 2".to_string(), format!{"{:p}", &vector[2]});
println!("");
println!("The value at index 1 is: {}", vector[1]);
print_memory_address_as_decimal(" HEAP:".to_string(), " The address for index 1".to_string(), format!{"{:p}", &vector[1]});
println!("");
vector[1] = 7;
println!("The updated value at index 1 is: {}", vector[1]);
print_memory_address_as_decimal(" HEAP:".to_string(), " The address for index 1".to_string(), format!{"{:p}", &vector[1]});
println!("");
vector.push(4);
vector.push(5);
print_memory_address_as_decimal("STACK:".to_string(), "The address for vector".to_string(), format!{"{:p}", &vector});
print_memory_address_as_decimal(" HEAP:".to_string(), " The address for index 0".to_string(), format!{"{:p}", &vector[0]});
print_memory_address_as_decimal(" HEAP:".to_string(), " The address for index 1".to_string(), format!{"{:p}", &vector[1]});
print_memory_address_as_decimal(" HEAP:".to_string(), " The address for index 2".to_string(), format!{"{:p}", &vector[2]});
print_memory_address_as_decimal(" HEAP:".to_string(), " The address for index 3".to_string(), format!{"{:p}", &vector[3]});
print_memory_address_as_decimal(" HEAP:".to_string(), " The address for index 4".to_string(), format!{"{:p}", &vector[4]});
}
As we can see in our addresses here, we are retaining the exact same memory location on the heap and are simply adding more values to it.
STACK: 140733255086760; The address for vector
HEAP: 94201032604112; The address for index 0
HEAP: 94201032604116; The address for index 1
HEAP: 94201032604120; The address for index 2
The value at index 1 is: 2
HEAP: 94201032604116; The address for index 1
The updated value at index 1 is: 7
HEAP: 94201032604116; The address for index 1
STACK: 140733255086760; The address for vector
HEAP: 94201032604112; The address for index 0
HEAP: 94201032604116; The address for index 1
HEAP: 94201032604120; The address for index 2
HEAP: 94201032604124; The address for index 3
HEAP: 94201032604128; The address for index 4
But what happens when we add more? Lets add 2 more values to our vector:
fn main() {
let mut vector = vec![1, 2, 3];
print_memory_address_as_decimal("STACK:".to_string(), "The address for vector".to_string(), format!{"{:p}", &vector});
print_memory_address_as_decimal(" HEAP:".to_string(), " The address for index 0".to_string(), format!{"{:p}", &vector[0]});
print_memory_address_as_decimal(" HEAP:".to_string(), " The address for index 1".to_string(), format!{"{:p}", &vector[1]});
print_memory_address_as_decimal(" HEAP:".to_string(), " The address for index 2".to_string(), format!{"{:p}", &vector[2]});
println!("");
println!("The value at index 1 is: {}", vector[1]);
print_memory_address_as_decimal(" HEAP:".to_string(), " The address for index 1".to_string(), format!{"{:p}", &vector[1]});
println!("");
vector[1] = 7;
println!("The updated value at index 1 is: {}", vector[1]);
print_memory_address_as_decimal(" HEAP:".to_string(), " The address for index 1".to_string(), format!{"{:p}", &vector[1]});
println!("");
vector.push(4);
vector.push(5);
vector.push(6);
vector.push(7);
print_memory_address_as_decimal("STACK:".to_string(), "The address for vector".to_string(), format!{"{:p}", &vector});
print_memory_address_as_decimal(" HEAP:".to_string(), " The address for index 0".to_string(), format!{"{:p}", &vector[0]});
print_memory_address_as_decimal(" HEAP:".to_string(), " The address for index 1".to_string(), format!{"{:p}", &vector[1]});
print_memory_address_as_decimal(" HEAP:".to_string(), " The address for index 2".to_string(), format!{"{:p}", &vector[2]});
print_memory_address_as_decimal(" HEAP:".to_string(), " The address for index 3".to_string(), format!{"{:p}", &vector[3]});
print_memory_address_as_decimal(" HEAP:".to_string(), " The address for index 4".to_string(), format!{"{:p}", &vector[4]});
print_memory_address_as_decimal(" HEAP:".to_string(), " The address for index 5".to_string(), format!{"{:p}", &vector[5]});
print_memory_address_as_decimal(" HEAP:".to_string(), " The address for index 6".to_string(), format!{"{:p}", &vector[6]});
}
Check it out. Our vector has moved to a new location on the heap! What's going on here? Did it create a new one or move it?
STACK: 140725224402664; The address for vector
HEAP: 94816360491472; The address for index 0
HEAP: 94816360491476; The address for index 1
HEAP: 94816360491480; The address for index 2
The value at index 1 is: 2
HEAP: 94816360491476; The address for index 1
The updated value at index 1 is: 7
HEAP: 94816360491476; The address for index 1
STACK: 140725224402664; The address for vector
HEAP: 94816360491600; The address for index 0
HEAP: 94816360491604; The address for index 1
HEAP: 94816360491608; The address for index 2
HEAP: 94816360491612; The address for index 3
HEAP: 94816360491616; The address for index 4
HEAP: 94816360491620; The address for index 5
HEAP: 94816360491624; The address for index 6
If we update one of the items in the array again, we'll see that the vector has in fact moved. Add this to the end of the main function
println!("");
vector[1] = 2;
println!("The updated value at index 1 is: {}", vector[1]);
print_memory_address_as_decimal(" HEAP:".to_string(), " The address for index 1".to_string(), format!{"{:p}", &vector[1]});
Run this and you'll see that the vector has definitely moved. We added 4 items to the array, all the addresses changed, we updated the value at a specific index and that adress is the same as the new one.
STACK: 140734978165816; The address for vector
HEAP: 94899678824912; The address for index 0
HEAP: 94899678824916; The address for index 1
HEAP: 94899678824920; The address for index 2
The value at index 1 is: 2
HEAP: 94899678824916; The address for index 1
The updated value at index 1 is: 7
HEAP: 94899678824916; The address for index 1
STACK: 140734978165816; The address for vector
HEAP: 94899678825040; The address for index 0
HEAP: 94899678825044; The address for index 1
HEAP: 94899678825048; The address for index 2
HEAP: 94899678825052; The address for index 3
HEAP: 94899678825056; The address for index 4
HEAP: 94899678825060; The address for index 5
HEAP: 94899678825064; The address for index 6
The updated value at index 1 is: 2
HEAP: 94899678825044; The address for index 1
So what is happening here? When you use a vector it is composed of two additional fields. Not only do we have the name of the variable and the address, we also have the length and the capacity. The length tells us how many values are in the vector, and the capacity is how many values the vector CAN hold. When the length is larger than the capacity the vector needs to deallocate itself and allocate a new instance. You can see that the stack address did not change. It is still the same variable, but the value on the heap has now changed and moved around. You can initialize vectors with a capacity to be more efficient with memory but that's out of scope for this overview.
Slices
A slice is like a window to view the values of a collection. Like a vector, a slice lives on the stack, has it's name, has a pointer to where to find the data, and finally has a length, however it does not have a capacity. A slice is simple saying "go to this address in memory, and read X number of bytes". You can think of a slice as read only access to data.
Let's check out some addresses and values in the Rust playground:
fn main() {
let vector = vec![1, 2, 3, 4, 5];
print_memory_address_as_decimal("STACK:".to_string(), "The address for vector".to_string(), format!{"{:p}", &vector});
print_memory_address_as_decimal(" HEAP:".to_string(), " The address for index 0".to_string(), format!{"{:p}", &vector[0]});
print_memory_address_as_decimal(" HEAP:".to_string(), " The address for index 1".to_string(), format!{"{:p}", &vector[1]});
print_memory_address_as_decimal(" HEAP:".to_string(), " The address for index 2".to_string(), format!{"{:p}", &vector[2]});
print_memory_address_as_decimal(" HEAP:".to_string(), " The address for index 3".to_string(), format!{"{:p}", &vector[3]});
print_memory_address_as_decimal(" HEAP:".to_string(), " The address for index 4".to_string(), format!{"{:p}", &vector[4]});
println!("");
let slice = &vector[2..];
print_memory_address_as_decimal("STACK:".to_string(), "The address for slice".to_string(), format!{"{:p}", &slice});
print_memory_address_as_decimal(" HEAP:".to_string(), " The address for slice 0".to_string(), format!{"{:p}", &slice[0]});
println!("The value at slice index 0 is: {}", slice[0]);
print_memory_address_as_decimal(" HEAP:".to_string(), " The address for slice 1".to_string(), format!{"{:p}", &slice[1]});
println!("The value at slice index 1 is: {}", slice[1]);
print_memory_address_as_decimal(" HEAP:".to_string(), " The address for slice 2".to_string(), format!{"{:p}", &slice[2]});
println!("The value at slice index 2 is: {}", slice[2]);
}
So we declare an slice by writing &vector[2..]
which is saying to get the address for the value at the index 2 until the end of the vector variable. So what we should expect to see in our slice is 3 values (index 2, 3, and 4) from the array and they should have the exact same addresses on the heap.
STACK: 140729459255624; The address for vector
HEAP: 94619019184592; The address for index 0
HEAP: 94619019184596; The address for index 1
HEAP: 94619019184600; The address for index 2
HEAP: 94619019184604; The address for index 3
HEAP: 94619019184608; The address for index 4
STACK: 140729459256752; The address for slice
HEAP: 94619019184600; The address for slice 0
The value at slice index 0 is: 3
HEAP: 94619019184604; The address for slice 1
The value at slice index 1 is: 4
HEAP: 94619019184608; The address for slice 2
The value at slice index 2 is: 5
So as we can see, our vector was declared, it owns the values on the heap. When we declare our slice we are just getting read access to the specified values we asked for. The memory addresses and values for the index values of 2-4 in our vector match exactly with the address and values of index 0-2 in our slice. But the Stack address are different, showing that these are different objects!
Conclusion
Hopefully now you've got a much better understand of the heap vs the stack. The stack is fast, organized, and the default allocation. Everything lives at least a little bit on the stack. The heap is slower, resizable, and is stored at the opposite end of the memory than the stack is. This was simultaneously a high level and very low level look into how it works. It might not seem incredibly important at the moment, but having a good understanding of the stack and the heap will help in the future when you're trying to do some sweet rust efficiency.
Top comments (0)