Skip to main content

Someone wanted to learn this too, so Grasp built them a personal learning path.

You can keep exploring this one or create your own

Refactoring Shared Mutable Code with Rust Borrowing Rules

Welcome back. Last lesson established the ownership baseline: a non-Copy value such as String has one owner, moves transfer that ownership, and the current owner determines when cleanup occurs. That is safe, but it would be cumbersome if every function that merely needed to inspect player state had to take ownership and return it.

This lesson refactors that style of code using references and Rust’s borrowing rules. You will learn to choose between a shared reference, an exclusive mutable reference, and ownership; then use that choice to make player-state code clearer and safer. These rules are foundational for the async sections later in the course, where a borrow that lasts too long can prevent a future from suspending safely.


Borrowing: use data without taking it

Suppose an early player prototype stores a selected track and buffer level:

struct PlayerState {
    current_track: String,
    buffered_ms: u64,
    paused: bool,
}

A function that takes the whole state by value consumes it:

fn buffered_seconds(state: PlayerState) -> u64 {
    state.buffered_ms / 1_000
}

fn main() {
    let state = PlayerState {
        current_track: String::from("video-012.m4s"),
        buffered_ms: 8_500,
        paused: false,
    };

    let seconds = buffered_seconds(state);

    // `state` was moved into `buffered_seconds`.
    println!("buffered: {seconds} seconds");
}

The function only reads buffered_ms, but its signature claims ownership of all of state. The caller loses its state just to calculate one number.

A reference lets the function access a value owned elsewhere:

fn buffered_seconds(state: &PlayerState) -> u64 {
    state.buffered_ms / 1_000
}

fn main() {
    let state = PlayerState {
        current_track: String::from("video-012.m4s"),
        buffered_ms: 8_500,
        paused: false,
    };

    let seconds = buffered_seconds(&state);

    println!("buffered: {seconds} seconds");
    println!("track: {}", state.current_track);
}

The important changes are paired:

  • At the call site, &state creates a reference to state.
  • In the function signature, &PlayerState says the parameter is a shared reference.

No PlayerState is moved. The original binding remains the owner, and its String is still dropped when state leaves scope.

Learning Rust: Memory, Ownership and Borrowing

Watch “Learning Rust: Memory, Ownership and Borrowing” from YouCodeThings for a compact visual explanation of why a reference avoids an ownership transfer, and why read-only and mutable borrows have different rules.

Watch borrowing rules. Focus on the distinction between read-only access through &T and controlled mutation through &mut T; the caller remains the owner in both cases.

A reference is non-owning. When a reference variable or reference parameter goes out of scope, Rust does not drop the value it points to. It merely stops having access to it.

References and Borrowing - The Rust Programming ...

Read the official Rust Book’s introduction to references and its explanation of mutable references. It provides the precise syntax and the reasoning behind the rules used throughout this lesson.

In the “References and Borrowing” section, begin with the explanation that references remain valid. Then continue through the “Mutable References” subsection. Pay particular attention to why a mutable reference requires a mutable original binding, and to the statement that mutation is exclusive.


The three API choices: inspect, modify, or consume

When refactoring a function, begin by asking what it truly needs from its argument. There are three common answers.

Function’s job Parameter form What the caller keeps
Inspect a value &T Ownership; possibly other shared readers
Modify a value temporarily &mut T Ownership, after the exclusive borrow ends
Take responsibility for the value T Nothing, unless ownership is returned

For ordinary player-state code, this gives a useful signature vocabulary:

fn buffered_seconds(state: &PlayerState) -> u64 {
    state.buffered_ms / 1_000
}

fn pause(state: &mut PlayerState) {
    state.paused = true;
}

fn close_player(state: PlayerState) {
    println!("closing {}", state.current_track);
} // `state` and its fields are dropped here

At a call site:

let mut state = PlayerState {
    current_track: String::from("audio-012.m4s"),
    buffered_ms: 8_500,
    paused: false,
};

let seconds = buffered_seconds(&state);
pause(&mut state);
close_player(state);

// `state` was moved into `close_player`.
println!("{seconds}");

The call syntax exposes the contract:

  • buffered_seconds(&state) borrows state for reading.
  • pause(&mut state) borrows state exclusively for modification.
  • close_player(state) transfers ownership.

That visibility is one of Rust’s practical strengths. A caller can reason about whether a function reads, modifies, or consumes a resource by looking at its parameter types.

Prefer borrowing over ownership when ownership is not required

Here is a common ownership-heavy design:

fn rename_track(mut state: PlayerState, suffix: &str) -> PlayerState {
    state.current_track.push_str(suffix);
    state
}

It works, but it makes every caller move the entire PlayerState in and receive it back:

state = rename_track(state, ".ready");

That pattern is occasionally appropriate when a transformation deliberately consumes and produces a new value. But this function modifies an existing state in place. Its intent is expressed more directly with a mutable borrow:

fn rename_track(state: &mut PlayerState, suffix: &str) {
    state.current_track.push_str(suffix);
}

rename_track(&mut state, ".ready");
println!("{}", state.current_track);

Now there is one owner throughout: state. The function receives temporary exclusive access, changes the track name, and returns nothing because there is no ownership to return.

Borrow strings as &str when you only need text

Notice that suffix has type &str, not String. A string slice is a borrowed view of UTF-8 text. It lets callers pass a string literal, a borrowed String, or another string slice without forcing allocation or ownership transfer:

rename_track(&mut state, ".ready");

let extension = String::from(".cached");
rename_track(&mut state, &extension);

For inputs that only need to be read as text, &str is often a more flexible public API than &String. The function does not need the allocation metadata or the right to grow the caller’s string; it needs only a readable sequence of characters.


Shared borrows and mutable borrows

Rust’s central borrowing rule is compact:

At any moment, a value may have either any number of shared references or exactly one mutable reference.

In type notation:

Borrow kind Syntax Permitted at one time Access provided
Shared borrow &T Many Read access
Mutable borrow &mut T One Read and write access

A shared reference is frequently described as immutable or read-only:

fn print_status(state: &PlayerState) {
    println!(
        "{}: {} ms buffered, paused = {}",
        state.current_track,
        state.buffered_ms,
        state.paused
    );
}

let first_view = &state;
let second_view = &state;

println!("{}", first_view.current_track);
println!("{}", second_view.buffered_ms);

Several readers are safe because none can modify the state through its shared reference. No reader can observe an unexpected mutation through another reader.

A mutable reference allows modification, but that capability is exclusive:

fn seek_forward(state: &mut PlayerState, amount_ms: u64) {
    state.buffered_ms = state.buffered_ms.saturating_sub(amount_ms);
}

seek_forward(&mut state, 2_000);

Two conditions are required:

  1. The original binding must be declared mut.
  2. The call must create a mutable reference using &mut.
let mut state = PlayerState {
    current_track: String::from("video-012.m4s"),
    buffered_ms: 8_500,
    paused: false,
};

seek_forward(&mut state, 2_000);

The mut on state permits mutation of that owned value. The &mut passed to seek_forward grants the function temporary exclusive permission to do it.

This infographic summarizes the two borrowing modes: multiple shared references may coexist, while a mutable reference is exclusive; Rust uses those constraints to prevent invalid references and unsynchronized conflicting access.

Why exclusivity matters

The following code does not compile:

let mut state = PlayerState {
    current_track: String::from("video-012.m4s"),
    buffered_ms: 8_500,
    paused: false,
};

let first = &mut state;
let second = &mut state;

println!("{}", first.buffered_ms);
println!("{}", second.buffered_ms);

first has exclusive access to state until its final use. Creating second would violate that guarantee.

The same issue occurs when mixing a shared borrow with a mutable borrow:

let track_name = &state.current_track;
let state_for_update = &mut state;

// `track_name` might still be used here.
println!("{track_name}");
state_for_update.paused = true;

A reader expects the value it is reading not to change unexpectedly during its borrow. A writer expects no other path can observe or modify the value while it performs an update. Rust rejects overlapping read and write access rather than making you discover inconsistent state at runtime.

References and Borrowing - The Rust Programming ...

Continue in the official Rust Book for the rules governing overlapping borrows and for Rust’s guarantee that references cannot outlive the values they point to.

In the discussion immediately before “Dangling References”, read the example mixing shared and mutable references. Focus on the rule that reading and mutation cannot overlap, then observe how the later example permits mutation after the final use of the shared references. In the “Dangling References” subsection, read the explanation that references cannot outlive their data.


Borrow duration: scoped access, not necessarily scoped variables

A useful modern Rust feature is that a borrow generally ends at its last use, not only when the variable’s enclosing braces end. This is called a non-lexical lifetime, though you do not need lifetime annotations to use it.

This compiles:

let mut state = PlayerState {
    current_track: String::from("video-012.m4s"),
    buffered_ms: 8_500,
    paused: false,
};

let track_name = &state.current_track;
println!("selected track: {track_name}");

// `track_name` is not used again, so its borrow has ended.
state.paused = true;

Rust sees that the shared reference is finished after println!, so it permits the later mutable access. The variable name track_name is still syntactically in scope, but its reference is no longer used.

When a borrow error appears, a productive debugging method is:

  1. Identify the reference that begins first.
  2. Find its last actual use, not just where its variable is declared.
  3. Ask whether another incompatible borrow begins before that final use.
  4. Shorten the first borrow by restructuring the code, passing only the data needed, or using a nested block.

For example, an explicit inner scope can communicate the intended boundary:

{
    let track_name = &state.current_track;
    println!("logging current track: {track_name}");
} // shared borrow is definitely over here

state.paused = true;

The block is not always necessary, but it can make a complicated refactor easier to read.

Do not keep a reference merely because you can

Avoid storing a reference while trying to update the owner later:

// Problematic shape:
let current = &state.current_track;

// More work that needs to modify `state`.

// `current` is still needed down here.
println!("{current}");

Instead, use the reference close to where it is needed:

println!("current track: {}", state.current_track);

// More work can now modify `state`.
state.paused = true;

This design principle becomes particularly important in async Rust. An .await may suspend a future, so references and mutable access should usually be made as short-lived as possible before that suspension point. For now, the key habit is simpler: borrow only for the duration of the access you need.


Valid references and borrowed return values

Borrowing is not only about preventing conflicting access. Rust also guarantees that every reference points to a live value.

This cannot work:

fn temporary_track_name() -> &str {
    let name = String::from("intro.m4s");
    &name
} // `name` is dropped here

If Rust allowed the returned &str, it would point at string data that had already been released when the function returned. Rust rejects this at compile time.

Return an owned value if the function creates it:

fn temporary_track_name() -> String {
    String::from("intro.m4s")
}

A function can return a reference when that reference comes from data supplied by the caller:

fn file_extension(path: &str) -> &str {
    path.rsplit('.').next().unwrap_or("")
}

let segment = String::from("video-012.m4s");
let extension = file_extension(&segment);

println!("extension: {extension}");

Here extension borrows a portion of segment. It cannot remain usable after segment is dropped or moved. Rust can infer that relationship in this simple signature. Later, when you work with parser APIs that return borrowed manifest or media data, you will learn how explicit lifetime annotations describe more complex versions of the same guarantee.


A practical refactoring pattern

When transforming ownership-heavy player code into borrowing-based code, use this sequence:

  1. Locate the true owner. In most cases, a local PlayerState, Playlist, or Vec<u8> should remain owned by the caller that controls its lifetime.
  2. Classify each helper’s need. Does it inspect, modify, or consume the value?
  3. Choose the narrowest parameter type. Use &T for inspection, &mut T for in-place changes, and T only when the helper must own the value.
  4. Update both ends. A signature using &mut PlayerState requires a caller passing &mut state.
  5. Keep borrows short. Do not retain a reference across unrelated work if a direct field access or a small scope suffices.
  6. Return results, not ownership, when possible. A query can return a number, boolean, or other derived value. A mutation often returns ().

Here is a small coherent refactor:

struct PlayerState {
    current_track: String,
    buffered_ms: u64,
    paused: bool,
}

fn is_ready_to_play(state: &PlayerState, minimum_buffer_ms: u64) -> bool {
    !state.paused && state.buffered_ms >= minimum_buffer_ms
}

fn pause(state: &mut PlayerState) {
    state.paused = true;
}

fn main() {
    let mut state = PlayerState {
        current_track: String::from("audio-012.m4s"),
        buffered_ms: 8_500,
        paused: false,
    };

    if is_ready_to_play(&state, 5_000) {
        println!("starting {}", state.current_track);
    }

    pause(&mut state);

    println!("paused = {}", state.paused);
}

The state is created once and owned by main. is_ready_to_play gets shared access and cannot modify it. pause gets exclusive temporary access and changes only what it needs to change. After each call finishes, main continues to own and use the same PlayerState.


Key takeaways

References let code access an owned value without transferring ownership:

  • Use &T for shared, read-only access.
  • Use &mut T for exclusive read-write access.
  • At one time, Rust permits many shared references or one mutable reference to the same value.
  • A mutable borrow requires both a mutable owner binding and &mut at the call site.
  • A reference does not drop the data it points to; the owner remains responsible for cleanup.
  • Rust rejects dangling references, so a reference cannot outlive its referent.
  • Refactor functions by expressing their real needs in the parameter type rather than taking ownership by default.

Next, you will use structs, enums, and exhaustive pattern matching to make player state more expressive: for example, representing whether playback is stopped, loading, playing, paused, or ended without relying on loosely related booleans.

Can't find a good explanation? Sign up and we'll make it for you

Sign up