Mastering Rust's Ownership: The Key to Memory Safety and Efficiency
Son DotCom 🥑💙
Posted on August 14, 2023
The ownership mechanism in Rust is a unique feature that sets it apart from other mainstream programming languages. It's what makes Rust both memory-safe and efficient while avoiding the need for a garbage collector, a common feature in languages like Java, Go, and Python. Understanding ownership is crucial for becoming proficient in Rust. In this article, we will explore the following topics:
- Stack and Heap
- Variable Scope
- Ownership
- Borrowing (Referencing)
- Mutable Reference
Note: This article is beginner-friendly. Familiarity with Rust's basic syntax, variable declaration within functions, and basic data types is assumed. If you need a quick refresher, you can check out this guide.
Stack AND Heap
Memory in Rust can be divided into two main parts: the stack and the heap. Both of these are available during both compile time and runtime and serve different purposes. Understanding their mechanics is crucial for writing efficient Rust code.
Stack
The stack is used to store data with known sizes at compile time. The size of data stored on the stack is fixed and cannot be changed during runtime.
The stack operates using a First In, Last Out (FILO) mechanism, similar to stacking plates. The first plate added is the last one removed.
Rust automatically adds primitive types to the stack:
fn main() {
let num: i32 = 14;
let array = [1, 2, 4];
let character = 'a';
let tp = (1, true);
println!("number = {}, array = {:?}, character = {}, tp = {:?}", num, array, character, tp);
}
The stack layout for the main
method would be as follows:
Address | Name | Value |
---|---|---|
3 | tp | (1, true) |
2 | character | 'a' |
1 | array | [1,2,4] |
0 | num | 14 |
In this table, the num
variable, being the first added, will be the last removed when the function scope ends.
Note: Address column uses numbers as representation, ideally addresses in computers are not just numbers.
Heap
The heap is used for data with sizes unknown at compile time. Types like Vec
, String
, and Box
are stored on the heap automatically.
fn main() {
let num: i32 = 14;
let new_str = String::new();
}
The memory mapping for data on the heap is as follows:
STACK MEM
Address | Name | Value |
---|---|---|
1 | new_str | ptr1 |
0 | num | 14 |
HEAP MEM
Address | Name | Value |
---|---|---|
ptr1 | "" |
When storing data on the heap, a pointer (like ptr1
) is first stored on the stack. This pointer then references the actual data on the heap. This additional step makes heap access slower compared to stack access.
With stack and heap mechanisms understood, let's delve into variable scope.
Variable Scope
Variable scope defines the range in code where a variable is valid. In Rust, curly braces create blocks, and variables within these blocks are scoped to them.
fn main() {
// everything here is scoped to this block
let hello = "Hello World";
// ...
}
Variables within a block cannot be accessed outside that block:
fn main() {
let hello = "Hello World";
// inner block
{
let num = 20;
}
// This will fail to compile
println!("{} {}", hello, num);
}
From the above you can't use num
outside the inner block because it is scoped only to the inner block.
Rust runs functions from top to bottom, allocating stack and heap memory as needed. When the scope ends, Rust calls the drop
method internally to deallocate memory, a crucial concept for understanding Rust's approach to memory management.
Ownership
Here we come to the heart of the article, where we explore ownership and its impact on Rust programming. Remember these ownership rules as we proceed:
Ownership Rules
- There can only be one owner of a value at a time.
- Each value has an owner.
- If the owner goes out of scope the value is dropped.
Lets see what this mean below:
fn main() {
let array = vec![1,2,3];
let title = String::new();
let new_array = array;
let new_title = title;
println!("array {:?} title {} new_array {:?} new_title {}", array, title, new_array, new_title);
}
The compilation error "borrow of moved value" arises because both array
and title
are being owned by multiple variables simultaneously. This violates the ownership rules.
Rust's memory layout for the code above is as follows:
STACK MEM
Address | Name | Value |
---|---|---|
3 | new_title | ptr2Copy |
2 | new_array | ptr1Copy |
1 | title | ptr2 |
0 | array | ptr1 |
HEAP MEM
Address | Name | Value |
---|---|---|
ptr2 and ptr2Copy | "" | |
ptr1 and ptr1Copy | vec![1,2,3] |
As seen from above because vectors and strings are non fixed size types Rust allocates them to the heap and can be accessed via pointer from the stack.
The ptr1Copy
pointer is a copy of ptr1
pointer and the ptr2Copy
pointer is a copy of ptr2
pointer.
When Rust is done it then calls the drop
method, the drop
method walks through the stack remember FILO we explained above? It starts by removing the last item added to the stack which is new_title
then finds out it has a pointer(ptr2Copy
) goes to the heap memory via that pointer and removes the string value. It continues to the next element on the stack new_array
which also has a pointer ptr1Copy
and removes the vector from the heap memory too. It again continues to the next which is title
and has a pointer ptr2
but when it follows the pointer it realises that the data no longer exits as it has been removed via ptr2Copy
this why it throws error for title
the same thing goes for the array
variable.
The Ownership rules apply only to Rust types on the heap. Primitive types on the stack don't follow these rules:
fn main() {
let array = [1,2,3];
let title = "hello";
let new_array = array;
let new_title = title;
println!("array {:?} title {} new_array {:?} new_title {}", array, title, new_array, new_title);
}
For readers familiar with other languages, reassigning values to new variables while they're on the heap might seem intuitive. How do we achieve this in Rust?
We can use the .clone
method, which duplicates the heap value and stores it in a new location on the heap:
See below:
fn main() {
let array = vec![1,2,3];
let title = String::new();
let new_array = array.clone();
let new_title = title.clone();
println!("array {:?} num {} new_array {:?} new_num {}", array, title, new_array, new_title);
}
From the above the code works now, let's see how the translation is done on the stack and heap
STACK MEM
Address | Name | Value |
---|---|---|
3 | new_title | ptr2Copy |
2 | new_array | ptr1Copy |
1 | title | ptr2 |
0 | array | ptr1 |
HEAP MEM
Address | Name | Value |
---|---|---|
ptr2Copy | "" | |
ptr1Copy | vec![1,2,3] | |
ptr2 | "" | |
ptr1 | vec![1,2,3] |
While this approach works, it might result in unnecessary memory consumption and inefficiencies.
Borrowing
In Rust, borrowing (or referencing) allows us to use values without taking ownership. To reference a value, we use the &
sign.
fn main() {
let hello = String::from("hello");
let another_hello = &hello;
let arr = vec![1];
let another_arr = &arr;
println!("hello = {} another_hello = {} {:?} {:?}", hello, another_hello, another_arr, arr)
}
By referencing values, we can read data without copying it, avoiding unnecessary memory overhead.
This is also applicable for functions parameters. Function parameters have their own scope, passing a value to it will automatically move the ownership to the parameters.
fn main() {
let name = String::from("John");
say_hello(name);
println!("{}", name)
}
fn say_hello(name: String) {
println!("hello {}", name)
}
The above failed because the ownership of value "John" was moved to the function params.
We can resolve this error by borrowing from the name owner, see below
fn main() {
let name = String::from("John");
say_hello(&name);
println!("{}", name)
}
fn say_hello(name: &String) {
println!("hello {}", name)
}
We don't have anymore errors, looks good so far.
All these while we've only read the value from the owner what if we want to update that value and still not take ownership?
Mutable References
Mutating values in Rust requires the mut
keyword, and to mutate a value via reference, we use the &mut
keyword.
fn main() {
let mut name = String::from("John");
full_name(&mut name);
println!("Your full name is: {}", name);
}
fn full_name(name: &mut String) {
name.push_str(" Doe");
}
From above the full_name
function is able to update the name value without taking ownership of the value.
One thing to always remember is at any given time, you can have either one mutable reference or any number of immutable references.
So the following code will fail to compile:
fn main() {
let mut s = String::from("hello");
let r1 = &mut s;
let r2 = &mut s;
println!("{}, {}", r1, r2);
}
Even if we are just reading the value hello
as far as we have a single mutable reference rust complains. The below still fails to compile
fn main() {
let mut s = String::from("hello");
let r1 = &s;
let r2 = &s;
let r3 = &mut s;
println!("{}, {}, {}", r1, r2, r3);
}
The reason for this is to avoid data inconsistencies because rust cannot guarantee at what point the reading or data change will happen.
We can make the above work with scope.
fn main() {
let mut s = String::from("hello");
// inner scope
{
let r1 = &mut s;
println!("{}", r1);
}
let r2 = &mut s;
println!("{}", r2);
}
Also we can read all read references and be done with them before mutating.
fn main() {
let mut s = String::from("hello");
let r1 = &s; // no problem
let r2 = &s; // no problem
println!("{} and {}", r1, r2);
// variables r1 and r2 will not be used after this point
let r3 = &mut s; // no problem
println!("{}", r3);
}
Rust enforces us to think of our code properly to avoid writing buggy code that are hard to fix.
Conclusion
In the world of programming languages, Rust stands out as a remarkable choice due to its ownership mechanism. This mechanism, while initially challenging to grasp, is the cornerstone of Rust's memory safety and efficiency. By strictly adhering to rules that enforce single ownership, Rust mitigates the risk of memory leaks, data inconsistencies, and other common programming pitfalls.
Through this article, we've explored the intricate workings of Rust's ownership concept. We began with a foundational understanding of the stack and heap, the pillars that support memory allocation. The stack, efficient for fixed-size data, and the heap, accommodating dynamic data, together enable Rust to balance performance and flexibility.
The significance of variable scope became evident as we delved into the importance of block-level scoping. Rust's rigorous scope management not only simplifies code but also assists in automating memory deallocation through the drop mechanism.
Ownership, the core of Rust's memory management philosophy, teaches us the invaluable lesson that only one entity can own a value at any given time. This principle may initially seem restrictive, but it's an essential safeguard against data races and memory issues.
We learned how to navigate these ownership rules by utilizing borrowing, a mechanism that allows controlled access to values without compromising ownership. We saw how references (&
) and mutable references (&mut
) facilitate this process, enabling both reading and manipulation without sacrificing data integrity.
In a landscape where memory safety is often sacrificed for convenience, Rust shines as a language that compels developers to adopt best practices. While the ownership model can be challenging, mastering it will make you a more disciplined and effective Rust programmer.
As you continue your journey with Rust, remember that ownership isn't just a technicality; it's a mindset. Embrace it, let it guide your coding decisions, and you'll unlock the true potential of this language. Rust's ownership is not merely a hurdle to overcome; it's a superpower to wield responsibly.
So, as you write your Rust code, keep these ownership principles in mind, and you'll be well on your way to creating robust, efficient, and reliable software that stands the test of time.
Posted on August 14, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.