Advanced~20 min read

Rust

A deep dive into Rust's ownership model, borrowing rules, traits, generics, and fearless concurrency for building fast, memory-safe systems software.

OwnershipBorrowingTraitsPattern Matching

Rust is a systems programming language focused on safety, speed, and concurrency. It achieves memory safety without a garbage collector through its unique ownership model, and its rich type system catches whole classes of bugs at compile time. These notes target the Rust 2021 edition.

Why Rust?

Rust gives you low-level control like C/C++ but guarantees no data races, no use-after-free, and no null-pointer dereferences at compile time. If it compiles, an entire category of runtime crashes is already ruled out.

Variables and Mutability

Bindings are created with let and are immutable by default. Add mut to allow reassignment. Shadowing lets you redeclare a name (even with a new type), and const declares compile-time constants that must be type-annotated.

let x = 5;              // immutable
let mut y = 10;         // mutable
y += 1;

let x = x + 1;          // shadowing: new binding, x is now 6
let x = "now a string"; // shadowing can change type

const MAX_POINTS: u32 = 100_000; // must be UPPER_SNAKE_CASE, typed

Scalar and Compound Types

Scalar types: integers (i8..i128, u8..u128, isize/usize), floats (f32, f64), bool, and char (a 4-byte Unicode scalar). Compound types are tuples and arrays.

let tup: (i32, f64, char) = (500, 6.4, 'z');
let (a, b, c) = tup;          // destructuring
let first = tup.0;            // index access

let arr: [i32; 5] = [1, 2, 3, 4, 5]; // fixed-size, stack allocated
let zeros = [0; 3];                  // [0, 0, 0]
let slice = &arr[1..3];              // slice: &[i32] = [2, 3]

Functions and Control Flow

Functions use fn. The last expression without a semicolon is the return value. if is an expression, and Rust has loop, while, and for.

fn add(a: i32, b: i32) -> i32 {
    a + b // no semicolon = returned expression
}

let n = if add(2, 3) > 4 { "big" } else { "small" }; // if is an expression

for i in 0..5 { println!("{i}"); }        // range 0..4
for item in &vec { /* borrow each */ }

let result = loop {                        // loop can return a value
    break 42;
};

Ownership

Ownership is Rust's central feature. Every value has a single owner; when the owner goes out of scope, the value is dropped (freed). Assigning or passing a heap value moves ownership, invalidating the original binding. Types that implement Copy (integers, bools, chars, floats, and tuples of such) are copied instead of moved.

let s1 = String::from("hello");
let s2 = s1;          // MOVE: s1 is no longer valid
// println!("{s1}");  // compile error: value borrowed after move
let s3 = s2.clone();  // deep copy: both s2 and s3 valid

let n1 = 5;
let n2 = n1;          // COPY: i32 is Copy, n1 still valid
println!("{n1} {n2}");

The three ownership rules

1) Each value has exactly one owner. 2) There can only be one owner at a time. 3) When the owner goes out of scope, the value is dropped.

Borrowing and References

Instead of moving, you can borrow a value with a reference &T (shared) or &mut T (mutable). The borrow checker enforces that at any given time you may have either one mutable reference or any number of shared references, but never both. This "one-mut-XOR-many-shared" rule is what prevents data races.

fn len(s: &String) -> usize { s.len() } // borrows, does not take ownership

let mut s = String::from("hi");
let r1 = &s;          // shared borrow
let r2 = &s;          // another shared borrow is fine
println!("{r1} {r2}");

let m = &mut s;       // exclusive mutable borrow
m.push_str(" there");
// let bad = &s;       // error: cannot borrow while &mut is live
Rule Meaning
&TAny number of shared (read-only) references allowed at once.
&mut TExactly one mutable reference, and no shared references simultaneously.
ValidityA reference must never outlive the value it points to (no dangling refs).

Lifetimes Basics

Lifetimes are named regions of code (written 'a) that describe how long references are valid. Most are inferred, but you sometimes annotate them when a function returns a reference derived from its inputs, so the compiler knows the returned reference lives at least as long as the inputs.

fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() { x } else { y }
}

struct Excerpt<'a> { part: &'a str } // struct holding a reference

Structs and Enums

Structs group related data; enums encode a value that is one of several variants, and each variant can carry data. Methods are defined in impl blocks.

struct Point { x: f64, y: f64 }

impl Point {
    fn new(x: f64, y: f64) -> Self { Point { x, y } } // associated fn
    fn dist(&self) -> f64 { (self.x * self.x + self.y * self.y).sqrt() }
}

enum Shape {
    Circle(f64),                 // tuple variant
    Rect { w: f64, h: f64 },     // struct variant
    Unit,                        // unit variant
}

Pattern Matching

match is exhaustive and powerful, destructuring values as it branches. if let and while let handle single-pattern cases concisely.

fn area(s: &Shape) -> f64 {
    match s {
        Shape::Circle(r) => std::f64::consts::PI * r * r,
        Shape::Rect { w, h } => w * h,
        Shape::Unit => 0.0,
    }
}

if let Some(x) = maybe_value { println!("{x}"); } // only the Some case
while let Some(top) = stack.pop() { process(top); }

let (a, .., z) = (1, 2, 3, 4);          // destructure with rest
match n { 1 | 2 => "low", 3..=9 => "mid", _ => "high" };

Option and Result, and the ? Operator

Rust has no null. Absence is modeled by Option<T> (Some(T) or None) and recoverable errors by Result<T, E> (Ok(T) or Err(E)). The ? operator propagates errors: it returns early with the Err/None or unwraps the success value.

fn parse_add(a: &str, b: &str) -> Result<i32, std::num::ParseIntError> {
    let x: i32 = a.parse()?; // returns Err early if parse fails
    let y: i32 = b.parse()?;
    Ok(x + y)
}

let v = Some(3).map(|n| n * 2).unwrap_or(0); // 6
let first = list.first().copied();           // Option<i32>

Error Handling

For unrecoverable errors, panic! aborts the current thread. For recoverable ones, return Result<T, E>. You can define custom error enums implementing std::error::Error. In practice, the thiserror crate reduces boilerplate for library error types, and anyhow provides an ergonomic catch-all error type for applications.

#[derive(thiserror::Error, Debug)]
enum ConfigError {
    #[error("missing field: {0}")]
    Missing(String),
    #[error(transparent)]
    Io(#[from] std::io::Error), // auto-convert with ?
}

// anyhow in an application:
fn run() -> anyhow::Result<()> {
    let data = std::fs::read_to_string("cfg.toml")?; // any error -> anyhow::Error
    Ok(())
}

Traits

Traits define shared behavior (like interfaces). Implement them with impl Trait for Type. Traits can provide default methods, and common ones can be auto-generated with #[derive(...)]. Trait objects (Box<dyn Trait>) enable dynamic dispatch over heterogeneous types.

trait Speak {
    fn name(&self) -> String;
    fn greet(&self) -> String { format!("Hi, {}", self.name()) } // default
}

#[derive(Debug, Clone, PartialEq)]
struct Dog { id: u32 }

impl Speak for Dog {
    fn name(&self) -> String { format!("Dog#{}", self.id) }
}

let animals: Vec<Box<dyn Speak>> = vec![Box::new(Dog { id: 1 })]; // dynamic dispatch

Generics and Trait Bounds

Generics avoid duplication. Constrain type parameters with trait bounds inline (T: Ord) or in a where clause for readability. Generics use static dispatch (monomorphization), so there is no runtime cost.

fn largest<T: PartialOrd + Copy>(list: &[T]) -> T {
    let mut max = list[0];
    for &item in list { if item > max { max = item; } }
    max
}

fn print_all<T>(items: &[T])
where
    T: std::fmt::Display,
{
    for it in items { println!("{it}"); }
}

Collections

The standard library provides growable, heap-allocated collections. Vec<T> is a dynamic array, String is a growable UTF-8 string, and HashMap<K, V> maps keys to values. Slices (&[T], &str) are borrowed views into contiguous data.

let mut v: Vec<i32> = vec![1, 2, 3];
v.push(4);
let slice: &[i32] = &v[1..];        // borrowed view

let mut s = String::from("Hello");
s.push_str(", world");

use std::collections::HashMap;
let mut scores: HashMap<String, i32> = HashMap::new();
scores.insert("blue".into(), 10);
*scores.entry("blue".into()).or_insert(0) += 5; // upsert

Iterators and Closures

Iterators are lazy: adapters like map and filter do nothing until a consumer like collect, sum, or a for loop drives them. Closures (|args| body) capture their environment and are the arguments these adapters take.

let nums = vec![1, 2, 3, 4, 5, 6];
let evens_squared: Vec<i32> = nums
    .iter()
    .filter(|&&n| n % 2 == 0) // lazy
    .map(|&n| n * n)         // lazy
    .collect();               // consumer: runs the chain -> [4, 16, 36]

let total: i32 = nums.iter().sum();
let factor = 10;
let scale = |x: i32| x * factor; // closure captures `factor`

Smart Pointers

Smart pointers own data on the heap and add capabilities. Choose based on ownership needs and thread safety.

Type Purpose
Box<T>Single-owner heap allocation; used for recursion and trait objects.
Rc<T>Reference-counted shared ownership, single-threaded only.
Arc<T>Atomically reference-counted; shared ownership across threads.
RefCell<T>Interior mutability with borrow rules checked at runtime.
use std::rc::Rc;
use std::cell::RefCell;

let shared = Rc::new(RefCell::new(vec![1, 2]));
let clone = Rc::clone(&shared);        // increments ref count
clone.borrow_mut().push(3);            // mutate through shared handle
println!("{:?}", shared.borrow());     // [1, 2, 3]

Concurrency

Rust's ownership rules deliver fearless concurrency: data races are compile errors. Spawn threads with thread::spawn and a move closure to transfer ownership. Share mutable state across threads with Arc<Mutex<T>>, or communicate over channels. For I/O-bound work, use async/.await with a runtime such as tokio.

use std::sync::{Arc, Mutex};
use std::thread;

let counter = Arc::new(Mutex::new(0));
let mut handles = vec![];
for _ in 0..4 {
    let c = Arc::clone(&counter);
    handles.push(thread::spawn(move || {
        *c.lock().unwrap() += 1; // move closure takes ownership of `c`
    }));
}
for h in handles { h.join().unwrap(); }
println!("{}", *counter.lock().unwrap()); // 4

// Channels
use std::sync::mpsc;
let (tx, rx) = mpsc::channel();
thread::spawn(move || tx.send("ping").unwrap());
println!("{}", rx.recv().unwrap());

// Async with tokio
#[tokio::main]
async fn main() {
    let body = fetch().await; // suspends without blocking the thread
}

Send and Sync

The marker traits Send (safe to move to another thread) and Sync (safe to share by reference across threads) are how the compiler statically rejects unsafe sharing. This is why Rc won't compile across threads but Arc will.

Cargo and Tooling

Cargo is Rust's build system and package manager. Dependencies (crates) are declared in Cargo.toml and pulled from crates.io.

Command Description
cargo new / initCreate a new project / initialize in the current dir.
cargo buildCompile (add --release for optimized builds).
cargo runBuild and run the binary.
cargo testRun unit and integration tests.
cargo clippyLint for common mistakes and idiom improvements.
cargo fmtAuto-format code with rustfmt.

Practice Exercises

  1. Write a function that takes ownership of a String, then rewrite it to borrow instead. Explain in a comment why the borrowing version lets the caller keep using the value.
  2. Implement an enum Shape with Circle and Rectangle variants and an area() method using a match.
  3. Write a function returning Result<i32, String> that parses two strings and adds them, using the ? operator to propagate parse errors.
  4. Define a trait Summary with a default method, implement it for two structs, and store both in a Vec<Box<dyn Summary>>.
  5. Given a Vec<i32>, use an iterator chain to produce a new vector containing the squares of only the odd numbers.
  6. Spawn four threads that each increment a shared counter guarded by Arc<Mutex<i32>>, join them, and print the final value.

Section navigation