Langsung ke konten
Semua insights

Academy

Borrowing and References: Sharing Data Without Losing Control (or Your Sanity)

Master Rust borrowing and references: sharing data safely with immutable and mutable rules that eliminate data races at compile time.

10 menit baca

Borrowing and References: Sharing Data Without Losing Control (or Your Sanity)
Di halaman ini

Ownership solves the memory safety problem. But if you stopped there, you'd have a language where passing data to a function means giving it away—permanently, irrevocably, like lending your favorite book to that one friend who "definitely" returns things. Every function call would be a one-way transfer. Every data access would require you to pass the data in, get it back, pass it again, get it back again, in an endless bureaucratic shuffle that would make even the most patient developer consider a career in goat farming.

"Hey, I need to print this string." Moves the string to the print function. "Okay, now I need to check its length." Moves the string to the length function. Wait, the print function consumed it. It's gone. "I guess I'll just... clone everything?"

Obviously, this isn't practical. Real programs need multiple parts of the code to read the same data. Functions need to inspect values without consuming them. Data structures need to reference other data structures without taking ownership. You need a way to access data without owning it.

That's what borrowing is. It's Rust's mechanism for letting code access data through references, with compile-time rules that prevent every data race, dangling pointer, and concurrent modification bug that has ever ruined a developer's evening. And the beautiful part? These rules are so simple that they fit on an index card—yet so powerful that they eliminate an entire category of concurrency bugs that other languages spend entire frameworks trying to mitigate.

At RantAI, where our systems pass data through complex pipelines of processing stages—where dozens of functions need to read simulation state, and carefully controlled writers need to update it—borrowing is the mechanism that makes our concurrent architecture safe by construction. Not safe by convention. Not safe by code review. Safe by the laws of the compiler, which are considerably more reliable than the laws of "please don't modify this variable while someone else is reading it." This article, drawn from Chapter 3, Section 3.4.2 of our guide "The Rust Programming Language," explains how borrowing works and why its rules prevent data races at compile time.

Two Kinds of References: The Simple Version#

Rust has two types of references. Learning when to use each one takes about five minutes. The rules governing them take another five minutes. And those ten minutes of learning will save you more debugging hours than any other ten minutes you'll ever spend in your programming career.

Immutable References (&T): Many Readers, No Writers#

Rust
fn calculate_length(s: &String) -> usize {
    s.len()  // Can read s, cannot modify it
}

fn first_word(s: &str) -> &str {
    let bytes = s.as_bytes();
    for (i, &byte) in bytes.iter().enumerate() {
        if byte == b' ' {
            return &s[..i];
        }
    }
    &s[..]
}

fn main() {
    let s = String::from("hello world");
    let len = calculate_length(&s);      // Borrow s immutably
    let word = first_word(&s);           // Borrow s immutably again — no problem!
    println!("'{}' has {} chars, first word: '{}'", s, len, word);
    // s is still owned by main, fully intact, dignity preserved
}

The & creates an immutable reference—a way to look at data without owning it. calculate_length can read s but cannot modify it. first_word can also read s. And after both calls, s is still fully owned by main, unchanged and available for further use. No cloning, no moving, no ownership gymnastics.

You can have as many immutable references as you want simultaneously. Multiple readers are safe—nobody is changing the data, so there's nothing to conflict. This is the default mode of sharing in Rust, and it covers the vast majority of function calls in any program.

Mutable References (&mut T): One Writer, Zero Readers#

Rust
fn add_exclamation(s: &mut String) {
    s.push_str("!");  // Can modify s because we have &mut
}

fn make_uppercase(s: &mut String) {
    *s = s.to_uppercase();  // Replace the entire contents
}

fn main() {
    let mut greeting = String::from("hello");
    add_exclamation(&mut greeting);
    println!("{}", greeting);  // "hello!"

    make_uppercase(&mut greeting);
    println!("{}", greeting);  // "HELLO!"
}

The &mut creates a mutable reference—exclusive access to modify the data. Only one mutable reference can exist at a time, and while it exists, no immutable references can coexist. This is the key constraint, and it's the reason Rust can prevent data races at compile time.

The Borrowing Rules: Two Rules, Zero Ambiguity#

The entire borrowing system is governed by two rules:

Rule 1: At any given time, you can have EITHER multiple immutable references (&T) OR exactly one mutable reference (&mut T)—but never both simultaneously.

Rule 2: References must always be valid (no dangling references).

That's it. Two rules. Let's see them in action:

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

    let r1 = &data;       // OK: first immutable borrow
    let r2 = &data;       // OK: second immutable borrow — multiple readers fine
    println!("{:?} {:?}", r1, r2);

    // r1 and r2 are no longer used after this point (NLL — Non-Lexical Lifetimes)

    let r3 = &mut data;   // OK: mutable borrow — no immutable refs alive anymore
    r3.push(4);
    println!("{:?}", r3);  // [1, 2, 3, 4]
}

Notice something subtle: r1 and r2 are immutable references that go out of use before r3 is created. Rust's Non-Lexical Lifetimes (NLL) system tracks when references are actually last used, not just when they go out of scope. This makes the rules more flexible than they might initially seem—you can switch between reading and writing within the same function, as long as the lifetimes don't overlap.

But try to break the rules, and the compiler stops you:

Rust
fn main() {
    let mut data = vec![1, 2, 3];
    let r1 = &data;          // Immutable borrow starts
    let r2 = &mut data;      // COMPILE ERROR: can't borrow mutably while
                              // immutable borrow is still active
    println!("{:?}", r1);    // r1 is still in use here — conflict!
}

The compiler's error message is clear, specific, and actionable: it tells you exactly which borrow conflicts with which, on which lines, and why. This isn't a cryptic segfault at runtime—it's a detailed explanation at compile time, before your code ever runs, before it ever reaches production, before it ever costs you a Saturday afternoon of debugging.

Why This Matters: Data Races Eliminated at Compile Time#

A data race occurs when two threads access the same memory, at least one of them writes, and there's no synchronization. Data races are among the most insidious bugs in software engineering because they're non-deterministic—they might happen once every thousand runs, or only under load, or only on Tuesdays when it's raining, or only during your demo for the CEO. They're essentially impossible to test for reliably, because the same code can produce different results on different runs.

Rust's borrowing rules make data races structurally impossible. If you can't have a mutable reference while immutable references exist—even within a single thread—then you certainly can't have concurrent read-write access across threads. The same rules that prevent single-threaded aliasing bugs also prevent multi-threaded data races. One mechanism, two categories of bugs eliminated.

This is why Rust's concurrency story is called "fearless concurrency." It's not that concurrent programming in Rust is easy (concurrency is inherently complex). It's that an entire category of concurrent bugs—data races—simply cannot exist. The compiler proves their absence before your code runs. If it compiles, there are no data races.

Borrowing in Practice: Real Patterns#

Processing Without Consuming#

Rust
fn summarize(data: &[f64]) -> (f64, f64, f64) {
    let sum: f64 = data.iter().sum();
    let min = data.iter().cloned().reduce(f64::min).unwrap_or(0.0);
    let max = data.iter().cloned().reduce(f64::max).unwrap_or(0.0);
    (sum / data.len() as f64, min, max)
}

fn main() {
    let measurements = vec![23.1, 24.5, 22.8, 25.0, 23.7];
    let (avg, min, max) = summarize(&measurements);
    println!("Avg: {:.1}, Min: {:.1}, Max: {:.1}", avg, min, max);
    // measurements is still available — we only borrowed it
    println!("Total readings: {}", measurements.len());
}

summarize borrows the slice immutably: it computes statistics without consuming or modifying the data. The caller retains full ownership and can continue using the data after the call. This is the pattern you'll use hundreds of times in every Rust project.

Transforming In Place#

Rust
fn normalize(data: &mut Vec<f64>) {
    let max = data.iter().cloned().reduce(f64::max).unwrap_or(1.0);
    if max > 0.0 {
        for val in data.iter_mut() {
            *val /= max;
        }
    }
}

fn main() {
    let mut values = vec![10.0, 20.0, 30.0, 40.0, 50.0];
    normalize(&mut values);
    println!("{:?}", values);  // [0.2, 0.4, 0.6, 0.8, 1.0]
}

normalize borrows the vector mutably and modifies it in place. The &mut in the function signature tells everyone reading the code: "this function will change the data." No surprises, no hidden side effects, no "did calling this function modify my array or not?" uncertainty.

Broader Implications: Trust Through Transparency#

Borrowing creates a codebase where data flow is visible. When you see &T, you know the function only reads. When you see &mut T, you know it modifies. When you see T (by value), you know it takes ownership. These three signatures encode the most important information about a function's relationship with its data—information that would otherwise require reading the entire function body, trusting the documentation, or hoping the code review caught it.

At RantAI, this transparency is what makes our concurrent simulation architecture maintainable. When a new engineer reads our code, they can see immediately which functions read shared state, which functions modify state, and which functions consume data. The signatures don't just describe behavior—they guarantee it. The compiler ensures that no function secretly modifies data it claimed to only read. In a world where "the documentation says it's read-only" is the best you can hope for in most languages, Rust's compile-time guarantees are a genuine competitive advantage.

Practical Applications & Strategic Takeaways#

For newcomers: Start with immutable references (&T) everywhere. Only use &mut T when you specifically need to modify data. If the compiler complains about a borrow conflict, resist the urge to just .clone() everything—understand the conflict first. Often, restructuring the code to avoid overlapping borrows produces cleaner, faster code than cloning.

For C/C++ veterans: Borrowing replaces raw pointers with compiler-checked guarantees. You get the same performance—references are pointers at the machine level—without the danger of dangling pointers, use-after-free, or data races. The constraint of "one writer or many readers" might feel restrictive at first, but it's the same constraint that makes RwLock work correctly in C++—Rust just enforces it at compile time instead of leaving it to your discipline.

For concurrent programmers: Rust's borrowing rules eliminate data races at compile time. Not "reduce." Not "make less likely." Eliminate. If you're spending significant time debugging race conditions in other languages, Rust's borrow checker is the most effective concurrency debugging tool ever created—because it runs before your code does.

Our Commitment to Open Knowledge#

RantAI is committed to open knowledge. Borrowing and references are covered in Chapter 3, Section 3.4.2 of our guide, "The Rust Programming Language," freely available online.

Explore these concepts further: https://trpl.rantai.dev

Support Our Mission & Get Your Handbook#

If this article has helped you understand why borrowing is Rust's most important day-to-day feature—or at least made you slightly nervous about all the unsynchronized shared mutable state in your other codebases—consider supporting RantAI's educational mission.

#RustLang #Borrowing #References #MemorySafety #DataRaces #RantAI #LearnRust #Concurrency #SystemsProgramming #CompileTimeSafety

Artikel berikutnya

Ownership: The One Rule That Changes Everything About How You Think About Memory