Rust Ownership 3: The Borrow Checker, Scope, and Ownership

This is the third article in the Rust Ownership Series. In part 1 and part 2, we learned the following basic concepts: Value Types, Reference Types, Memory Management, Stack and Heap Memory. These concepts are the building blocks that will make it easier to understand Ownership in Rust.

In part 3 of the series, we will be learning about the Borrow-Checker, Scope, and Ownership - alongside the rules of Ownership. Understanding these concepts will make your journey into Rust programming a lot easier. This article has a lot of visualizations to make the concepts click faster, enjoy the ride :)

The Borrow-Checker

The borrow-checker is a fundamental part of the compiler in Rust; it checks that all access to data is legal, and it is what allows Rust to prevent memory safety issues.

Whenever you write code that violates the rules of ownership in Rust (which we will learn shortly), the borrow-checker is like the police officer who checks for these violations and reports them to you. Your code won't compile until you resolve the issues identified.

Rust Ownership

Ownership in Rust aims to manage Heap data. It relates to cleaning values when they are no longer needed. Unlike Rust, most other programming languages have tools like a Garbage Collector (GC) that cleans memory.

Instead of using a garbage collector (GC), Rust uses an ownership model and borrow checker, which allows for predictable performance and deterministic memory management without the additional complexity at runtime. This makes it perfect for systems programming and guarantees memory safety at compile time.

How does Rust know when it's safe to clean up a variable in memory? The short answer is when the variable goes out of scope. What is the scope of a variable in Rust?

Variable Scope

When we declare a variable like the following in Rust:

let name = "Olusola";

The scope of the variable name is the range within the program for which it is valid, and one can access it. Rust uses curly braces {} to define the block scope of a variable.

To illustrate this, check out the following example:

fn main() {
    // `name` has not yet come into scope here, so it is invalid
    {
        // CODE BLOCK STARTS 
        let name = "Olusola"; // `name` comes into scope here and it is valid 

        // scope is still valid
        println!("{name}");
    } // CODE BLOCK ENDS
      // this scope ends, `name` is no longer valid and cannot be used
     // println!("{name}"); // this fails with an error: cannot find value `name` in this scope
}

The variable name is only valid within the code block where it is declared, the curly braces {..} . It cannot be used outside those curly braces.

There are two important points in time for any variable:

  1. When the variable comes into scope, it is valid

  2. It remains valid until it goes out of scope

From the scope of a variable, we can tell who owns its value. In this case, the variable name owns the value that it is binding. Whenever a variable goes out of scope, its memory is freed.

Ownership Rules in Rust

Rust has some rules about Ownership, Rust uses these rules to ensure that your program is valid and safe. The rules are:

  1. Each value in Rust has an owner.

  2. There can be only one owner at any particular time.

  3. When the owner goes out of scope, the value will be dropped. Rust does this by calling a special function drop() automatically that frees memory.

For programmers coming from other programming languages, these rules look strange. We will be expanding on these rules (with lots and lots of visualizations ) for the rest of this article.

Thinking about Rust's Ownership Rules

Before we go into more technical explanation of Ownership in Rust, let us first try to understand the rules of Ownership by likening it to a lending library system.

Imagine a lending library where each book represents a value in Rust. Here are the parallels to Rust's ownership rules:

  1. Each Book Has an Owner: Just like each book in the library has an owner (say, the person who it is lent out to), each value in Rust has an owner (the variable that holds the value).

  2. Single Owner at a Time: Similar to how a book can only be lent out to one person at a time, in Rust, there can only be one owner for a value at any given moment. Once someone is given a book, it's not available for others to borrow until it's returned.

  3. Dropping When Out of Scope: When the person the book is lent out to leaves the library (scope), they return the book (drop ownership). Likewise, when the owner of a value in Rust goes out of scope, the value is 'dropped' or deallocated automatically.

In this analogy, the lending out and returning of books mirror the ownership and lifetime of values in Rust, emphasizing the unique ownership system where each value is tied to a single owner at any given time and is 'dropped' when the owner is no longer in scope.

The analogy is not perfect, but it should help make the rules easier to remember and understand.

Variables and Data Interacting with Move

It is common for multiple variables to interact with the same data in a program. There are different ways that this can happen in Rust; let's look at an example with integers:

fn main() {
    let x = 10;
    let y = x;
    println!("x: {}, y: {}", x, y)
}

[ You can play with this code here ]

Here is what is happening in this example:

  • We bind the value 10 to x

  • We then make a copy of the value in x and bind it to y

We end up with two variables x and y both equal 10. Recall that integers are simple value types with a known, fixed size, and these two 10 values are pushed onto the stack.

If you build and run this program, the output will be:

x: 10, y: 10

Let's look at another example with String

fn main() {
    let s1 = String::from("lisabi");
    let s2 = s1;
    println!("s1: {}, s2: {}", s1, s2);
}

[ You can play with this code here ]

One might assume the same thing happens here, as this looks very similar to the example with integers. If we try to build and run the program, we get the following error:

The code does not compile; why is this so?

Brief Anatomy of String in Rust

Before we can understand what is wrong with the program above, we need to know what a String in Rust is actually is. Recall that String is dynamically sized and is a reference type. A String in Rust is made up of three parts:

  • A pointer to the memory that holds the contents of the string

  • A length

  • A capacity

This group of data is stored on the Stack. The actual string content is stored on the Heap and the pointer points to that memory location on the Heap:

Recall that data stored on the stack is copied by value, so when we do:

let s1 = String::from("lisabi");
let s2 = s1;

Both s2 and s1 each holds a copy of the group of data on the stack (a pointer, length, and capacity). The pointers both point to the same memory location on the Heap.

Below is a simple visualization:

Why does rust do this? Why can't both s2 and s1 each have their own heap data? If the data stored on the heap was very large (remember that String can grow dynamically), then the operation s2 = 21 can be very expensive if a deep copy is made. Copying the value of the pointer, length, and capacity is cheaper; this is known as a shallow copy. Rust performs shallow copy by default because it is inexpensive in terms of runtime performance.

Now let us answer the question of why the code did not compile. Recall that when a variable goes out of scope, Rust cleans it from memory, i.e., the memory the variable occupies is freed.

If s2 and s1 have pointers to the same memory location and they both go out of scope, they will both attempt to free the same memory. Say memory occupied by s1 was freed first, that means the pointer s2 holds now points to invalid data. Any attempt to free s2 will lead to what is known as a double-free error, and it is one of the most common memory safety bugs. Memory corruption may result from freeing memory twice, which may present security risks.

Rust ensures that a double-free error never happens in our program. How does Rust do that?

After the line s2 = s1, Rust considers s1 as no longer valid. That means that Rust doesn't have to free any memory when s1 goes out of scope. It's as if the value that s1 previously held was moved into s2. In Rust terms, we say that ownership (of the value) was transferred to s2:

Functions and Ownership

Similar to variable assignment, passing a variable to a function will move or copy depending on the type of variable (and how it is passed).


fn takes_ownership(input_string: String) { // `input_string` comes into scope
    println!("{}", input_string);
} // Here, `input_string` goes out of scope and `drop` is called. 
  // The backing memory for `input_string` is freed.

fn makes_copy(input_integer: i32) { // `input_integer` comes into scope
    println!("{}", input_integer);
} // Here, `input_integer` goes out of scope. Nothing special happens.


fn main() {
    let greeting = String::from("bawo ni"); // `greeting` comes into scope
    takes_ownership(greeting);      // ownership of `greeting`'s value moves into the function...
                                    // ... therefore `greeting` is no longer valid here
    let age = 15;                   // `age` comes into scope
    makes_copy(age);                // `age` would move into the function,
                                    // but `i32` is `Copy`, so it's okay to still
                                    // use `age` afterward
} // Here, `age` goes out of scope, then `greeting`. Because `greeting`'s value was moved, nothing
  // special happens.

In the given code sample, the function takes_ownership expects a parameter of String type. When greeting was passed into takes_ownership function, ownership of the value that greeting binds to was moved. If we tried to use greeting after call to takes_ownership, it would lead to a compile-time error.

On the other hand, makes_copy expects an integer i32 which is a primitive type, and is passed to makes_copy by value so the variable age can still be accessed after the call to makes_copy(age).

Return Values, Scope and Ownership

Another means by which ownership can be transferred is by returning values.

fn gives_ownership() -> String { // gives_ownership will move its
                                 // return value into the function
                                 // that calls it
    let greeting = String::from("Oga mi"); // greeting comes into scope
    return greeting; // greeting is returned and
                     // moves out to the calling function
}

// This function takes a String and returns one
fn takes_and_gives_back(some_string: String) -> String { // some_string comes into scope     
    return some_string;  // some_string is returned and moves out to the calling function
}

fn main() {
    let s1 = gives_ownership(); // gives_ownership moves its return
                                // value into s1
    let s2 = String::from("Agba firiyoyo"); // s2 comes into scope
    let s3 = takes_and_gives_back(s2);  // s2 is moved into
                                        // takes_and_gives_back, which also
                                        // moves its return value into s3
} // Here, s3 goes out of scope and is dropped. s2 was moved, so nothing
  // happens. s1 goes out of scope and is dropped.

In the given code sample, the function gives_ownership creates a greeting String and returns the string, by doing so, it transfers the ownership of the value greeting holds into the function that calls it. In this case, the value is moved into s1 inside of the main function.

The function takes_and_gives_back takes in a String and returns a String too. In this case, it returns the same string it takes in. That means, it takes ownership of the value the string holds and returns ownership to the function that calls it (main) by moving the value to the variable s3 inside of main

The Pattern of Ownership Movement

The movement of ownership follows the same pattern every time; assigning a value to another variable moves it. When we pass a variable to a function, we are assigning the value of the variable to the local variable in the function. When a function returns a variable, the value of the variable is assigned to the new variable that collects the returned value. In either case, the variable is moved.

Another important thing to note is that whenever a variable holds data that is stored on the heap when the variable goes out of scope, the value will be cleaned automatically by Rust unless ownership of the data has been moved to another variable.

Isn't this Stressful???

If we could never use a variable after passing it to a function, that would make programming more difficult, boring, and stressful. Yet we understand that Rust makes these decisions to ensure our programs are safe.

Fortunately, Rust provides a feature that allows us to work with values without transferring ownership, and it's called references. When we do this, we say we "borrow" the value rather than take ownership. Understanding references and borrowing will be the focus of the final part of this series.

Conclusion

This article is long, and I hope you enjoyed it. In here, we learned about what it means for a variable to own a value in Rust. We also learned about what the borrow-checker is, what scope is, the rules of ownership, and how ownership is transferred in Rust.

In the last part of this series, we will learn about how references and borrowing can help us work with values without giving up ownership in Rust. See you then!

Happy coding :)

References: