Ownership in Rust
Rust has a concept of ownership that is unique among programming languages. It is a key feature of the language that allows it to be both safe and fast. In this lesson, we will explore what ownership is, how it works, and why it is important.
If you prefer a video version
All the code is available on GitHub (link available in the video description)
In this lesson
In this lesson, we will cover the following topics:
- What is ownership?
- The
Stack
and theHeap
- Ownership rules
- The
String
type - Memory allocation and the
Drop
function - The
Move
trait - The
Clone
trait - The
Copy
trait - Final Recap
What is Ownership?
Ownership is a distinct feature of Rust, enabling safe and efficient memory management.
In Rust, each value has a sole owner responsible for its disposal when it's not needed, eliminating the need for garbage collection or reference counting.
This lesson will be about ownership through examples centered on Strings, a common data structure.
⚠️ Before we proceed, it's important to understand that Ownership is not something that runs in the background or adds an overhead to the program. It's a concept that is enforced at compile time, and it's a key feature of the language that allows it to be both safe and fast.
The Stack and the Heap
Understanding the stack and heap is essential in Rust due to its unique memory management through ownership.
The Stack
The Stack, used for fixed-size data, provides quick access.
It has some methods to manage the data, like push
and pop
, and it's very fast.
All the data stored on the stack must have a known, fixed size at compile time, making it ideal for small, fixed-size data.
Data with an unknown size or a size that might change at runtime is stored on the heap, which is slower than the stack due to its dynamic nature.
The Heap
The Heap, on the other hand, is used for data whose size might change or cannot be determined until runtime.
The Heap is less organized than the stack, and it's slower to access data stored on it.
The momory allocated on the heap is not managed by the Rust compiler, but by the programmer. This is why Rust has a strict ownership model for managing the memory safely and efficiently, preventing memory leaks, and ensuring data is cleaned up properly.
Note: the pointer to the data is stored on the stack, but the data itself is stored on the heap.
Operations on the Stack and the Heap
Here is a recap of the operations on the stack and the heap:
The stack is fast and efficient, but it can only store data with a known, fixed size at compile time.
The heap is slower and less organized, but it can store data with an unknown size or a size that might change at runtime.
The memory allocated on the heap is not managed by the Rust compiler but by the programmer.
Functions like
push
andpop
are available for the stack, but not for the heap.The pointer to the data is stored on the stack, but the data itself is stored on the heap.
Ownership purpose and Rules
Ownership's primary purpose is to manage the data stored on the Heap, ensuring it's cleaned up properly and preventing memory leaks.
To achieve this:
- it keeps track of what code is using what data on the heap
- it minimizes the amount of duplicate data on the heap
- it cleans up the data on the heap when it's no longer needed
Rust has a few ownership rules:
- Each value in Rust has a variable that's its owner.
- There can only be one owner at a time.
- When the owner goes out of scope, the value will be dropped.
Let's see an example.
Variable scope
In Rust, a variable is only valid within the scope it was declared.
Even when a variable is stored on the Stack, it is only valid within the scope it was declared.
fn main() {
{ // s is not valid here, it’s not yet declared
let s = "hello"; // s is valid from this point forward
// do stuff with s
} // this scope is now over, and s is no longer valid
}
In the example above, s
is the owner of the string "hello". When s
goes out of scope, the string will not be valid anymore.
The String type
In a previous lesson, we covered simple, fixed-size data types in Rust.
Now, we'll explore the String type, a compound type allocated on the heap, with a detailed look planned for a future lesson.
We've touched on string literals, which are immutable and embedded in the program. Unlike these, the String type is mutable and heap-allocated, allowing it to hold text of variable length, unknown at compile time.
let s = String::from("hello");
This kind of string can be mutated:
let mut s = String::from("hello");
s.push_str(", world!"); // push_str() appends a literal to a String
println!("{}", s); // This will print `hello, world!`
So, what’s the difference here?
The key difference lies in their memory handling: String can be mutated due to its dynamic memory allocation on the heap, while literals, stored in fixed memory, cannot be changed.
Memory Allocation and the Drop Function
{
let s = String::from("hello"); // s is valid from this point forward
// do stuff with s
} // this scope is now over, and s is no longer valid
When a variable goes out of scope, Rust calls a special function for us.
This function is called drop
, and it’s where the author of String can put the code to return the memory.
Rust calls drop automatically at the closing curly bracket.
The Move Trait
If we try to do something like that:
let s1 = 5;
let s2 = s1;
// print s2
println!("{}, ", s2);
// print s1
println!("{}, ", s1);
This code is valid and it will print 5
twice.
If we do something like that:
let s1 = "hello";
let s2 = s1;
// print s2
println!("{}, ", s2);
// print s1
println!("{}, ", s1);
This code is also valid, and it will print hello
twice.
But if we type something like that:
let s1 = String::from("hello");
let s2 = s1;
// print s2
println!("{}, ", s2);
// print s1
println!("{}, ", s1);
This code will throw a compile-time error!
Why? Because Rust considers s1
to be invalid after the assignment to s2
. This is because Rust has a special trait called Move
that is implemented for the String
type.
Below is a schema of what happens. We might think that when we use the =
operator, we copy the pointer or the whole data again, but this is not what happens: We MOVE
the pointer to the new variable (that's why it's called Move trait).
The s1
variable is no longer valid after the assignment to s2
.
To 'fix' our code, we can use the clone
method (as suggested by the compiler):
let s1 = String::from("hello");
let s2 = s1.clone();
// print s2
println!("{}, ", s2);
// print s1
println!("{}, ", s1);
The Clone Trait
If we call the clone method, what happens is exactly what is shown in the schema below: we copy the data and the pointer to the new variable.
So why do we need the clone? Because for the variables stored on the heap, Rust, by default, does not copy the data when we assign a variable to another. It only copies the pointer to the data. This is because copying the data would be expensive in terms of performance.
So if we use the clone
method or we read someone else using the clone
method in their code, we know that this was intentional and that the author of the code wanted to copy the data and the pointer to the new variable.
Stack-Only Data: Copy
So now you might be wondering: "Hey, if we need to use the clone
method to copy the data and the pointer to the new variable, why didn't we need to use the clone
method when we copied the integer or the string literal?"
let x = 5;
let y = x;
println!("x = {}, y = {}", x, y);
Integers, being simple fixed-size values, are stored directly on the stack, enabling Rust to copy their bits from one variable to another without invalidation upon scope exit. This is because there's no distinction between deep and shallow copying for such types, making bit-copy safe.
Rust utilizes the Copy trait for types like integers that reside on the stack, ensuring automatic data duplication without runtime overhead. However, types with the Drop trait, which require special cleanup, cannot implement Copy. Adding Copy to such types would lead to compile-time errors, as it contradicts the need for controlled resource release.
Types eligible for the Copy trait include simple scalar values that don't need dynamic allocation or aren't complex resources.
Examples include
- all integer types (e.g., u32)
- the Boolean type (bool)
- floating-point types (e.g., f64)
- the character type (char)
- tuples containing only Copy types (e.g., (i32, i32)).
Conversely, tuples with non-Copy components, like (i32, String), do not implement Copy.
Final Recap
In this lesson, we covered the following topics:
- What is ownership?
- The
Stack
and theHeap
- Ownership rules
- The
String
type - Memory allocation and the
Drop
function - The
Move
trait - The
Clone
trait - The
Copy
trait
In the next lesson, we will cover references and borrowing in Rust.
If you prefer a video version
All the code is available on GitHub (link available in the video description)
You can find me here: Francesco