Rust Basics — Safe and Fast Systems Programming

Status: Active | Last Updated: 2026-08-29 Category: Languages — Rust Prerequisites: Basic programming concepts; some understanding of memory concepts (see C++ memory model) helps Tags: rust, ownership, borrow, cargo, systems Estimated Time: 4-6 hours (Self-paced, includes lab time)

Summary

Rust is a systems programming language that guarantees memory safety without a garbage collector. It achieves this through an ownership and borrowing system that is checked at compile time. Learning Rust teaches you how memory works in a way no other modern language requires.

What You'll Learn (Core Competencies)

Table of Contents

  1. Architectural Overview & Core Schema
  2. Deep Dive & Implementation
  3. Anti-Patterns & Common Pitfalls
  4. Independent Challenge
  5. Consolidation & Key Invariants
  6. Next Steps

1. Architectural Overview & Core Schema

Why Rust

Rust was designed for systems programming where performance and reliability matter. Key guarantees at compile time:

These guarantees come from the borrow checker, not a garbage collector or runtime, so there is no performance overhead.

The Ownership Model

Every value in Rust has exactly one owner. When the owner goes out of scope, the value is dropped (destructor runs). This is the core rule:

fn main() {
    let s1 = String::from("hello");
    let s2 = s1;  // s1's ownership moved to s2
    // println!("{}", s1);  // ERROR: s1 no longer owns the value
}

This is different from JavaScript or Python, where assignment copies a reference. In Rust, String is moved by default. Types that implement Copy (like integers) are copied, not moved.

The Borrow Checker

The borrow checker enforces two rules at compile time:

  1. At any time, you can have either one mutable reference OR any number of immutable references.
  2. References must always be valid (no dangling pointers).

These rules prevent data races at compile time, not at runtime. A data race happens when (1) two pointers access the same data, (2) at least one writes, (3) with no synchronization. Rust guarantees this cannot happen.

Lifetime Tracking

Every reference in Rust has a lifetime — a span of code during which the reference is valid. Most of the time the compiler infers lifetimes (lifetime elision). When a reference outlives the data it points to, the compiler rejects the program at build time. This eliminates dangling pointers by construction.


2. Deep Dive & Implementation

Ownership

Every value has exactly one owner. When the owner goes out of scope, the value is dropped:

fn main() {
    let s1 = String::from("hello");
    let s2 = s1;  // s1's ownership moved to s2
    // println!("{}", s1);  // ERROR: s1 no longer owns the value
}

This is different from JavaScript or Python, where assignment copies a reference. In Rust, String is moved by default. Types that implement Copy (like integers) are copied, not moved.

Stack vs Heap Values

Rust's ownership model treats stack-allocated and heap-allocated values differently:

fn main() {
    // Stack-allocated: i32 implements Copy, so x is duplicated
    let x = 5;
    let y = x;  // x is still valid
    println!("x = {}, y = {}", x, y);

    // Heap-allocated: String does NOT implement Copy, so it's moved
    let s1 = String::from("hello");
    let s2 = s1;  // s1 is moved to s2
    // println!("{}", s1);  // ERROR: borrow of moved value
}

Why the difference? A String is three words on the stack (pointer, length, capacity) plus a heap buffer. Copying the stack part and re-allocating the heap would be expensive. For i32, the value is just one word — copying is trivial.

Clone When You Need a Real Copy

If you actually want two independent heap allocations, call .clone():

fn main() {
    let s1 = String::from("hello");
    let s2 = s1.clone();  // explicit deep copy

    println!("s1 = {}, s2 = {}", s1, s2);  // both valid
}

clone() is explicit, so you can see where copying happens and reason about cost.

Ownership and Functions

Passing a value to a function moves it (or copies it, if Copy):

fn takes_ownership(s: String) {
    println!("{}", s);
}  // s is dropped here

fn makes_copy(n: i32) {
    println!("{}", n);
}  // n goes out of scope — nothing special (i32 is Copy)

fn main() {
    let s = String::from("hello");
    takes_ownership(s);     // s is moved; no longer valid
    // println!("{}", s);   // ERROR

    let n = 42;
    makes_copy(n);          // n is copied; still valid
    println!("n = {}", n);  // OK
}

Returning a value transfers ownership to the caller. This is why Rust has tuple returns like (usize, &str) — to return a value plus a borrowed reference.

The Drop Trait

When a value goes out of scope, Rust calls drop on it. This is where destructors live:

struct CustomGuard {
    name: String,
}

impl Drop for CustomGuard {
    fn drop(&mut self) {
        println!("dropping {}", self.name);
    }
}

fn main() {
    let g = CustomGuard { name: String::from("first")  };
    let h = CustomGuard { name: String::from("second") };
    // h drops first (LIFO), then g
}

Output: dropping second, then dropping first. This deterministic destruction is what makes RAII patterns work in Rust.

Borrowing and References

References let you access data without taking ownership:

fn calculate_length(s: &String) -> usize {
    s.len()  // read-only reference, s not moved
}

fn main() {
    let s = String::from("hello");
    calculate_length(&s);
    println!("{}", s);  // s is still valid — no move
}

A mutable reference (&mut) lets you modify the value, but only one mutable reference exists at a time, and no immutable references can coexist with it.

Mutable References

fn append_world(s: &mut String) {
    s.push_str(", world");
}

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

The Borrow Checker Rules

At any point in your code, the borrow checker enforces:

  1. At any time, you can have either one mutable reference OR any number of immutable references.
  2. References must always be valid (no dangling pointers).

Violations are compile-time errors, not runtime crashes:

fn main() {
    let mut s = String::from("hello");

    let r1 = &s;      // OK
    let r2 = &s;      // OK — multiple immutable borrows
    // let r3 = &mut s; // ERROR — cannot borrow as mutable while immutable borrows exist
    println!("{} {}", r1, r2);

    // After r1, r2 go out of scope, the borrow ends
    let r3 = &mut s;  // OK now
    r3.push_str("!");
}

This rule prevents data races at compile time. A data race happens when (1) two pointers access the same data, (2) at least one writes, (3) with no synchronization. Rust guarantees this cannot happen.

Non-Lexical Lifetimes (NLL)

The borrow checker is smarter than "from declaration to end of scope." It uses non-lexical lifetimes: a reference is live only as long as it is actually used:

fn main() {
    let mut s = String::from("hello");

    let r1 = &s;
    let r2 = &s;
    println!("{} {}", r1, r2);  // last use of r1 and r2

    let r3 = &mut s;  // OK — r1, r2 are no longer used
    r3.push_str("!");
    println!("{}", r3);
}

Even though r1 and r2 are still "in scope," their last use is the println!, so the borrow ends there.

Mutable Reference Scope Gotcha

fn main() {
    let mut s = String::from("hello");

    let r1 = &mut s;
    let r2 = &mut s;  // ERROR: second mutable borrow
    //                  (unless r1's last use is before r2)
    println!("{} {}", r1, r2);
}

Fix: scope the borrows explicitly, or rely on NLL by ordering uses.

Lifetimes

Every reference in Rust has a lifetime: a span of code during which the reference is valid. Most of the time, the compiler infers lifetimes (this is called lifetime elision). Sometimes you need to write them out.

Why Lifetimes Exist

References must never outlive the data they point to. Lifetimes are the compiler's way of tracking that:

fn main() {
    let r;                // ---------+-- 'a
    {                     //          |
        let x = 5;        // -+-- 'b  |
        r = &x;           //  |       |
    }                     // -+       |
    // println!("{}", r); //          |  // ERROR: x does not live long enough
}                         // ---------+

'a and 'b are lifetime labels. r has lifetime 'a, but the data x has lifetime 'b, which is shorter. The compiler rejects the program.

Lifetime Annotations in Function Signatures

When a function takes references and returns a reference, the compiler needs to know how the lifetimes relate:

// Without annotations — compile error
// fn longest(s1: &str, s2: &str) -> &str {

// With annotations — OK
fn longest<'a>(s1: &'a str, s2: &'a str) -> &'a str {
    if s1.len() > s2.len() { s1 } else { s2 }
}

fn main() {
    let a = String::from("long string");
    let b = "short";
    println!("{}", longest(&a, b));
}

<'a> declares a generic lifetime parameter. The signature says: the returned reference lives at least as long as the shorter of the two input references.

The 'static Lifetime

'static means the reference can live for the entire program. String literals are 'static:

let s: &'static str = "I live forever";

Use 'static for global config, error messages, and constants. Avoid it in function signatures unless the data is genuinely static — 'static is a strong requirement, not a default.

Structs with References

When a struct holds a reference, you must annotate the lifetime:

struct Excerpt<'a> {
    text: &'a str,
}

impl<'a> Excerpt<'a> {
    fn first_word(&self) -> &'a str {
        // Returns a reference tied to self's lifetime
        self.text.split_whitespace().next().unwrap_or("")
    }
}

fn main() {
    let novel = String::from("Call me Ishmael. Some years ago...");
    let first = Excerpt { text: &novel };
    println!("{}", first.first_word());  // "Call"
}

The struct cannot outlive the data it borrows. Drop the novel and the Excerpt becomes invalid.

Lifetime Elision Rules

In many cases, the compiler infers lifetimes so you don't have to write them:

  1. Each input reference gets its own lifetime parameter.
  2. If there's exactly one input lifetime, that lifetime is assigned to all output references.
  3. If there are multiple input lifetimes and one is &self or &mut self, the lifetime of self is assigned to all output references.

That's why fn first_word(&self, s: &str) -> &str works without annotations — rule 3 applies.

Pattern Matching

Pattern matching is how Rust deconstructs data. It's used in match, if let, while let, function parameters, and let bindings.

Match Expressions

match is exhaustive — the compiler verifies you covered every case:

enum Coin {
    Penny,
    Nickel,
    Dime,
    Quarter(String),  // US state
}

fn value_in_cents(coin: Coin) -> u8 {
    match coin {
        Coin::Penny => 1,
        Coin::Nickel => 5,
        Coin::Dime => 10,
        Coin::Quarter(state) => {
            println!("quarter from {}", state);
            25
        }
    }
}

fn main() {
    let c = Coin::Quarter(String::from("Alaska"));
    println!("{} cents", value_in_cents(c));
}

The compiler refuses to compile if you add a new variant to Coin and forget to update the match. This is "make invalid states unrepresentable" applied to control flow.

Binding Values from Patterns

fn describe(x: Option<i32>) -> &'static str {
    match x {
        Some(0) => "zero",
        Some(n) if n > 0 => "positive",
        Some(_) => "non-positive",
        None => "nothing",
    }
}

Some(n) binds n; Some(_) ignores the value. The if n > 0 is a match guard — an extra condition.

if let and while let

When you only care about one pattern, if let is shorter than match:

fn main() {
    let some_value = Some(7);

    // Verbose
    match some_value {
        Some(v) => println!("got {}", v),
        _ => (),
    }

    // Concise
    if let Some(v) = some_value {
        println!("got {}", v);
    }

    // while let for streams
    let mut stack = vec![1, 2, 3];
    while let Some(top) = stack.pop() {
        println!("{}", top);
    }
}

Destructuring

Patterns can destructure tuples, structs, and enums:

struct Point { x: i32, y: i32 }

fn main() {
    // Tuples
    let (a, b) = (1, 2);

    // Structs
    let p = Point { x: 10, y: 20 };
    let Point { x, y } = p;
    println!("{} {}", x, y);

    // Nested
    let ((a, b), Point { x, y }) = ((1, 2), Point { x: 3, y: 4 });
    println!("{} {} {} {}", a, b, x, y);
}

Matching on Slices and Ranges

fn classify(n: i32) -> &'static str {
    match n {
        0 => "zero",
        1..=9 => "single digit",
        10..=99 => "two digits",
        _ => "big",
    }
}

..= is an inclusive range. _ is a catch-all that matches anything.

Error Handling with Result

Rust has no exceptions. Errors are values of type Result<T, E>:

enum Result<T, E> {
    Ok(T),
    Err(E),
}

Functions that can fail return Result. The caller decides how to handle errors.

The ? Operator

? propagates errors up the call stack:

use std::fs::File;
use std::io::{self, Read};

fn read_username_from_file() -> Result<String, io::Error> {
    let mut s = String::new();
    File::open("username.txt")?.read_to_string(&mut s)?;
    Ok(s)
}

? is shorthand for: "if this is Err, return it; if it's Ok, unwrap the value." It also performs an implicit From conversion so error types can differ.

Unwrap and Expect

For prototypes and known-impossible errors, .unwrap() panics on Err:

let f = File::open("config.toml").unwrap();
let f = File::open("config.toml").expect("config.toml must exist");

.expect(msg) panics with a custom message. Use these only when:

In production code, prefer ? or explicit match.

Custom Error Types

For libraries, define a custom error enum and implement std::error::Error:

use std::fmt;

#[derive(Debug)]
enum AppError {
    Io(io::Error),
    Parse(std::num::ParseIntError),
    NotFound,
}

impl fmt::Display for AppError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match self {
            AppError::Io(e) => write!(f, "I/O error: {}", e),
            AppError::Parse(e) => write!(f, "parse error: {}", e),
            AppError::NotFound => write!(f, "not found"),
        }
    }
}

impl std::error::Error for AppError {}

impl From<io::Error> for AppError {
    fn from(e: io::Error) -> Self { AppError::Io(e) }
}

impl From<std::num::ParseIntError> for AppError {
    fn from(e: std::num::ParseIntError) -> Self { AppError::Parse(e) }
}

fn read_port(path: &str) -> Result<u16, AppError> {
    let s = std::fs::read_to_string(path)?;       // io::Error -> AppError
    let n: u16 = s.trim().parse()?;                // ParseIntError -> AppError
    Ok(n)
}

The From implementations let ? work seamlessly. This pattern is the foundation of libraries like anyhow and thiserror.

Option vs Result

Use .ok() and .ok_or() to convert between them when the conversion makes sense:

let port: u16 = std::env::var("PORT")
    .ok()                       // Option<String>
    .and_then(|s| s.parse().ok())  // Option<u16>
    .unwrap_or(8080);

When to Panic

Panic when:

Don't panic when:

Cargo: Building and Running

Cargo is Rust's build system, package manager, test runner, and documentation generator.

Creating a Project

cargo new my_app          # binary crate
cargo new --lib my_lib    # library crate

Generated structure:

my_app/
├── Cargo.toml
├── src/
│   └── main.rs
├── tests/                # integration tests
└── target/               # build output (gitignored)

Cargo.toml

[package]
name = "my_app"
version = "0.1.0"
edition = "2021"          # language edition; 2021/2024 are current
authors = ["you@example.com"]

[dependencies]
serde = { version = "1", features = ["derive"] }
tokio = { version = "1", features = ["full"] }
clap = "4"

[dev-dependencies]
proptest = "1"

[profile.release]
opt-level = 3
lto = true                # link-time optimization
codegen-units = 1
strip = true

[dependencies] are normal deps; [dev-dependencies] are only for tests/examples/benches; [build-dependencies] are for build.rs.

Essential Commands

cargo build                  # debug build
cargo build --release        # optimized build
cargo run                    # build + run
cargo check                  # fast type check (no codegen)
cargo test                   # run all tests
cargo test --doc             # doc tests only
cargo test integration_test  # single integration test file
cargo bench                  # run benchmarks (nightly)
cargo clippy                 # lints
cargo fmt                    # format
cargo doc --open             # build and open docs
cargo update                 # update Cargo.lock
cargo tree                   # dependency tree

Dependencies from crates.io

Add to Cargo.toml:

[dependencies]
serde = "1.0"
serde_json = "1.0"

Then use them in code:

use serde::{Serialize, Deserialize};

#[derive(Serialize, Deserialize, Debug)]
struct User {
    name: String,
    age: u32,
}

fn main() {
    let u = User { name: String::from("Ada"), age: 36 };
    let json = serde_json::to_string(&u).unwrap();
    println!("{}", json);
}

Cargo.lock records exact versions for reproducible builds. Commit it for binaries; for libraries, leave it out so downstream users resolve their own versions.

Workspaces

For multi-crate projects:

# Cargo.toml at workspace root
[workspace]
members = ["crates/core", "crates/cli", "crates/server"]

[workspace.dependencies]
serde = "1"
tokio = "1"

Member crates inherit workspace deps via serde.workspace = true. This keeps versions in one place.

Cargo Features

Conditional compilation:

[features]
default = ["json"]
json = ["dep:serde_json"]
yaml = ["dep:serde_yaml"]
#[cfg(feature = "json")]
fn load_json() { ... }

Features are additive — disabling json removes the json module from compilation. This is how optional dependencies work.

Build Profiles

Cargo has four profiles: dev, release, test, bench.

Profile opt-level debug Incremental
dev 0 yes yes
release 3 no no

Use cargo build --release for benchmarks and production. The default dev build prioritizes compile speed over runtime speed.

Guided Checkpoint

Create a new binary crate, build it in release mode, and verify the binary runs independently:

mkdir -p /tmp/rust-checkpoint && cd /tmp/rust-checkpoint
cargo init --name checkpoint

cat > src/main.rs <<'EOF'
fn main() {
    println!("Rust binary running — release build verified.");
}
EOF

cargo build --release
ls -la target/release/checkpoint
file target/release/checkpoint  # should show "executable" and architecture
./target/release/checkpoint    # outputs the message

3. Anti-Patterns & Common Pitfalls

These failure modes appear repeatedly in Rust codebases. Each is detectable, preventable, and rooted in a misunderstanding of the borrow checker or ownership rules.

Anti-Pattern: Cloning Instead of Borrowing

Using .clone() when a reference would work avoids the borrow checker but creates unnecessary allocations and copies:

// Bad: unnecessary clone of every string
fn process_users(users: Vec<String>) {
    for user in users.clone() {  // clones the entire vector
        print!("{}", user);
    }
}

// Good: borrow the vector
fn process_users(users: &[String]) {
    for user in users {
        print!("{}", user);
    }
}

Fix: prefer borrowing (&T) over cloning when the caller does not need ownership of the result. Clone only when the data must outlive the borrow scope.

Anti-Pattern: Fighting the Borrow Checker

Trying to hold both mutable and immutable references to the same data at the same time produces compile errors. The fix is usually restructuring, not disabling checks:

// Bad: wants to modify and read the same data simultaneously
let mut s = String::from("hello");
let r1 = &s;
let r2 = &mut s;  // ERROR: mutable borrow conflicts with r1

Fix: reorder the operations so the immutable borrow ends before the mutable borrow begins. Use the let scopes to make this explicit:

// Good: separate scopes by ordering
let mut s = String::from("hello");
{
    let r1 = &s;
    println!("{}", r1);
}  // r1's borrow ends here
let r2 = &mut s;
r2.push_str(", world!");

Anti-Pattern: Panic on Routine Errors

Using .unwrap() or .expect() for errors that are expected (missing files, parse errors, network timeouts) makes the program brittle:

// Bad: crashes on any missing file
let config = std::fs::read_to_string("config.toml").unwrap();

// Good: returns an error to the caller
fn load_config(path: &str) -> Result<String, std::io::Error> {
    std::fs::read_to_string(path)
}

Fix: use ? to propagate errors, match to handle specific error cases, and reserve unwrap() for genuinely impossible conditions (like a hardcoded path in a test).

Anti-Pattern: Over-Restricting Lifetimes

Over-annotating lifetimes with 'static or overly long lifetimes makes APIs harder to use:

// Over-restricted: requires the data to live forever
fn longest<'a>(s1: &'a str, s2: &'a str) -> &'a str { ... }

// More flexible: allows shorter lifetimes as needed
fn longest<'a>(s1: &'a str, s2: &'a str) -> &'a str { ... }

Fix: rely on lifetime elision where possible. Write lifetime annotations only when the compiler requires them (usually when a function takes multiple reference inputs and returns one of them).


4. Independent Challenge

Implement a safe, concurrent bounded buffer (ring buffer) for byte streams.

Without step-by-step guidance, produce:

  1. A RingBuffer struct with a fixed capacity (provided at construction, must be a power of 2).
  2. A concurrent push(data: &[u8]) -> usize method that writes up to the available capacity and returns how many bytes were written.
  3. A concurrent pop() -> Vec<u8> method that reads all available bytes and removes them from the buffer.
  4. A len() method that reports the number of bytes currently buffered.
  5. Use std::sync::Arc, std::sync::Mutex, or a lock-free approach with std::sync::atomic.
  6. No data races: go test -race must pass on a multi-threaded test.

Constraints:

Deliverable: a ring.rs file with tests. Verify with cargo test -- --nocapture and cargo test -- --race. No solution is provided.


5. Consolidation & Key Invariants


6. Next Steps

Next Article: nodejs-runtime — see how V8 differs from a compiled language runtime, or cpp-basics for a comparison with manual memory management.


Content depth matches kb/containers/docker-concepts.md — production-level patterns, real syntax, and practical examples.

Change Log

Date Change
2026-09-15 Migrated to canonical 6-section template (Architectural Overview, Deep Dive with Guided Checkpoint, Anti-Patterns, Independent Challenge, Consolidation, Next Steps)
2026-08-29 Full hydration of Rust Basics lesson
2026-08-29 Added sections: Why Rust, Ownership, Borrowing, Lifetimes, Pattern Matching, Error Handling, Cargo
2026-09-15 Added Next Steps links, preserved prerequisites and tags

Choose Theme

Your selection is saved locally.

Neural Cacophony
Aperture v2
Flux v1
Mosaic Chaos
Nexus v1
Nexus Zest
Prism v2
Synapse