Rust’s memory management revolves around three concepts: ownership, borrowing, and lifetimes. Below is my personal and somewhat superficial understanding of them.

1. Understanding Ownership, Borrowing, and Lifetimes from a Memory-Safety Perspective

To understand these three concepts, the first thing to ask is what motivates them — memory safety, which Rust emphasizes strongly. One way to look at it: ownership, borrowing, and lifetimes are designed largely for the sake of memory safety.

Ownership: thinking from a memory-safety angle, if an instance had multiple owners it would very likely be unsafe, since multiple owners could each operate on the instance and cause a race. The solution is to give it only one owner, so that a race (data race) becomes impossible under any circumstance. A new question then arises: what if someone else wants to access the instance? Borrowing.

Borrowing is somewhat like a reference: rather than taking ownership of the instance, you simply borrow it and give it back when done — you use it without owning it. There are two kinds of borrows: mutable borrows and immutable borrows. The rules are explained below.

Lifetimes: for now, you can understand lifetimes as follows: their purpose is to prevent dangling pointers, or to guarantee the validity of references. If you perform a borrow, but the borrowed instance goes out of scope or has already been freed (i.e., your lifetime is longer than the lifetime of the borrowed instance), then what you point to is a meaningless address, which may cause serious errors that are also hard to detect. You must have a very clear picture of the lifetime of the borrowed instance to avoid such errors. In C++ there is no explicit notion of lifetimes; the programmer must be extremely careful and have a thorough understanding of the program when dealing with such situations, or a dangling pointer may appear. In Rust, lifetimes make the lifetime of each object explicit, ensuring that your lifetime and the lifetime of the borrowed instance overlap — your lifetime will not exceed that of the borrowed instance. Although lifetimes are also fairly tricky to deal with in Rust, the Rust compiler will explicitly point out possible lifetime errors and force you to resolve them, thereby avoiding potential bugs.

In short, when you write a Rust program, the compiler checks your code against the rules of ownership, borrowing, and lifetimes. If it does not conform to Rust’s rules, it will not compile (even if you think the code has no bug right now — the problem is that the compiler thinks it might). This reduces the chance of future memory problems.

2. Ownership

There are mainly two ways programs manage computer memory: garbage collection (GC), and having the programmer allocate and free memory manually. Setting aside their respective pros and cons, Rust adopts a third approach: it manages memory through an ownership system, and the compiler checks the code against a set of rules at compile time. At runtime, none of the ownership system’s machinery slows the program down.

Ownership rules:

  1. Every value in Rust has a variable that is called its owner.
  2. There can only be one owner at a time.
  3. When the owner (the variable) goes out of scope, the value will be dropped.
{
    let s = String::from("hello"); // s is valid from this point forward

    // use s
}                                  // this scope is now over,
                                   // s is no longer valid

In Rust, memory is automatically freed once the variable that owns it goes out of scope. The compiler can recognize a variable’s lifetime and knows at compile time when to free the memory it occupies.

3. Borrowing & References

Borrowing and references are relatively easy to understand. To ensure memory safety, Rust establishes the following reference rules and forces the checks below to happen at compile time:

Reference rules

  1. At any given time, you can have either one mutable reference or any number of immutable references.
  2. References must always be valid.

The core intent of these two rules is to avoid data races and dangling pointers.

Below, a comparison between C++ and Rust code shows how the Rust compiler performs strict safety checks and raises explicit compile errors for code that could be problematic, even when the code might not actually produce a data race or a dangling pointer.

C++ code compiles and produces no errors:

#include <iostream>
using namespace std;

int main(){
  int a = 10;
  int &b = a;
  a = 100;
  b = 200;

  cout<<a<<endl;
  cout<<b<<endl;

  return 0;
}

//compiles fine

Rust code fails to compile. Although the code looks like it would not produce an error, the compiler performs strict checking and rejects it:

fn main() {
    let mut a = 10;
    let ref b = a;
    let ref mut c = a;
    //compile error: cannot borrow `a` as immutable because it is also borrowed as mutable
}

4. Lifetimes

Lifetimes are a kind of generic that allows us to tell the compiler how references relate to each other. Rust’s lifetime feature lets you borrow values in many scenarios while still allowing the compiler to check that those references are valid.

[1] The Difference Between C++ and Rust

The main goal of lifetimes is to avoid dangling references, which cause a program to refer to data it did not intend to reference. The Rust compiler forces lifetime checks; if the rules are violated it reports a compile error, forcing you to write code that conforms to the lifetime rules. The comparison below between Rust and C++ code illustrates the motivation for and the benefits of Rust’s lifetimes.

Rust code, if it violates the lifetime rules, fails to compile:

fn main() {
    let r;
    {
        let x = 5;
        r = &x;
    }

    println!("r: {}", r);
}

This produces the following compile error:

error[E0597]: `x` does not live long enough

  --> src/main.rs:76:18
   |
76 |             r = &x;
   |                  ^ borrowed value does not live long enough
77 |         }
   |         - `x` dropped here while still borrowed
...
80 |     }
   |     - borrowed value needs to live until here

C++ code, even when it produces a dangling pointer, still compiles and runs normally. Such a dangling pointer may lead to very subtle bugs and make them hard to track down.

#include <iostream>
using namespace std;

int main(){
  int *r = new int(5);
  int *x = r;
  delete r;

  cout<< *r <<endl;
  cout<< *x <<endl;

  return 0;
}

Output:

0
2608    //this situation may produce a rather subtle bug

It compiles, but may or may not cause a bug; and once a bug does arise from this, it can be hard to find.

From the code above, we can see the benefit of lifetimes for memory safety. To some extent, lifetimes are a product of Rust’s emphasis on memory safety. Next, code is used again to explain why lifetimes are needed.

[2] Why Do We Need Lifetimes?

The simple understanding is this: when the compiler cannot determine whether a reference is valid — cannot tell whether the referenced memory is safe — the programmer must use explicit lifetime annotations in the Rust code to tell the compiler the lifetime of each reference, giving it enough information to judge whether that reference is valid, whether it conforms to the rules, and that no unsafe operation such as a dangling reference occurs.

The following code produces a dangling reference:

fn main() {
    let a = 10;
    let m;
    {
        let b = 100;

        m = max_num(&a, &b);
        assert_eq!(100, *m);
    }
    println!("max num is {}", m);
}

fn max_num(a: &i32, b: &i32) -> &i32 {
    if *a > *b {
        return *a;
    }
    *b
}

In Rust, the code above produces a compile error:

error[E0106]: missing lifetime specifier
  --> src/main.rs:23:33
   |
23 | fn max_num(a: &i32, b: &i32) -> &i32 {
   |                                 ^ expected lifetime parameter
   |
   = help: this function's return type contains a borrowed value, but the signature does
 not say whether it is borrowed from `a` or `b`

The compiler hints that a lifetime annotation is needed, because right now it cannot tell whether the function returns a reference to a or to b. So the compiler cannot use scope information to determine whether the returned reference is always valid. If it returned a reference to a, then no dangling reference would occur above; if it returned a reference to b, then a dangling reference would occur, creating a safety hazard.

What to do? Add a lifetime annotation to give the compiler more information so it can judge whether the reference is valid.

// add a lifetime annotation
fn max_num<'a>(a: &'a i32, b: &'a i32) -> &'a i32 {
    if *a > *b {
        return a;
    }
    b
}

With the lifetime annotation added, the compiler can now judge the reference’s validity:

error[E0597]: `b` does not live long enough
  --> src/main.rs:16:26
   |
16 |         m = max_num(&a, &b);
   |                          ^ borrowed value does not live long enough
17 |         assert_eq!(100, *m);
18 |     }

   |     - `b` dropped here while still borrowed
19 |     println!("max num is {}", m);
20 | }
   | - borrowed value needs to live until here

The compiler tells us that b does not satisfy the lifetime annotation, because our annotation states that the returned reference stays valid until the shorter of a’s and b’s lifetimes ends. More precisely, here m’s lifetime is the intersection of the lifetimes of a and b.

Following the compiler’s hint, we change the code as follows and it compiles successfully:

fn main() {
    // let inner = Inner{data:"inner data."};
    let a = 1000;
    let b = 100;    //extend b's lifetime to match a's and m's
    let m;
    {
        m = max_num(&a, &b);
        assert_eq!(100, *m);
    }
    println!("max num is {}", m);
}
[3] Lifetime Annotation Syntax

A lifetime annotation does not change how long any reference actually lives. The annotation syntax is shown in the examples below:

&i32        // a reference
&'a i32     // a reference with an explicit lifetime
&'a mut i32 // a mutable reference with an explicit lifetime