Ownership in Rust

Ownership in Rust

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 the Heap
  • 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 in Rust - Rust programming tutorial

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 and the Heap

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 Stack and the Heap

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 and pop 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.

The Stack and 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:

  1. Each value in Rust has a variable that's its owner.
  2. There can only be one owner at a time.
  3. 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 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.

The Clone Trait

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);

The Copy Trait

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 the Heap
  • 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

Did you find this article valuable?

Support Francesco Ciulla by becoming a sponsor. Any amount is appreciated!