curayious Language Lab

Rust — Ownership in the Compiler

Why here, and why after OCaml. The C track ends on one idea: every C program has an ownership discipline, and it lives in comments. Rust is what happens when that discipline becomes part of the type system. Read it after OCaml as well. Option, Result, sum types and exhaustive match are ML ideas, and OCaml had them twenty years earlier. With those set aside, Rust adds one new idea: ownership checked at compile time.

This track uses a handful of terms that the C track used informally. They have exact meanings here, so they are defined once, up front:

Term Meaning in Rust
owner The one variable responsible for a value. When the owner goes out of scope, the value is freed.
move Passing a value to a new owner. The old variable can no longer be used.
borrow A reference to a value that someone else owns. &T is a shared borrow (read-only, many allowed); &mut T is a mutable borrow (read-write, exactly one allowed).
lifetime The stretch of code during which a borrow is used. Written 'a when it has to be named.
crate The unit of compilation: a whole library or binary, not one file.

Q1 · Origin and niche

Graydon Hoare started Rust as a personal project in 2006. Mozilla sponsored it from 2009, paying a team to build it as a safer language for browser engines. Version 1.0 shipped on 15 May 2015. MIT Technology Review's history of the project has the dates and the story of the broken lift that prompted it.

The constraint Rust was built under is the one the C track's Q8 describes: a browser is a large C++ program that parses hostile input all day, and most of its serious security bugs are memory bugs. The design goal was C++'s control over memory layout and cost, with the memory bugs made into compile errors. Rust 1.0 has no garbage collector: a collector adds pauses and a runtime, and a browser engine or a kernel cannot accept either.

Rust has no ISO standard. What pins the language is the edition: a crate declares edition = "2015", "2018", "2021", or "2024" in Cargo.toml, and the compiler applies that edition's syntax and lint rules to it. The rule that makes this workable is that crates of different editions link together freely (Edition Guide). The 2024 edition was stabilised in Rust 1.85, and this track uses it. The compiler itself ships a stable release every six weeks; this machine has 1.98.1.

Who pays for it today: Mozilla handed the project to the independent Rust Foundation in 2021, whose founding members were AWS, Huawei, Google, Microsoft, and Mozilla (announcement). Production users include the Linux kernel, which accepts Rust drivers (kernel docs); Android, where Google reports memory-safety bugs falling from 76% of vulnerabilities in 2019 to 24% in 2024 as new code moved to memory-safe languages (Google Security Blog); AWS's Firecracker microVM monitor; and Cloudflare's proxy layer, which appears again in Q8.

Further reading:

Q2 · Hello, build

Cargo is the build tool, the dependency manager, the test runner, and the entry point to the formatter and linter. The C, Java, and OCaml tracks assembled their builds from parts (gcc and flags, javac and a classpath, ocamlc under ocamlrun). Like Go, Rust has one command:

cargo build --release
.\build.ps1          # cargo fmt --check, cargo clippy, cargo build, run on the fixture
.\build.ps1 -Shard 4 # the same, through the --shard path

What that command does:

Compare this with the C pipeline stage by stage. Three differences matter:

  1. The unit of compilation is the crate, not the file. C compiles each .c file alone and knows other files only through the declarations in headers. rustc sees the whole crate at once, so there are no headers and no declaration that can drift from its definition. The cost is compile time. rustc splits the crate into codegen units (16 by default in release builds, per the Cargo profile reference) so LLVM can work on them in parallel.
  2. There is a checking stage after type checking. The compiler lowers the code to MIR, a simplified control-flow form, and runs the borrow checker on it (rustc dev guide). Every ownership error in this track (E0502, E0597, E0499) comes from that stage.
  3. The standard library is linked in statically. logagg.exe is 1.2 MB against the C build's 84 KB, because the C build calls into a C runtime DLL and this one carries its own copy of std. It still links the Windows C runtime for a few functions.

The toolchain on this machine is stable-x86_64-pc-windows-gnu, installed without admin by tools/install-rust.ps1. The usual Windows default, -msvc, links with link.exe from the Visual Studio Build Tools, which are not installed here. The GNU target (platform support) links with MinGW, and rustup ships a copy of the MinGW linker inside the toolchain (NOTES.md shows where). One consequence surfaced in objdump -p: this binary imports msvcrt.dll, the old C runtime that the C track's notes warn against for printf. It does not matter here, because Rust's formatting does not go through the C runtime's printf.

The build profile turns one default back on:

[profile.release]
overflow-checks = true

Integer overflow panics in debug builds and wraps silently in release builds, by default. Q3 explains why that default exists and why this track overrides it.

Dependencies come from crates.io, recorded in Cargo.toml, with exact versions locked in Cargo.lock. This track has none. regex would replace the hand-written digit checks, and a production version would use it; staying on std keeps the line counts comparable with C and Go. Cargo.lock is committed, as the Cargo FAQ advises for binaries.

Further reading:

Q3 · Type and memory model

A variable is the owner of a value. In C a variable names a region of memory and says nothing about who frees it. In Rust every value has exactly one owner at a time, the compiler tracks ownership through moves, and the value is freed when its owner goes out of scope. The compiler inserts the free; the programmer never writes it.

Typing is static and strong. There is no implicit conversion between numeric types (u16 to i64 needs i64::from or as), and there is no cast that turns an integer into a usable reference outside unsafe code.

The layout is still C's. A String is three machine words on the stack (pointer, capacity, length) and a heap block of UTF-8 bytes. A &str is two words: a pointer and a length, pointing into bytes that something else owns. This is logagg part-way through one line:

Put this beside the C version. The memory is identical: one reused line buffer, fields that point into it, and keys that are copied because the buffer is about to be overwritten. In C the difference between a borrowed pointer and an owned one is a comment. In Rust it is the difference between &str and String, and the compiler checks every use.

Lifetimes

Record<'a> says that a record contains a reference that is valid for some lifetime 'a:

struct Record<'a> {
    service: &'a str,
    status: u16,
    micros: i64,
}

fn parse_record(line: &str) -> Result<Record<'_>, Reason>

The '_ in the return type ties the record to the line argument. The caller cannot keep the record after the line changes. Here is what that means inside the read loop:

The C track's worst plausible bug was storing the borrowed service pointer as the table key, so that every key silently became the last line of the file. That program is HashMap<&str, Stats> in Rust. It does not compile:

error[E0502]: cannot borrow `buf` as mutable because it is also borrowed as immutable
  --> bug.rs:9:9
   |
 9 |         buf.clear();
   |         ^^^^^^^^^^^ mutable borrow occurs here
10 |         if reader.read_line(&mut buf).unwrap() == 0 { break; }
11 |         let service = buf.split_ascii_whitespace().nth(1).unwrap_or("");
   |                       --- immutable borrow occurs here
...
14 |     println!("{}", services.len());
   |                    -------- immutable borrow later used here

The full reproduction is in NOTES.md. The rule the compiler enforces is short: at any point, a value has either one mutable borrow or any number of shared borrows, and no borrow outlives the owner. Since non-lexical lifetimes (RFC 2094, 2018), a borrow lasts until its last use, not until the end of the enclosing block. That change is why the real logagg needs no extra braces or temporary variables around the lookup.

The unspecified, Rust's version

C has three tiers of "not specified" (C track, Q3). Rust divides the same space differently:

Term Meaning Example
Panic Defined. The thread unwinds (or the process aborts), and destructors run v[10] on a 3-element Vec; .unwrap() on Err
Wrap or panic Defined either way: panic with overflow checks on, two's-complement wrap with them off i64::MAX + 1
Undefined behaviour Only reachable inside unsafe Dereferencing a dangling raw pointer; creating an invalid &str

The middle row is a deliberate compromise. RFC 560 made overflow a program error that must either panic or wrap, never undefined behaviour, and left checks off in release builds for speed. This track turns them on, because a wrapped sum_micros would produce a plausible wrong mean, which is the failure SPEC 2 was written to prevent. The complete list of undefined behaviour is in the Reference under Behavior considered undefined. It is short, and every item on it requires unsafe.

Further reading:

Q4 · The unit of abstraction

Structs, enums, and traits, organised into modules and crates. There are no classes and no inheritance.

logagg builds its abstractions from three structs (Record, Stats, Tally) and one enum (Reason). Tally is the one worth noticing. It is the whole result of one pass over some lines, and it has a merge method. The serial run builds one Tally; the sharded run builds one per thread and merges them. The same type serves both because it owns everything it contains: no field points into a line buffer.

At production scale, the pattern to recognise is the trait as the interface boundary. std::io::Read and BufRead are traits, which is why tally_stream takes impl BufRead. It would accept a file, a socket, stdin, or an in-memory buffer in a test, with no change and no runtime cost.

Further reading:

Q5 · The error model

Result<T, E> for failures the caller should handle, and panic for bugs. There are no exceptions.

  1. Result<T, E> is an enum with two variants, Ok(T) and Err(E). A function that can fail returns one, and the caller must take it apart to reach the T.
  2. The ? operator returns early with the Err and otherwise unwraps the Ok. It is the shortest correct way to pass an error up, and it is visible at every call site (RFC 243).
  3. Option<T> (Some or None) is for "there might be no value", with no error to report.
  4. Panic is for bugs: an index out of range, an .unwrap() on an Err, a failed assertion. It unwinds the thread, running destructors on the way. The kept-but- discouraged escape hatch is std::panic::catch_unwind, which can stop the unwinding. It exists for FFI boundaries and thread pools, not for control flow. A crate built with panic = "abort" cannot catch panics at all.

logagg uses the first three. parse_record is the SPEC 1 rule in executable form:

fn parse_record(line: &str) -> Result<Record<'_>, Reason> {
    let [_, service, _, _, status, latency] = split6(line).ok_or(Reason::Fields)?;
    ...
}

The function parses rather than validates: it returns the typed Record, not a yes or no, so no later code can use a field that was never checked. Alexis King's Parse, don't validate is the standard statement of the idea. The C, Go, and Python tracks validate first and then parse the same fields a second time.

A malformed line is expected input, so it is an ordinary Err(Reason), and the caller records it and continues. A file that cannot be opened is an io::Error. It travels up through ? to main, which prints to stderr and returns ExitCode::from(12). The report is built in a String first and written in one call, so the fatal path cannot leave half a report on stdout.

Track Line 31 has status 20x
C classify returns "STATUS"; caller checks for non-NULL and continues
Go classify returns "STATUS"; "" means good
Python classify returns "STATUS"; None means good
Rust parse_record returns Err(Reason::Status); match must also handle Ok

The second clause of the Rust row is the difference. In the other three, a forgotten check compiles and then uses a bad record. In Rust the good record is only reachable inside Ok.

Further reading:

Q6 · The concurrency model

Operating-system threads, plus two marker traits that the compiler checks. A type is Send if it is safe to move to another thread, and Sync if it is safe to share a & reference between threads. The compiler works both out automatically from a type's fields. thread::spawn requires its closure to be Send, and there are no exceptions outside unsafe (Rustonomicon: Send and Sync).

The result is that a data race is a compile error. Two threads writing one HashMap with no lock:

error[E0499]: cannot borrow `services` as mutable more than once at a time
 --> race.rs:8:21
  |
8 |             s.spawn(|| *services.entry("api".into()).or_default() += 1);
  |             --------^^-------------------------------------------------
  |             |       |   |
  |             |       |   borrows occur due to use of `services` in closure
  |             |       `services` was mutably borrowed here in the previous iteration of the loop

Compare the C track's Q6, where the same program compiles and is undefined behaviour.

logagg is the first track to implement SPEC 6's --shard N. It uses the per-worker design the C track recommended: each thread builds its own Tally and nothing is shared while they run.

thread::scope(|s| {
    let workers: Vec<_> = lines
        .chunks(per)
        .enumerate()
        .map(|(i, chunk)| s.spawn(move || { /* build a Tally from chunk */ }))
        .collect();
    // join in order, merge
})

The workers borrow slices of lines, which borrows text, which main owns. Plain thread::spawn would reject that, because the compiler cannot know the thread finishes before text is freed:

error[E0597]: `text` does not live long enough
...
   |         .map(|chunk| thread::spawn(move || count(chunk)))   // no scope
   |                      ----------------------------------- argument requires that `text` is borrowed for `'static`

thread::scope, stable since Rust 1.63 (August 2022), joins every thread spawned inside it before it returns. With that guarantee the borrows are valid, and no Arc (reference counting) or Mutex is needed. The output is byte-identical to the serial run for every shard count from 1 to 500.

What Rust does not prevent: deadlocks, lock-ordering bugs, and race conditions in logic (two correct operations in the wrong order). Those are ordinary program errors in Rust, as in every other track. Rust also has async/await for I/O-bound work, and it ships no runtime for it: you choose one, usually Tokio. That is outside logagg's scope, and it is the reason many Rust services contain two concurrency models.

Further reading:

Q7 · The idiom test — logagg

src/main.rs · working notes in NOTES.md

PASS  rust
Track Code lines (no blanks or comments) Notes
C 285 own hash table, line buffer, and splitter
Rust 267 includes --shard N: 33 lines for tally_sharded and Tally::merge
Go 189
OCaml 183
Python 140 the reference

Without the shard path, Rust is about 235 lines. That is close to C's length for the opposite reason. C spends its lines on data structures the standard library does not provide. Rust spends them on types that make the rules explicit: Record<'a>, Reason, the Tally that can be merged. The hash map, the line reader, and the splitter each take one line.

Four decisions that come from Rust:

And one piece of SPEC 2 confirmed: std::fmt's documentation says float formatting rounds half to even, the same as C and Python. A {:.3} of an f64 latency would have disagreed with Java's output, exactly as the spec warns.

Q8 · How it fails

Ranked by cost in production, not by how often beginners hit them. The first three are things safe Rust allows by design.

  1. Panics in production paths. .unwrap() and .expect() turn an Err into a panic. That is correct for bugs and wrong for input. On 18 November 2025 Cloudflare's FL2 proxy, written in Rust, panicked across its network when a generated configuration file grew past a 200-entry limit, and an unwrap() on the Result of that check took down traffic for about three hours before the main fix (Cloudflare's post-mortem). The compiler did its job: the Result was there. Someone chose to unwrap it. logagg has one expect, on join(), which only fails if a worker thread already panicked.
  2. Silent integer wrap in release builds, unless you turn checks on (Q3). Casts with as also truncate silently: 300_i32 as u8 is 44.
  3. Deadlocks and logic races. Send and Sync prevent data races only. Two Mutexes locked in opposite orders by two threads deadlock in Rust as in C. A RefCell borrowed mutably twice panics at run time, because it moves the borrow check from compile time to run time.
  4. unsafe blocks that break the rules. Every guarantee above assumes that unsafe code upholds the invariants. A bug in an unsafe block causes undefined behaviour that may surface in safe code far away. The RustSec advisory database records these for published crates. Its memory-corruption entries trace back to unsafe code, because safe Rust cannot produce memory corruption without a compiler bug.
  5. Fighting the borrow checker. This does not fail at run time; it costs design time. A structure where everything points at everything (a graph, a doubly-linked list, a GUI tree) does not fit single ownership. The usual answers are indices into a Vec, Rc<RefCell<T>>, or an arena. Choose one early, because changing it later touches every function.
  6. Memory leaks are safe. Rc cycles are never freed, and std::mem::forget is not unsafe. Rust guarantees memory safety, and a leak is not unsafe.

The tooling layer, in the same shape as the C track's:

Layer Tool What it catches
Compile time rustc borrow checker; clippy (-W clippy::pedantic in build.ps1) Ownership errors; around 800 lints for style, performance, and likely bugs
Interpreter for unsafe Miri Undefined behaviour in unsafe code, by running MIR under an interpreter that checks every access
Concurrency loom Explores the thread interleavings of a concurrent test
Adversarial input cargo fuzz (libFuzzer) Panics on inputs you did not think of; parse_record is a natural target
Supply chain cargo audit against RustSec Dependencies with published advisories

Professional Rust is the language plus clippy in CI, a rule about where unwrap is allowed, and Miri for any crate with unsafe. The compiler covers much more than C's does, but not all of it.

Q9 · Read real code

Recommended: the function behind the read_line call that logagg makes in its loop, append_to_string in library/alloc/src/io/read.rs. (It lives in alloc and std re-exports it; search the file for the name, because line numbers move.) About 25 lines of code under a 20-line comment. It is the inside of the safety guarantee:

This is where the C track's discipline went. The same care exists, written once in the standard library, and every safe caller relies on it without repeating it.

Alternative, longer: ripgrep's crates/searcher/src/line_buffer.rs (about 550 lines of code and 400 of tests). It is a production line buffer: a reused Vec<u8>, a configurable limit on how far it may grow for one long line, binary-file detection, and a roll step that moves a partial line to the front before the next read. Read it against logagg's read_line loop to see what a line reader needs when the input is anything anyone throws at it.

Q10 · What transfers

Ownership is a design tool first and a compiler feature second. Writing Rust teaches you to answer "who owns this, and who is only borrowing it?" for every value. That answer improves a C, Go, or Java design as well, where nothing checks it.

Carry these forward: