Tech Wiki

TOPICSSERIES

[Rust Zero to Production 08] Rust Borrowing and References: Read the Compiler’s Design Feedback

A function that only reads an endpoint should not take ownership of it. A function that increments its check count needs temporary write access, not permanent possession. Rust puts those promises in the type signature: &Endpoint for a shared borrow and &mut Endpoint for a mutable borrow.

Calling a reference "a pointer" is convenient shorthand, but it leaves out the part that guides API design. A reference lets code reach a value without owning it, and it has a pointer-like representation in ordinary cases. Its contract is richer: the reference must remain valid, and safe Rust controls which other accesses may overlap its borrow. Read &T and &mut T as access permissions and compiler errors become much easier to use.

This guide applies those permissions to a small Rust 2024 endpoint CLI. It also includes two deliberately rejected programs and their compiler diagnostics.

1. References as Contracts

The borrow operators create two different contracts:

  • &T grants shared, read-only access. Several shared borrows may be active together.
  • &mut T grants mutable access that must be exclusive while that borrow is active.

The second point is often memorized as "only one mutable reference." Add the time axis: accesses conflict only when their live borrow ranges overlap. A borrow can finish at its last use, before the surrounding block ends. That is why this compiles:

let mut endpoint = String::from("api");
let shared = &endpoint;
println!("{shared}"); // last use of the shared borrow

let mutable = &mut endpoint;
mutable.push_str("-v2");

Rust does not require an extra scope here because the shared borrow is no longer used when the mutable borrow starts. Braces can still make a boundary clearer, but adding braces mechanically is not the lesson. Find the last use and inspect whether the two access phases really need to overlap.

A reference also cannot outlive the value it borrows. This validity rule is one reason "reference equals memory address" is a poor design model. An address alone says nothing about whether the target still exists or whether mutation may occur through another path.

2. Borrowing in Domain Functions

The example stores owned text in Endpoint. The rendering function only observes it, so its parameter is &Endpoint. The update function receives &mut Endpoint because changing checks is part of its contract.

#[derive(Debug, PartialEq, Eq)]
pub struct Endpoint {
    name: String,
    url: String,
    checks: u32,
}

impl Endpoint {
    #[must_use]
    pub fn new(name: &str, url: &str) -> Self {
        Self {
            name: name.to_owned(),
            url: url.to_owned(),
            checks: 0,
        }
    }

    #[must_use]
    pub const fn checks(&self) -> u32 {
        self.checks
    }
}

#[must_use]
pub fn render_endpoint(endpoint: &Endpoint) -> String {
    format!(
        "{} -> {} (checks: {})",
        endpoint.name, endpoint.url, endpoint.checks
    )
}

fn increment_checks(endpoint: &mut Endpoint) {
    endpoint.checks += 1;
}

pub fn record_check(endpoint: &mut Endpoint) -> String {
    increment_checks(&mut *endpoint);
    render_endpoint(&*endpoint)
}

render_endpoint does allocate a new report String; it does not clone the Endpoint or take its fields. The owner remains usable after the call. Multiple callers can hold shared references at once because none of them can mutate the endpoint through those references.

record_check spells out a reborrow. Given an existing &mut Endpoint, &mut *endpoint creates a shorter mutable borrow for increment_checks. After that call, &*endpoint creates a shared reborrow for rendering. The original mutable reference becomes usable again when each shorter reborrow ends.

At a function call, Rust usually inserts the reborrow that the parameter requires, so production code can write this more plainly:

pub fn record_check(endpoint: &mut Endpoint) -> String {
    increment_checks(endpoint);
    render_endpoint(endpoint)
}

The explicit spelling is useful while learning what happens. It should not become decorative punctuation applied to every call.

3. Ownership at the CLI Boundary

main owns the endpoint. Helper functions borrow it for the shortest access they need.

use article_08_borrowing_references::{Endpoint, record_check, render_endpoint};

fn main() {
    let mut endpoint = Endpoint::new("api", "https://example.com/health");

    let label = render_endpoint(&endpoint);
    let audit_copy = render_endpoint(&endpoint);
    println!("before: {label}");
    println!("audit: {audit_copy}");

    println!("after: {}", record_check(&mut endpoint));
    println!("owner can still read checks: {}", endpoint.checks());
}

The two calls to render_endpoint use shared borrows. The later call to record_check gets exclusive mutable access only for that call. main can read endpoint again afterward, which proves the mutable borrow did not transfer ownership.

A signature is therefore a compact architecture decision. Taking Endpoint says the callee consumes the value. Taking &Endpoint says it observes existing state. Taking &mut Endpoint says it may change existing state while excluding competing access. Choose among those contracts before reaching for clone().

4. E0502: Overlapping Access

This example keeps failing examples outside Cargo's normal targets. The first one retains a shared borrow and then tries to create a mutable borrow before the shared one is used for the last time:

fn main() {
    let mut endpoint = String::from("api");
    let shared = &endpoint;
    let mutable = &mut endpoint;

    mutable.push_str("-v2");
    println!("{shared}");
}

rustc rejects it with E0502:

error[E0502]: cannot borrow `endpoint` as mutable because it is also borrowed as immutable

The diagnostic identifies three useful locations: where the shared borrow begins, where the conflicting mutable borrow is attempted, and the later use that keeps the shared borrow alive. Together, those locations form a small data-flow diagram. If the read can happen first, move its last use before the update. If both operations truly need concurrent access, reconsider the data model or operation boundary.

Do not "fix" this example by cloning endpoint without asking which value should be authoritative. A clone creates independent data and may hide a design mistake rather than resolve an access conflict.

5. E0499: Competing Writers

The second rejected program asks for two mutable borrows whose uses overlap:

fn main() {
    let mut endpoint = String::from("api");
    let first = &mut endpoint;
    let second = &mut endpoint;

    first.push_str("-one");
    second.push_str("-two");
}

The compiler reports E0499:

error[E0499]: cannot borrow `endpoint` as mutable more than once at a time

Often the clean repair is sequential access: finish using first, then create second. Another valid design is to expose one operation that performs the complete mutation under one &mut borrow. For genuinely disjoint data, borrow disjoint fields or use safe splitting APIs that can prove the regions do not overlap.

The error is not a request to scatter mut keywords around. mut on a binding permits mutation; it does not waive aliasing rules. E0499 says two exclusive access paths are live at the same time.

6. Rust 2024 Borrowing Example

The complete project is examples/article-08-borrowing-references. It has no external dependencies and does not use another article's crate.

[package]
name = "article-08-borrowing-references"
version = "0.1.0"
edition = "2024"
publish = false

[lints.rust]
unsafe_code = "forbid"

[lints.clippy]
all = "warn"
pedantic = "warn"

From that directory, run:

cargo fmt --check
cargo check --all-targets --all-features
cargo clippy --all-targets --all-features -- -D warnings
cargo test --all-features
cargo run --quiet
./verify_compile_fail.sh

The program prints:

before: api -> https://example.com/health (checks: 0)
audit: api -> https://example.com/health (checks: 0)
after: api -> https://example.com/health (checks: 1)
owner can still read checks: 1

Three integration tests pass. They check simultaneous shared reads, mutation without an ownership transfer, and repeated helper calls through one mutable reference. The compile-fail verifier invokes rustc --edition=2024 for both rejected files and checks both the error code and the central diagnostic text:

compile_fail/shared_then_mutable.rs: verified E0502 (cannot borrow `endpoint` as mutable because it is also borrowed as immutable)
compile_fail/two_mutable.rs: verified E0499 (cannot borrow `endpoint` as mutable more than once at a time)

Compiler wording can gain notes or change formatting between toolchains. Checking the stable error code plus the central message makes the example strict enough to catch an unexpected success without copying an entire version-specific diagnostic into the article.

7. Limits of the Simple Rule

"Many readers or one writer" is a useful safe-Rust rule, not a complete account of every Rust memory model detail. Interior-mutability types such as Cell, RefCell, Mutex, and RwLock move some checks to different mechanisms. Raw pointers have different rules and require separate unsafe-code reasoning. Neither is needed to solve this CLI.

For ordinary domain functions, start with the narrowest honest signature. Use &T for observation, &mut T for in-place change, and ownership when the callee should retain or consume the value. When the borrow checker refuses the code, locate the first borrow, its last use, and the conflicting access. Those three points usually reveal whether the fix is a shorter borrow, sequential phases, or a better API boundary.

Full source code

The complete runnable source for this article is available in the Chapter 08 project on GitHub.

Sources


One response

  1. […] Previous articleRust Borrowing and References: Read the Compiler's Design Feedback […]

Leave a Reply

Your email address will not be published. Required fields are marked *

Tech Wiki

Built with WordPress · Learn in public.