Contents

Lecture 3: Borrowing and Lifetime

 Back to syllabus

Notice
This lecture note is still in draft mode. I will add notes about slice/&str, aliasing rules, and static variable. Check back after the lecture to see the updated notes.
Changelog
01/07: Lifetime parameter section has been rewritten and extended. Check it out!

Background

In the last lecture, we have learned about ownership, along with move and copy semantics. However, having only ownership is not useful. We don’t want to pass a value’s ownership back and forth whenever it is used. Furthermore, we may sometimes want some value to not move. We still need “sharing”. Rust provides a solution that ensures memory safety and also provides additional benefits.

Two C/C++ examples

Example 1

Recall the following example from Lecture 1. On line 5, we create a reference to the second value in the vector, we expect this value to not change if we have never directly modified the second value in the vector. However, after pushing back a new value to the vector, we find x to have changed. This is caused by “iterator invalidation”: when we push a value to the vector, the vector could get reallocated, making the old pointer x a dangling pointer. By running this code, we can see the pointer output in the last line shows different addresses.

1
2
3
4
5
6
7
8
9
#include <vector>
#include <iostream>
int main() {
    std::vector<int> v{5, 10,};
    int &x = v[1];
    v.push_back(20);
    std::cout << x << '\n';   // segfault, or random number
    std::cout << &v[1] << ' ' << &x << '\n';
}

Example 2

The following example tries to create two version of names, one all lower-case and one all upper-case. However, since the program accidentally forget to duplicate the string, the two conversions end up happening on the same string. Thus, the lower-case conversion result is overwritten by the upper-case conversion.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include <stdio.h>
#include <stdlib.h>
#include <ctype.h>
#include <string.h>
int main() {
    char *buffer = malloc(10);
    strcpy(buffer, "Robert");

    char *lower_case_name = buffer;
    char *upper_case_name = buffer;   // here: forget to duplicate the string

    // Convert to all lower-case version of name
    for (size_t i = 0, len = strlen(lower_case_name); i < len; ++i)
        lower_case_name[i] = tolower(lower_case_name[i]);

    // Convert to all upper-case version of name
    for (size_t i = 0, len = strlen(lower_case_name); i < len; ++i)
        upper_case_name[i] = toupper(upper_case_name[i]);

    printf("lower-case name %s\nupper-case name %s\n", lower_case_name, upper_case_name);

    free(lower_case_name);
    free(upper_case_name);  // double free here
    return 0;
}

The output is

lower-case name ROBERT
upper-case name ROBERT

followed by a crash due to double free.

Rust borrowing

The reference type

To allow safe sharing, Rust creates a notion of “borrowing”. Although only one variable can own a value, the owner is allowed to “lend” the value out by references. During the period of borrowing, the ownership is still in the original owner’s hand.

The reference type in Rust is somewhat a mix between the C pointer and C++ reference:

  • Like C and C++, reference is internally an address to a value.
  • Like C pointer and unlike C++, the reference itself is a value, and can be changed.
  • Like C++ and unlike C, the reference cannot do pointer arithmetics.
  • Unlike both C and C++, Rust’s references must always be valid.

Reference variables also have scopes. When a variable goes out of scope, the borrowing is ended.

When a value is borrowed, it cannot be moved out of its owner.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
let mut hello = String::from("hello");
let mut world = String::from("world");
{
    let mut borrowed = &mut hello;
    borrowed.push_str(", world");
    println!("{}", borrowed);   // we can output the value via the reference
    // println!("{}", hello);   // error: but we cannot use the orignal variable
                                // (except immutable borrows)
    borrowed = &mut world;      // we can change the reference variable itself
    borrowed.push_str(" is beautiful");
    // let move_borrowed = *borrowed; // error: cannot move out of borrowed value
    println!("{}", borrowed);
    // println!("{}", world);   // error: same
    println!("{}", hello);      // but `hello` is now given back to us
}
println!("{}", world);  // we can now use the orignal variable again

Basic usage

Let’s start with the example that passes values around.

1
2
3
4
5
6
fn append_world(s: String) -> String {
    s + ", world"
}
let hello = String::from("hello");
let hello_world = append_world(hello);
println!("{}", hello_world);

This works fine, and correctly outputs “hello, world”. However, it not efficient and not easy to write: every time we want to modify some value, we have to pass it into the function and get the return value back. If the value moved is large, we may end up having unnecessary memory copying.

Now, let’s utilize borrowing. We mutably borrow the value so that we can change the value in it in-place.

1
2
3
4
5
6
fn append_world(s: &mut String) {
    *s += ", world";
}
let mut hello = String::from("hello");
append_world(&mut hello);
println!("{}", hello);

This now also works.

Now, what if I want to keep both “hello” and “hello, world”? One solution is by calling .clone() before passing to the move version of append_world. Another solution is by passing only a view of the value to append_world.

Here we create and pass an “immutable borrow” to the new append_world.

1
2
3
4
5
6
fn append_world(s: &String) -> String {
    s.clone() + ", world"
}
let hello = String::from("hello");
let hello_world = append_world(&hello);
println!("{} {}", hello, hello_world);

Now append_world can internally duplicate the string, and return a newly calculated string. When we try to print both hello and hello_world, it would also work (it does not work for previous move example).

Borrowing rules

Let’s consider the following example. The code can compile perfectly if we remove line 5 and line 6.

1
2
3
4
5
6
7
let hello = String::from("hello");
let h1 = &hello;
let h2 = &hello;
let h3 = &hello;
let h4 = &mut hello;
h4.push_str(", world");
println!("{} {} {}", h1, h2, h3);

So, the effective code that compiles turn out to be:

1
2
3
4
5
let hello = String::from("hello");
let h1 = &hello;
let h2 = &hello;
let h3 = &hello;
println!("{} {} {}", h1, h2, h3);

This makes sense, since our immutably borrowed references h1 through h3 are not expecting to see changes under the reference. They should only be immutable views to the original value.

What if we still want to modify the “hello” string? The solution to it is by modifying it later:

1
2
3
4
5
6
7
8
9
let mut hello = String::from("hello");
let h1 = &hello;
let h2 = &hello;
let h3 = &hello;
println!("{} {} {}", h1, h2, h3);
let h4 = &mut hello;
h4.push_str(", world");
println!("{}", h4);
println!("{}", hello);

If we move mutable borrow h4 after the immutable borrows, the Rust borrow checker is smart enough to know that h1 through h3 is no longer used, and ends borrowing. You may think of it as the Rust compiler implicitly shrinking the scope of the no-longer-used borrows.

Comparing this example and the original example that cannot compile, we can see that as long as the scope of the immutable and mutable borrows do not overlap, the Rust compiler will accept code.

Now let’s consider another example. This code does not compile because Rust does not allow mutably borrowing twice at the same time. This will help avoid data race in the lower- and upper-case example.

1
2
3
4
5
6
let mut hello = String::from("hello");
let h1 = &mut hello;
let h2 = &mut hello;
h1.push_str(", world");
h2.push_str(" is beautiful");
println!("{}", hello);

We can summarize rules from the above examples:

Borrowing rules
  1. There can have multiple immutable borrows to a value.
  2. There can have only one mutable borrow to a value.
  3. Immutable and mutable borrows to the same value cannot exist at the same time.

This helps eliminate a whole group of bugs already.

Back to the C/C++ examples

Now let’s consider the first two C/C++ examples, and see how Rust helps check and prevent the bugs.

Rewriting the first example:

1
2
3
4
5
6
let mut v = vec![5, 10];
let x: &mut i32 = &mut v[1];
// change the above line to the following works the same
// let x: &i32 = &v[1];
v.push(20);
println!("{x}");

With either line 2 or line 4, the Rust compiler will reject the code. This is because on line 5, the push methods will try to borrow v as mutable. That conflicts with any of the scenarios.

Rewriting the second example:

1
2
3
4
5
6
let buffer = String::from("Robert");
let mut lower_case_name = &mut buffer;
let mut upper_case_name = &mut buffer;
lower_case_name.make_ascii_lowercase();
upper_case_name.make_ascii_uppercase();
println!("{} {}", lower_case_name, upper_case_name);

This again will not work since we cannot have two mutable references to the same value.

Rust lifetime

Simply put, Rust’s lifetime is designed to prevent invalid references. Lifetimes are named regions of code that a reference must be valid for.

Avoiding invalid outliving

Simple scope example

Let’s consider the following example. This code does not compile. The Rust compiler complains that “x does not live long enough”: when it is dropped upon leaving the scope in line 5, there still is a reference pointing to it. This is dangerous because it will easily cause use-after-free on the heap-allocated string in line 6. If this was C/C++, the compiler may turn a blind eye and allow the program to compile, but Rust compiler checks this with lifetime.

1
2
3
4
5
6
let r;
{
    let x = String::from("hello");
    r = &x;
}
println!("{}", r);

The internal mechanism in Rust compiler is by implicitly naming the scope of the variables. For example, we may annotate the above code as: (below is only pseudo-code and does not compile)

1
2
3
4
5
6
let r: &'outer i32;
{
    let x: 'inner i32 = 5;
    r: &'outer i32 = &'inner x;
}
println!("{}", r);

Here, we use 'abc to represent a lifetime. We know that 'inner is strictly shorter than 'outer (“strictly” means cannot be equal), thus Rust compiler does not allow assigning a 'inner value to a 'outer variable.

This works even if you write an indirect layer. The Rust compiler tracks the lifetime relationship between parameters and return values. The following code does not compile.

1
2
3
4
5
fn proxy(x: &i32) -> &i32 { x }
let t = {
    let x = 1;
    proxy(&x)
};

A little explanation for the syntax here: the {} between let t = and ; is a block, which is also an expression. As mentioned in Lecture 1, {} can have as many statements as you want in it, and can also return a value at last without ;.

In this case, x is in the inner scope, and the proxy line returns &x to outside of {}, which is then assigned to t. Same as the previous example, x does not live long enough.

The solution to this is by letting x to have a longer lifetime. You can always extend the lifetime of a variable by moving it out of the current scope, as long as it can be moved.

1
2
3
4
5
6
7
let x_extended;
{
    let x = String::from("hello");
    x_extended = x;   // moved out of current scope
}
// value String("hello") now has lifetime of outer scope
println!("{x_extended}");

One thing worth mentioning is that if we take the original example, but without the println! at last, this code actually compiles. This is because if we do not use r for once afterwards, Rust’s compiler will shrink the scope of r, just like the examples in the previous Borrowing rules section. Rust compiler will find r and x both accessible on line 5, and thus accept the code. On the other hand, if we use r once, Rust compiler will extend r’s scope to line 6, but x cannot go beyond line 5, and thus the compiler gives an error.

1
2
3
4
5
6
let r;
{
    let x = String::from("hello");
    r = &x;
}
// println!("{}", r);

The dangling pointer example

With lifetime, Rust can easily eliminate dangling pointers. Similarly, the following code does not compile.

1
2
3
4
fn dangling() -> &i32 {
    let x = 5;
    &x
}

This example pushes a value 5 onto the stack, and then return a reference to the value 5. This may be accepted in C/C++, but it is extremely dangerous. After this function returns, the caller may call other functions, and stack could grow back to the same address of this variable and overwrite the value on it.

1
2
3
| dangling(): x |                                  | f(): [i32; 100] |
|     main()    |   ->   |   main(): &x   |   ->   |    main(): &x   |
                                                                ^ invalidated

Lifetime parameter

Zero reference arguments

If you try to compile the dangling example, you may find that the compiler error is not because of returning something on stack, but because of missing “lifetime parameter”.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
error[E0106]: missing lifetime specifier
 --> src/main.rs:3:22
  |
3 |     fn dangling() -> &i32 {
  |                      ^ expected named lifetime parameter
  |
  = help: this function's return type contains a borrowed value, but there is no value for it to be borrowed from
help: consider using the `'static` lifetime
  |
3 |     fn dangling() -> &'static i32 {
  |                       +++++++

Without knowing why, let’s first rewrite the function signature in the correct way:

1
2
3
4
fn dangling<'a>() -> &'a i32 {
    let x = 5;
    &x
}

Now this one will show error about returning value on stack.

1
2
3
4
5
error[E0515]: cannot return reference to local variable `x`
 --> src/main.rs:5:9
  |
5 |         &x
  |         ^^ returns a reference to data owned by the current function

One reference arguments

Let’s turn back to the proxy example. Why this does not require writing “lifetime parameter"s? What if we try to force adding “lifetime parameter”?

1
2
fn proxy(x: &i31) -> &i32 { x }
fn proxy<'a>(x: &'a i32) -> &'a i32 { x }

Lifetime elision: The above two lines both work the same. This is because of lifetime elision. When the Rust compiler only see one parameter with non-static reference and only one return field with non-static reference, it will default to consider their lifetime as equal.

In the modified dangling example, 'a becomes an unbounded lifetime.

Syntax: The <'a> is part of the generic system. Generic in Rust is similar to template in C++, but with some different usages. In Rust, we can not only define generic type parameters, but also define lifetime parameters. The parameters start with apostrophe ('), in the form of 'this_is_a_lifetime.

Two and more reference arguments

Now we know the rule of lifetime elision for one parameter and one return reference, what if we have multiple non-static reference parameters? Let’s try the following example:

1
2
3
fn max(x: &i32, y: &i32) -> &i32 {
    if x > y { x } else { y }
}

In this example, “the max value” semantically could be either x or y. So the lifetime of the return value should depend on the smaller one of the two.

The solution is directly given below:

1
2
3
fn max<'a>(x: &'a i32, y: &'a i32) -> &'a i32 {
    if x > y { x } else { y }
}

By annotating all as the same 'a, the Rust compiler will assume the three references' lifetime as equal.

The inherent logic of compiler’s lifetime checking is twofold:

  1. The compiler will check the body of the function to see if the return value is 'a.
  2. At the call site, when the Rust compiler sees same lifetimes, it will try to find a common lifetime region that meets all variables' minimum lifetime and maximum lifetime. If such region does not exist, the compiler will give a compile error for the call site.

In the end, the function signature is the gold standard. Everything (function body and call site) is checked against the signature, not the other way around.

Now let’s consider a tricky situation:

1
2
3
4
5
6
7
let x = 1;
let z;
{
    let y = 10;
    z = max(&x, &y);
}
println!("{}", z);

This example won’t compile (and it shouldn’t). The minimum lifetime requirement of z is until line 7, but y can only live until line 6 at maximum. Thus, a common lifetime region does not exist, and this code will not be accepted.

This makes sense if we think in another way: y could be the return value of max, and thus z could store a dangling pointer to y after y goes out of scope.

Then what if there are situations that we only want to return from one of the arguments? The solution is given below:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// Alternative solution: don't write 'b at all:
// fn always_return_x<'a>(x: &'a i32, y: &i32) -> &'a i32 {
fn always_return_x<'a, 'b>(x: &'a i32, y: &'b i32) -> &'a i32 {
    x
}
let x = 1;
let z;
{
    let y = 10;
    z = always_return_x(&x, &y);
}
println!("{}", z);

By setting parameter 'b that is not relevant to 'a, the Rust compiler knows only the first parameter will be returned, and x and z do have the same scope. In this case, the Rust compiler will accept the program.

However, I want to explore one more thing: what if the function body does not conform with the function signature? What will the compiler say? Here we return y instead:

1
2
3
fn always_return_x<'a, 'b>(x: &'a i32, y: &'b i32) -> &'a i32 {
    y
}

The compiler says

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
error: lifetime may not live long enough
 --> src/main.rs:4:9
  |
3 |     fn always_return_x<'a, 'b>(x: &'a i32, y: &'b i32) -> &'a i32 {
  |                        --  -- lifetime `'b` defined here
  |                        |
  |                        lifetime `'a` defined here
4 |         y
  |         ^ function was supposed to return data with lifetime `'a` but it is returning data with lifetime `'b`
  |
  = help: consider adding the following bound: `'b: 'a`

The Rust compiler is confused by us! Rust compiler knows

  • y has lifetime 'b
  • the return value should have lifetime 'a
  • 'a and 'b has unknown relationship

And we are returning a 'b value as 'a, so the Rust compiler do not know what to do. This is the same as type mismatch! The Rust compiler then have to guess: maybe the programmer wants 'b to be longer or equal to 'a? So Rust compiler hints you “consider adding the following bound: 'b: 'a”.

'b: 'a is the notation of lifetime subtyping. 'b is a subtype of 'a. It means 'b is as at least as long as 'a, i.e. 'b is longer or equal to 'a. If we accept this change and modify the function signature:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
fn always_return_x<'a, 'b: 'a>(x: &'a i32, y: &'b i32) -> &'a i32 {
    y
}
let x = 1;
let z;
{
    let y = 10;
    z = always_return_x(&x, &y);
}
println!("{}", z);

Rust compiler will still try to return a 'b lifetime y as a 'a lifetime return value. But now the Rust compiler knows one more thing: 'b is longer or equal to 'a. Fitting a longer lifetime to a shorter lifetime is no big deal, just like passing a child class pointer as a parent class pointer in C++. Therefore, Rust compiler accepts this code.

However, the call site will now show an error. The error message is the same as the error for max. This judgment by the compiler is correct: y’s lifetime ('b) is required to be longer or equal to x and z’s lifetime ('a). The maximum possible 'b region is within the block, and obviously, z’s lifetime ('a) is strictly longer than y’s ('b), which contradicts with 'b: 'a. Therefore, the code is rejected by the compiler. But this is not really useful for our use case, so our solution is still our previous solution.

Now with lifetime subtyping, we can update the previous lifetime checking mechanism into a more generalized version:

Lifetime checking mechanism

The compiler checks whether both the function body and the call site conforms to the lifetime specified by the function signature:

  1. In the function body: track the data flow of the return value and infer the lifetime of it. Check whether this lifetime is a subtype of the return value lifetime in the function signature.
  2. At the call site:
    1. For all the variables that have the same lifetime parameter, find a common lifetime region that meets all variables' minimum lifetime and maximum lifetime. Check if such region do exist.
    2. Then check whether the relationships of different lifetime parameters conflict with the lifetime subtyping specified by the function signature. If any contradiction is found, give a compile error.

Phew. It’s getting more and more close to mathematical proof. But we will stop right here. This section is very complicated and is the most common challenge that Rust beginners face. It may require quite some time and experience to fully understand. Good luck plying with lifetime in your future code writing! :)

At last, the following quote summarizes clearly how lifetime annotation is supposed to work. This is why we are only annotating lifetime: annotating to tell compiler more information, and in return better assist us checking correctness.

Quote
You don’t declare lifetimes. Lifetimes come from the shape of your code, so to change what the lifetimes are, you must change the shape of the code.Rust User Forum

Lifetime parameter on types

The lifetime parameter works both on functions and on type definitions. In the following Student struct example, Rust compiler will complain about missing lifetime parameter on field name.

1
2
3
4
struct Student {
    name: &str,
    id: i32,
}

We can simply add it like the following. Note that this annotation means “this &str reference must outlive Student”.

1
2
3
4
struct Student<'a> {
    name: &'a str,
    id: i32,
}

Alternatively, you can also set the name’s lifetime to 'static, but this will lose flexibility since you cannot assign this field with dynamically generated strings at runtime.

1
2
3
4
struct Student {
    name: &'static str,
    id: i32,
}

Closing marks

In summary, Rust’s borrowing rules and lifetime design helps avoid data races and ensure always-valid references.

When coding in other languages, try to add const to pointers that does not require mutation. Try to also think about what are the locations of mutable and immutable pointers. Think about: could they exist at the same time, and could two mutable pointers exist at the same time. Also, keep track of the lifetime in mind, and make sure that no references or pointers have been invalidated even implicitly. Make sure all references and pointers are valid at the point of usage.

Extended reading: https://practice.rs/lifetime/advance.html