References and Borrowing in Rust

Rust replaces C pointers with references that the compiler can reason about. You get the power of indirection without the bugs. This chapter shows how Rust's borrowing system works, what you give up compared to C, and what you gain.

Shared References: &T

A shared reference lets you read data without owning it. Multiple shared references can exist at the same time.

// shared_ref.rs
fn print_value(r: &i32) {
    println!("value = {}", r);
    // *r = 99;  // ERROR: cannot assign through a shared reference
}

fn main() {
    let x = 42;
    let r1 = &x;
    let r2 = &x;   // multiple shared references: OK

    print_value(r1);
    print_value(r2);
    println!("x is still {}", x);
}
$ rustc shared_ref.rs && ./shared_ref
value = 42
value = 42
x is still 42

Compare to C:

/* shared_ref.c */
#include <stdio.h>

void print_value(const int *r)
{
    printf("value = %d\n", *r);
    /* *r = 99; */  /* ERROR: r points to const int */
}

int main(void)
{
    int x = 42;
    const int *r1 = &x;
    const int *r2 = &x;

    print_value(r1);
    print_value(r2);
    printf("x is still %d\n", x);
    return 0;
}

The surface similarity is real: &T in Rust behaves like const T * in C. But Rust enforces it at a deeper level -- you cannot cast away the constness.

Mutable References: &mut T

A mutable reference gives exclusive read-write access. Only one &mut T can exist for a given value at a time, and no shared references may coexist with it.

// mut_ref.rs
fn increment(r: &mut i32) {
    *r += 1;
}

fn main() {
    let mut x = 10;
    increment(&mut x);
    increment(&mut x);
    println!("x = {}", x);  // 12
}
$ rustc mut_ref.rs && ./mut_ref
x = 12

The C equivalent:

/* mut_ref.c */
#include <stdio.h>

void increment(int *r)
{
    (*r)++;
}

int main(void)
{
    int x = 10;
    increment(&x);
    increment(&x);
    printf("x = %d\n", x);  /* 12 */
    return 0;
}

In C, any int * is mutable. There is no compiler-enforced exclusivity.

The Borrowing Rules

Rust enforces exactly two rules at compile time:

  1. One mutable reference, OR any number of shared references -- never both.
  2. References must always be valid -- no dangling references.
// borrow_rules.rs -- This will NOT compile
fn main() {
    let mut x = 10;
    let r1 = &x;       // shared borrow
    let r2 = &mut x;   // ERROR: cannot borrow x as mutable
                        // because it is also borrowed as immutable
    println!("{} {}", r1, r2);
}
error[E0502]: cannot borrow `x` as mutable because it is
              also borrowed as immutable

This is the key innovation. The compiler prevents data races and aliased mutation at compile time.

  Borrowing Rules Visualized
  ===========================

  Allowed:                    Allowed:
  +---+   &T    +---+        +---+  &mut T  +---+
  | A |-------->| x |        | A |--------->| x |
  +---+         +---+        +---+          +---+
  +---+   &T      ^               (only one)
  | B |----------/
  +---+

  FORBIDDEN:
  +---+   &T    +---+
  | A |-------->| x |
  +---+         +---+
  +---+ &mut T    ^
  | B |---------/          <-- compile error
  +---+

Rust Note: This is the fundamental difference from C. In C, you can have a const int * and an int * to the same address at the same time. The compiler cannot catch the resulting bugs.

Why This Prevents Data Races

A data race requires three conditions simultaneously:

  1. Two or more threads access the same memory
  2. At least one access is a write
  3. No synchronization

Rust's borrowing rules make condition 2 impossible when condition 1 is true. If multiple references exist, none can write. If a mutable reference exists, no other reference exists.

// no_data_race.rs
use std::thread;

fn main() {
    let mut data = vec![1, 2, 3];

    // This would fail to compile:
    // let r = &data;
    // thread::spawn(move || {
    //     data.push(4);  // cannot move `data` while borrowed
    // });
    // println!("{:?}", r);

    // Instead, you must choose: move or borrow, not both
    thread::spawn(move || {
        data.push(4);
        println!("{:?}", data);
    }).join().unwrap();
}
$ rustc no_data_race.rs && ./no_data_race
[1, 2, 3, 4]

In C, the equivalent code compiles without complaint and races silently.

Non-Lexical Lifetimes (NLL)

Rust's borrow checker is smart. A borrow ends at its last use, not at the end of the scope. This is called Non-Lexical Lifetimes.

// nll.rs
fn main() {
    let mut x = 10;

    let r1 = &x;
    println!("r1 = {}", r1);
    // r1's borrow ends here (last use)

    let r2 = &mut x;   // OK: r1 is no longer active
    *r2 += 5;
    println!("r2 = {}", r2);
}
$ rustc nll.rs && ./nll
r1 = 10
r2 = 15

Without NLL (older Rust), this would not compile. The borrow checker has gotten significantly smarter over time.

References to Structs

Like C's -> operator, Rust auto-dereferences through references when calling methods or accessing fields.

// struct_ref.rs
struct Point {
    x: i32,
    y: i32,
}

fn move_right(p: &mut Point, dx: i32) {
    p.x += dx;  // no -> needed, Rust auto-dereferences
}

fn show(p: &Point) {
    println!("({}, {})", p.x, p.y);
}

fn main() {
    let mut pt = Point { x: 3, y: 7 };
    show(&pt);
    move_right(&mut pt, 10);
    show(&pt);
}
$ rustc struct_ref.rs && ./struct_ref
(3, 7)
(13, 7)

Compare to the C version from Chapter 6: the logic is identical, but Rust distinguishes &Point (read-only) from &mut Point (read-write) at the type level.

Reborrowing

When you pass a &mut T to a function, Rust implicitly creates a shorter-lived mutable borrow. The original reference is "frozen" until the reborrow ends.

// reborrow.rs
fn add_one(val: &mut i32) {
    *val += 1;
}

fn add_two(val: &mut i32) {
    add_one(val);  // reborrow: val is implicitly &mut *val
    add_one(val);  // works again after first reborrow ends
}

fn main() {
    let mut x = 0;
    add_two(&mut x);
    println!("x = {}", x);  // 2
}
$ rustc reborrow.rs && ./reborrow
x = 2

This is why you can call multiple &mut functions in sequence -- each reborrow is temporary.

Dangling References: Impossible in Safe Rust

In C, returning a pointer to a local variable is a dangling pointer bug. In Rust, the compiler rejects it outright.

// dangling.rs -- This will NOT compile
fn bad() -> &i32 {
    let x = 42;
    &x   // ERROR: `x` does not live long enough
}

fn main() {
    let r = bad();
    println!("{}", r);
}
error[E0106]: missing lifetime specifier
error[E0515]: cannot return reference to local variable `x`

Compare to C, where this compiles with only a warning:

/* dangling.c */
#include <stdio.h>

int *bad(void)
{
    int x = 42;
    return &x;  /* warning: returning address of local variable */
}

int main(void)
{
    int *r = bad();
    printf("%d\n", *r);  /* undefined behavior */
    return 0;
}

Caution: The C version "works" on many systems because the stack frame has not been overwritten yet. This makes the bug hard to detect. Rust eliminates the entire class of bug.

Slices: References to Contiguous Data

A slice &[T] is a reference plus a length. It is Rust's answer to C's "pointer plus separate length parameter."

// slice.rs
fn sum(data: &[i32]) -> i32 {
    let mut total = 0;
    for val in data {
        total += val;
    }
    total
}

fn main() {
    let arr = [10, 20, 30, 40, 50];
    println!("sum of all: {}", sum(&arr));
    println!("sum of [1..4]: {}", sum(&arr[1..4]));  // 20+30+40
}
$ rustc slice.rs && ./slice
sum of all: 150
sum of [1..4]: 90
  Slice layout in memory:

  &arr[1..4]
  +----------+--------+
  | pointer  | length |  <-- fat pointer (2 words)
  | &arr[1]  |   3    |
  +----------+--------+
       |
       v
  +----+----+----+----+----+
  | 10 | 20 | 30 | 40 | 50 |   <-- underlying array
  +----+----+----+----+----+
       [1]  [2]  [3]

Try It: Create a function fn largest(data: &[i32]) -> i32 that finds the maximum value in a slice. Test it with different sub-slices of an array.

What You Give Up, What You Gain

Compared to C pointers, Rust references give up:

  • Pointer arithmetic -- no p + 3 on references. Use indexing or iterators.
  • NULL -- references cannot be null. Use Option<&T> instead.
  • Aliased mutation -- you cannot have multiple mutable paths to the same data.
  • Casting -- no implicit type-punning through references.

What you gain:

  • No dangling references -- compile-time guarantee.
  • No data races -- compile-time guarantee.
  • No null dereferences -- no null to dereference.
  • No buffer overflows -- slices carry their length.

When You Need unsafe

Sometimes you genuinely need raw pointer behavior. Rust's unsafe blocks let you opt out of borrow checking for specific operations.

// raw_ptr.rs
fn main() {
    let mut x = 42;

    // Create raw pointers (safe -- creating is fine)
    let r1 = &x as *const i32;
    let r2 = &mut x as *mut i32;

    // Dereference raw pointers (unsafe -- you take responsibility)
    unsafe {
        println!("r1 = {}", *r1);
        *r2 = 99;
        println!("r2 = {}", *r2);
    }
}
$ rustc raw_ptr.rs && ./raw_ptr
r1 = 42
r2 = 99

Driver Prep: The Rust-for-Linux project uses unsafe blocks to interact with kernel C APIs. The idea is to build safe abstractions on top of unsafe foundations. The unsafe surface area is small and auditable.

Pattern: Option Instead of NULL

Rust uses Option<&T> where C uses "pointer or NULL."

// option_ref.rs
fn find(data: &[i32], target: i32) -> Option<&i32> {
    for val in data {
        if *val == target {
            return Some(val);
        }
    }
    None
}

fn main() {
    let arr = [10, 20, 30, 40];

    match find(&arr, 30) {
        Some(val) => println!("found: {}", val),
        None => println!("not found"),
    }

    match find(&arr, 99) {
        Some(val) => println!("found: {}", val),
        None => println!("not found"),
    }
}
$ rustc option_ref.rs && ./option_ref
found: 30
not found

The C equivalent:

/* find.c */
#include <stdio.h>

const int *find(const int *data, int len, int target)
{
    for (int i = 0; i < len; i++) {
        if (data[i] == target)
            return &data[i];
    }
    return NULL;
}

int main(void)
{
    int arr[] = {10, 20, 30, 40};
    const int *result = find(arr, 4, 30);

    if (result)
        printf("found: %d\n", *result);
    else
        printf("not found\n");

    return 0;
}

The Rust version forces you to handle None. The C version lets you forget to check for NULL.

Putting It Together: A Borrowing Exercise

// borrow_exercise.rs
struct Stats {
    count: usize,
    sum: f64,
}

fn compute_stats(data: &[f64]) -> Stats {
    let count = data.len();
    let sum: f64 = data.iter().sum();
    Stats { count, sum }
}

fn print_stats(s: &Stats) {
    println!("count = {}", s.count);
    println!("sum   = {:.2}", s.sum);
    if s.count > 0 {
        println!("mean  = {:.2}", s.sum / s.count as f64);
    }
}

fn normalize(data: &mut [f64], factor: f64) {
    for val in data.iter_mut() {
        *val /= factor;
    }
}

fn main() {
    let mut data = vec![10.0, 20.0, 30.0, 40.0, 50.0];

    let stats = compute_stats(&data);  // shared borrow
    print_stats(&stats);               // shared borrow of stats

    normalize(&mut data, stats.sum);   // mutable borrow of data
    println!("\nnormalized: {:?}", data);
}
$ rustc borrow_exercise.rs && ./borrow_exercise
count = 5
sum   = 150.00
mean  = 30.00

normalized: [0.06666666666666667, 0.13333333333333333, 0.2, ...]

Try It: Modify the program to normalize by the mean instead of the sum. Make sure you compute the stats before the mutable borrow.

Knowledge Check

  1. What happens if you try to hold &x and &mut x at the same time?
  2. How does Rust represent "this function might return no value"?
  3. What is a "fat pointer" in the context of slices?

Common Pitfalls

  • Fighting the borrow checker -- restructure your code, do not reach for unsafe.
  • Holding borrows across .push() -- pushing to a Vec might reallocate, invalidating references.
  • Forgetting &mut -- writing increment(&x) when you mean increment(&mut x).
  • Confusing &T with T -- a reference is not a copy; you must dereference to get the value.
  • Using unsafe to "shut up the compiler" -- if the compiler says no, you probably have a real bug.
  • Indexing instead of iterating -- for val in &data is safer than for i in 0..data.len().