07 Ownership of the Power of Life and Death Ultimately in Whose Hands

07 Ownership: Who Truly Holds the Power of Life and Death over Value? #

Hello, I’m Chen Tian.

After completing last week’s “get hands dirty” challenge, you must have developed an emotional understanding of Rust’s charm. Are you feeling super confident and starting to try writing small projects?

But as your code grows, the compiler seems to start working against you. Some code that seems fine keeps throwing mysterious errors during compilation.

So from today, let’s return to a rational approach and study the hardest nut to crack in the Rust learning process: ownership and lifetimes. Why start with this knowledge point? Because, ownership and lifetimes are a major distinction between Rust and other programming languages, and they are also the basis for other Rust concepts.

Many Rust beginners don’t quite understand this area, half-learn and continue to study, and as a result, they find it increasingly tough and often trip up when they start coding in practice; the compiler’s errors can lead to losing confidence in Rust.

The reason that ownership and lifetimes are so hard to understand is not only their unique angle on solving memory safety issues but also because the current resources are not beginner-friendly, tending to explain how to use Copy/Move semantics right away without clarifying why it is used this way.

So in this lecture, we’ll switch perspectives and starting with how a variable uses the stack, we’ll explore the intentions behind Rust’s design of ownership and lifetimes to help you solve these compilation issues fundamentally.

What Happens to a Variable During Function Calls #

First, let’s take a look at what happens in most familiar programming languages when a variable is passed to a function and what problems arise.

Consider this code: In the main() function, a dynamic array data and a value v are defined, then passed to the function find_pos. The function checks if v exists in data, and if so, returns the index where v is found, otherwise returns None (Code 1):

fn main() {
    let data = vec![10, 42, 9, 8];
    let v = 42;
    if let Some(pos) = find_pos(data, v) {
        println!("Found {} at {}", v, pos);
    }
}

fn find_pos(data: Vec<u32>, v: u32) -> Option<usize> {
    for (pos, item) in data.iter().enumerate() {
        if *item == v {
            return Some(pos);
        }
    }

    None
}

This code isn’t difficult to understand, but it’s important to reemphasize that because the dynamic array’s size can’t be determined during compile-time, it is placed on the heap, and there is a fat pointer on the stack containing the length and capacity pointing to the memory on the heap.

When find_pos() is called, local variables data and v from main() are passed as parameters to find_pos(), so they are placed in the parameter area of find_pos().

Function call stack and heap diagram

By the approach of most programming languages, there are now two references to the memory on the heap. Not only that, every time we pass data as a parameter, there is one more reference to the memory on the heap.

We don’t know what these references will do, nor can we restrict them, and it’s especially hard to determine when the memory on the heap can be freed, particularly when multiple call stacks are referencing it. So, such a seemingly simple function call can cause a great deal of trouble in memory management.

For the problem of multiple references to heap memory, let’s look at the solutions of most languages:

  • C/C++ require manual management by the developer, which is very inconvenient. This requires high discipline when writing code, operating according to best practices summarized by others. But people will inevitably make mistakes, and one carelessness can lead to memory safety issues, either memory leaks or using freed memory, leading to program crashes.
  • Languages like Java use tracing GC, periodically scanning the heap data for references to manage the heap memory for developers. It’s a solution, but the STW (stop-the-world) problem brought by GC limits the language’s applicability and also leads to significant performance loss.
  • ObjC/Swift use Automatic Reference Counting (ARC), automatically adding code to maintain reference counts during compilation, reducing the developer’s burden of maintaining heap memory. However, this also results in a significant runtime performance loss.

Existing solutions all consider the problem from the perspective of managing references, each with its drawbacks. Revisiting the function call process we’ve just reviewed, the fundamental problem is the arbitrary referencing of heap memory. So, changing perspective, could we restrict the behavior of references themselves?

Rust’s Solution #

This idea opened a new door, and Rust took a different path.

Before Rust, referencing was a casual, implicitly generated behavior with no defined permissions, such as the rampant pointers in C or the ubiquitous by-reference parameters in Java. They were readable and writable, with extensive permissions. Rust decided to restrict the developers’ casual referencing behavior.

In fact, as developers, we often feel that appropriate restrictions can unleash infinite creativity and productivity. The most typical examples are various development frameworks like React, Ruby on Rails, etc., which restrict developers’ use of the language but greatly improve productivity.

Okay, we have the idea, but how do we actually implement these restrictions on data referencing behavior?

To answer this question, we first need to answer: Who truly owns data or the power of life and death over value, and can this right be shared or does it need exclusive ownership?

Ownership and Move Semantics #

As usual, let’s first try to answer whether the right over life and death of value can be shared or needs to be exclusive. We may feel that it is better for a value to have just one owner, as shared ownership will inevitably lead to ambiguity in use and release, going back down the old road of tracing GC or ARC.

So how do we ensure exclusivity? The specific implementation is actually somewhat difficult due to the many situations one needs to consider. For example, transferring a variable to another variable, passing it as a parameter to another function, or returning it from a function could all lead to duplicate owners of the variable. What can be done?

Rust lays out the following rules:

  • A value can only be owned by one variable, known as the owner (Each value in Rust has a variable that’s called its owner).
  • At any given moment, there can only be one owner (There can only be one owner at a time), meaning two variables cannot own the same value. So for variable assignment, parameter passing, or returning from a function, as mentioned above, the old owner transfers the ownership of the value to the new owner to ensure the single owner constraint.
  • When the owner goes out of scope, the value is dropped (When the owner goes out of scope, the value will be dropped), and the memory is released.

These three rules are easy to understand, with the core being to ensure a single ownership. The second rule on the transfer of ownership is the Move semantics, an idea Rust borrowed and learned from C++.

Scope is a new concept in the third rule, which I’ll briefly explain - it refers to a block of code. In Rust, a set of curly braces encloses a scope. For example, if a variable is defined within an if {}, then when the if statement ends, the scope of that variable ends as well, and its value will be dropped; the same applies to variables defined in a function, which are discarded when leaving the function.

Under these ownership rules, let’s see how the previously mentioned referencing issue is resolved: - Ownership visualization

Originally, the data from the main() function, when moved to find_pos(), becomes invalidated, and the compiler ensures that subsequent code in main() cannot access this variable. This ensures there is still only one reference to the memory on the heap.

Looking at this picture, you may wonder: Another parameter v passed from main() to find_pos() would have been moved as well, right? Why isn’t it grayed out in the picture? Let’s set this question aside for a moment until the end of this lecture, by which time you should have an answer.

Let’s now write some code to deepen our understanding of ownership.

In this piece of code, we first create an immutable data data, then assign data to data1. According to ownership rules, after assignment, the value pointed to by data is moved to data1, making it inaccessible. Later, data1 is passed as a parameter to the sum() function, and afterward, data1 is inaccessible in the main() function.

However, the subsequent code still attempts to access data1 and data, so there should be two errors in this code (Code 2):

fn main() {
    let data = vec![1, 2, 3, 4];
    let data1 = data;
    println!("sum of data1: {}", sum(data1));
    println!("data1: {:?}", data1); // error1
    println!("sum of data: {}", sum(data)); // error2
}

fn sum(data: Vec<u32>) -> u32 {
    data.iter().fold(0, |acc, x| acc + x)
}

The compiler indeed catches these two errors at runtime and tells us clearly that we cannot use variables that have been moved:

Compiler error

What can we do if we want to pass data1 to sum() while still allowing main() to access data?

We can call data.clone() to copy data for data1, so on the heap, we have two independent and separately releasable copies of vec![1,2,3,4], as shown in the following diagram:

Cloning values in Rust

You can see that the ownership rules resolve who truly owns the power over the data by preventing multiple references to the same heap data, which is their biggest advantage.

But this can also make the code more complex, especially for simple data that is only stored on the stack. If we want to avoid not being able to access data after transfer of ownership, we have to manually copy it, which can be very troublesome and inefficient.

Rust has considered this and offers two solutions:

  1. If you do not wish for value ownership to be transferred, Rust offers Copy semantics in addition to Move semantics. If a data structure implements the Copy trait, it will use Copy semantics. This means, when assigning or passing as a parameter, the value will be automatically copied (shallow copy).
  2. If you do not wish for value ownership to be transferred and can’t use Copy semantics, you can “borrow” data, which will be discussed in more detail in the next lecture.

Let’s look at the first option we’ll discuss today: Copy semantics.

Copy Semantics and the Copy trait #

Types that adhere to Copy semantics will be automatically copied on assignment or parameter passing. This isn’t hard to understand, so how exactly is it implemented in Rust?

If we look more closely at the compiler’s error message from earlier, you’ll notice that it complains about the Vec<u32> type of data not implementing the Copy trait. Therefore it cannot be copied during assignment or function calls, and it defaults to using Move semantics. After the Move, the original variable data becomes inaccessible, hence the error.

Compiler explanation on error E0382

In other words, when you are moving a value, if the type of the value implements the Copy trait, it will use Copy semantics to perform the copy; otherwise, it will employ Move semantics to move it.

Speaking of which, I must say that while learning Rust, you can modify the code based on the error messages provided by the compiler to pass the compilation and use Stack Overflow to search error messages to learn more about concepts you’re not familiar with. I also highly recommend you explore detailed information about specific error codes like rustc --explain E0382.

Okay, back to the main text. What data structures in Rust implement the Copy trait? You can quickly verify whether a data structure implements the Copy trait with the following code (Verification Code):

fn is_copy<T: Copy>() {}

fn types_impl_copy_trait() {
    is_copy::<bool>();
    is_copy::<char>();

    // all iXX and uXX, usize/isize, fXX implement Copy trait
    is_copy::<i8>();
    is_copy::<u64>();
    is_copy::<i64>();
    is_copy::<usize>();

    // function (actually a pointer) is Copy
    is_copy::<fn()>();

    // raw pointer is Copy
    is_copy::<*const String>();
    is_copy::<*mut String>();

    // immutable reference is Copy
    is_copy::<&[Vec<u8>]>();
    is_copy::<&String>();

    // array/tuple with values which is Copy is Copy
    is_copy::<[u8; 4]>();
    is_copy::<(&str, &str)>();
}

fn types_not_impl_copy_trait() {
    // unsized or dynamic sized type is not Copy
    is_copy::<str>();
    is_copy::<[u8]>();
    is_copy::<Vec<u8>>();
    is_copy::<String>();

    // mutable reference is not Copy
    is_copy::<&mut String>();

    // array/tuple with values that not Copy is not Copy
    is_copy::<[Vec<u8>; 4]>();
    is_copy::<(String, u32)>();
}

fn main() {
    types_impl_copy_trait();
    types_not_impl_copy_trait();
}

I recommend running this code yourself and reading the compiler errors carefully to help reinforce the concept. Here’s a summary:

  • Primitive types, including functions, immutable references, and raw pointers, implement Copy;
  • Arrays and tuples, if their internal structures implement Copy, they also implement Copy;
  • Mutable references do not implement Copy;
  • Non-fixed-sized structures do not implement Copy.

Additionally, the official documentation page on the Copy trait lists all data structures in Rust’s standard library that implement the Copy trait. You can also check the Trait implementation section of the documentation for any data structure to see if it implements the Copy trait.

Rust documentation on the Copy trait

Summary #

Today we learned about Rust’s single ownership model, Move semantics, and Copy semantics. Here’s a recap of key information for you to review one more time.

  • Ownership: A value can only be owned by one variable, and there can only be one owner at a time. When the owner leaves the scope, the value is dropped, and memory is released.
  • Move semantics: Assigning or passing parameters causes the value to move, and ownership is transferred. Once ownership is transferred, the previous variable becomes inaccessible.
  • Copy semantics: If a value implements the Copy trait, then assignment or parameter passing will employ Copy semantics. The relevant value will be copied (shallow copy), creating a new value.

Visual summary of ownership, Move, and Copy semantics in Rust

Through its unique ownership model, Rust solves the issue of heap memory being too flexible and difficult to release safely and efficiently. However, the ownership model also introduces a lot of new concepts, such as today’s discussion on Move/Copy semantics.

As they are completely new concepts, they can be challenging to learn, but if you grasp the core point: Rust restricts arbitrary referencing behavior through single ownership, you will understand the intentions behind these new concepts.

In the next lecture, we will continue to learn about Rust’s ownership and lifetimes, discussing how to “borrow” data when you do not want the ownership of the value to be transferred and Copy semantics cannot be used…

Thought Exercises #

There are two exercises for today’s thought: the first is to consolidate what you’ve learned. Additionally, if you recall, I raised a small question in the text and asked you to set it aside momentarily. Now that you have finished today’s lesson, do you have an answer? Feel free to share your thoughts, and we’ll discuss this together.

  1. In Rust, can a data structure allocated on the heap reference data on the stack? Why or why not?
  2. Another parameter v passed to the find_pos() function by the main() function would have been moved as well, right? Why then hasn’t it been grayed out in the diagram?

I look forward to seeing your thoughts shared in the comments section. Today is your seventh Rust learning checkpoint; thank you for listening. If you find the content useful, feel free to share it with those around you to discuss it together.

References #

A trait is Rust’s interface for defining the behavior of data structures. If a data structure implements the Copy trait, it will execute Copy semantics during assignment, function call, and return, which means the value will be copied bitwise (shallow copy), instead of moved. You can read more about the Copy trait.