Docs · Rust, spelled out

Rust, spelled out

A lookup table for the mechanical half of Rust — how the ordinary things are spelled. It assumes you have written programs before, in any language, and that what you are missing is not the idea of a loop but Rust's way of typing one.

Nothing here is graded and nothing here is a lesson. Where a spelling belongs to a concept a course teaches properly, the entry says which lab owns it and stops. Come back to this page when a line in a project brief uses a symbol you have not met; read the lab when you want to know why the symbol exists.

Every snippet on this page was compiled before it was published.

Variables and mutability

let n = 12;              // immutable — this is the default
let mut total = 0;       // `mut` is the only thing that makes it assignable
total += n;
let n: u32 = 12;         // `: T` is a type annotation, and it is usually optional

Different from most languages: a binding you never reassign needs no keyword to say so, and one you do reassign needs mut or the compiler refuses it (E0384). Rust also infers the type of almost every let, so an annotation appears when you want to choose a type rather than accept the inferred one. A name you deliberately do not use starts with an underscore (let _unused = …), which silences the warning.

Lab 01 meets E0384 as one of its six errors.

Functions, and the difference a semicolon makes

fn double(n: usize) -> usize {
    n * 2                // no semicolon: this is the return value
}

fn shout(s: &str) -> String {
    let out = s.to_uppercase();
    out                  // same thing — the last expression is the result
}

Different from most languages: a function body's last expression is its return value, and putting a ; after it throws that value away — which is why a stray semicolon produces "expected usize, found ()". return exists and works anywhere, but idiomatic Rust uses it for early exits only. A function with no -> T returns (), the empty tuple, pronounced "unit".

Parameters always carry their types; there is no inference across a function boundary.

if is an expression

let kind = if n > 16 { "big" } else { "small" };

if n > 16 {
    println!("big");
} else if n > 8 {
    println!("medium");
}

Different from most languages: no parentheses around the condition, braces are never optional, and the condition must be a bool — an integer is not truthy and there is no if (ptr). Because if is an expression it can sit on the right of a let, which is Rust's version of a ternary; both arms must then produce the same type.

The three loops

for i in 0..4 { }             // 0, 1, 2, 3     — `..` excludes the end
for i in 0..=3 { }            // 0, 1, 2, 3     — `..=` includes it
for b in bytes.iter() { }     // each element, by reference
while remaining > 0 { remaining -= 1; }
loop { break; }               // forever, until a `break`

Different from most languages: for only walks an iterator — there is no three-clause for (i = 0; …). A range is a value, so 0..n can be stored, passed and reversed ((0..n).rev()). loop is deliberate rather than while true, and it is the one loop that can carry a value out: let x = loop { break 7; };.

Lab 02 loops over byte offsets; lab 08 replaces most loops with iterator chains, and owns that subject.

match, at spelling level

let name = match byte {
    0x0d => "table leaf",
    0x05 => "table interior",
    other => return Err(other),   // an arm may bind the value it matched
};

if let Some(v) = maybe_value { println!("{v}"); }
while let Some(item) = stack.pop() { drop(item); }
assert!(matches!(err, MyError::TooShort { .. }));

Different from most languages: match is an expression, so every arm has to produce the same type; arms are pattern => value, with a comma, not case:; and there is no fall-through. It is also exhaustive — the compiler refuses a match that does not cover every case. if let is a one-arm match, while let loops while a pattern keeps fitting, and matches!(v, P) is a bool you can put in an assert!.

Lab 05 owns match — why exhaustiveness is the feature, and what a catch-all _ costs you.

Arrays, Vec, and slices

let fixed: [u8; 4] = [1, 2, 3, 4];   // length is part of the type
let mut grown: Vec<u8> = Vec::new(); // heap, growable
grown.push(1);
let zeros = vec![0u8; 32];           // the `vec!` macro: 32 zero bytes
let view: &[u8] = &fixed[1..3];      // a slice — a borrowed window, no copy
let maybe = fixed.get(1..3);         // Option<&[u8]> — None instead of a panic

Different from most languages: [T; N] and Vec<T> are different types — the first has its length in the type and lives inline, the second is a heap buffer you can grow. A slice &[T] is a view into either one: a pointer and a length, owning nothing and copying nothing. Indexing with [a..b] panics when the range is out of bounds; .get(a..b) hands back Option instead, which is the reflex this course builds in lab 02.

Lab 03 owns owned-vs-borrowed; lab 07 owns slices and their lifetimes; lab 08 owns Vec and HashMap.

String and &str

let owned: String = String::from("orders");
let borrowed: &str = &owned;          // a view into the same bytes
let copied: String = borrowed.to_string();   // allocates
let n = owned.len();                  // BYTES, not characters
let mut line = String::new();
line.push_str("order");              // append text (&str)
line.push('!');                      // append ONE character (char)

Different from most languages: Rust has two string types because it has two answers to "who frees this?". String owns a growable UTF-8 buffer; &str is a borrowed window into one (a literal like "orders" is a &'static str baked into your binary). Passing &str copies nothing; .to_string() and String::from copy. .len() counts bytes, always — "é".len() is 2 — because that is the only length that is free to compute. Appending splits the same way: .push_str takes text, .push takes a single char'x' and "x" are different types.

Lab 03 introduces the owner/view pair; lab 07 owns &str and UTF-8 validation.

Tuples, and taking them apart

let pair: (i64, usize) = (187, 2);
let (value, consumed) = pair;         // destructuring — two names, one line
let first = pair.0;                   // field access is by position
fn split(n: usize) -> (usize, usize) { (n / 2, n % 2) }

Different from most languages: a tuple is an ordinary anonymous type, so a function that has two things to say returns (A, B) rather than an out parameter or a small struct. You take one apart by writing the shape on the left of the =; you reach into one by position, .0 and .1, never [0]. The empty tuple () is the unit type — what a function returns when it returns nothing.

Lab 02's varint returns Option<(i64, usize)> — the value and how many bytes it consumed — and every later lab reads it with a destructuring let.

::, use, and modules

use std::collections::HashMap;   // now `HashMap` names the type
let m: HashMap<&str, usize> = HashMap::new();
let v = u16::from_be_bytes([0x10, 0x00]);   // an associated function
std::process::exit(0);           // or spell the whole path inline
pub mod outer {                  // a module written inline, in braces
    pub fn f() -> u8 { 1 }
}

Different from most languages: :: walks namespaces — crates, modules, types — while . walks values. So it is HashMap::new() (a function belonging to the type) and map.len() (a method on a value you hold). use only creates a shorter name; it never "imports" code, and the full path keeps working without it.

A module is either inlinemod name { … }, braces — or a filemod name;, semicolon, which makes the compiler look for name.rs beside the current file. Writing the semicolon form when you meant the braces form is a "file not found for module" error, not a syntax error.

Lab 01 wires the first module in with pub mod warmup;; lab 11 owns crate structure and re-exports.

struct, impl, and methods

pub struct Header {
    pub page_size: u32,          // `pub` on the struct AND on each field
    count: u32,                  // no `pub`: private outside this module
}

impl Header {
    pub fn new(page_size: u32, count: u32) -> Self {
        Self { page_size, count }        // field-init shorthand: name == value
    }
    pub fn count(&self) -> u32 { self.count }   // a METHOD: takes self
    pub fn zero() -> Self { Self::new(0, 0) }   // ASSOCIATED: no self
}

Different from most languages: fields and behaviour are declared apart — a struct block holds the data, one or more impl blocks hold the functions. There is no constructor keyword: new is a plain associated function by convention, called as Header::new(…). Anything whose first parameter is self (usually &self) is a method and gets the h.count() dot; everything else is reached with ::. Self is shorthand for the type's own name. Visibility is per item and private by default, so pub struct with no pub fields publishes a type nobody outside can build a literal of.

Lab 03 asks you to write your first struct and impl; lab 04 owns the subject — associated function vs method, and which derives a type may have.

enum, Option and Result — the spelling

pub enum PageKind { Leaf, Interior, Other(u8) }   // a variant may carry data

let k = PageKind::Other(0x53);
let found: Option<u8> = Some(3);      // or None
let parsed: Result<u8, String> = Ok(3);   // or Err(…)

Different from most languages: a variant is not a number with a nice name — it is a shape, and different variants can carry different data. Option<T> and Result<T, E> are ordinary enums written this way in the standard library, not compiler magic, and Some/None/Ok/Err are their variant names. There is no null, so "might not be there" is spelled in the type.

Lab 05 owns enums and match; lab 06 owns Result and designing an error type.

Attributes and derives

#[derive(Debug, Clone, Copy, PartialEq)]
struct Point { x: i32, y: i32 }

#[cfg(test)]                 // compile this only for `cargo test`
mod tests {}

Different from most languages: #[…] attaches to the item below it, and that is the whole syntax — #[derive(…)] asks the compiler to write an implementation for you, #[test] marks a test, #[cfg(…)] compiles an item conditionally. The rarer #![…], with the bang, applies to the file it opens rather than to the next item.

The test-module ritual

pub fn double(n: usize) -> usize { n * 2 }

#[cfg(test)]
mod tests {
    use super::*;                    // pull the parent module's items into scope

    #[test]
    fn double_doubles() {
        assert_eq!(double(4), 8);            // equality, printing both sides
        assert!(double(0) == 0, "zero");     // any bool, with an optional message
        assert_ne!(double(1), 1);
    }
}

Different from most languages: unit tests live inside the file they exercise, in a child module marked #[cfg(test)] so it is compiled only when testing. That child module is a fresh scope, which is why the first line inside it is nearly always use super::*; — without it, nothing from the file around it is visible and every reference is "cannot find … in this scope" (E0425). A test fails by panicking, so assert!/assert_eq! are the whole vocabulary; assert_eq! prints both values when it fails, which is why it beats assert!(a == b).

A #[test] function may also return Result<(), E>, in which case ? works inside it and an Err fails the test.

Every lab in this course is graded on tests you write this way, starting with lab 01.

Doc comments and doc examples

/// One line describing the item below.
///
/// ```
/// assert_eq!(2 + 2, 4);
/// ```
pub struct Documented;

/// An example that must FAIL to compile.
///
/// ```compile_fail,E0384
/// let x = 1;
/// x = 2;
/// ```
pub struct Rejected;

Different from most languages: /// documents the item that follows and //! documents the file or module it sits at the top of — both are markdown. A fenced code block inside one is a doctest: cargo test compiles and runs it as if a stranger had written it against your crate, so it must reach your items through use yourcrate::…. Tagging the fence compile_fail inverts the test — it passes when the code is rejected. pub struct Documented; with no braces is a unit struct: a type with no fields, which is exactly what you want when the item exists only to hang a doc example on.

Labs 03, 06, 07 and 10 each score a pair of these.

Printing and format strings

let n = 4096;
println!("page size: {n}");           // capture a name directly
println!("page size: {}", n);         // or pass it positionally
println!("{n:08x}");                  // 00001000 — width, zero-pad, lowercase hex
eprintln!("error: could not read");   // stderr, not stdout
print!("no newline");
let s: String = format!("{n} bytes"); // the same machinery, into a String
println!("{:?}", (1, 2));             // Debug — for you, not for a user

Different from most languages: the format string must be a literal (the compiler reads it), and {} asks for the Display of a value while {:?} asks for its Debug. A type gets {} only if somebody wrote a Display for it; {:?} usually comes from #[derive(Debug)]. {name} capturing a variable of that name is the modern spelling and needs no argument list.

Lab 04 owns Display; lab 11 owns stdout-versus-stderr and exit codes.

Closures

let limit = 2048;
let inside = |p: u16| p < limit;      // captures `limit` from around it
let evens: Vec<u16> = vec![2, 3, 4].into_iter().filter(|n| n % 2 == 0).collect();
let read = std::fs::read("x").unwrap_or_else(|_e| Vec::new());

Different from most languages: the parameter list goes between pipes, the types are usually inferred, and a one-expression body needs no braces. A closure captures the variables it mentions, so it can be passed to a method like filter or unwrap_or_else without you writing a callback signature.

Lab 08 owns closures and iterator chains.

Operators and sigils you will meet

spelling what it does
&x, &mut x borrow x — shared, or exclusive
&T, &mut T the types of those borrows
*p read through a reference (rarely needed — the dot does it for you)
expr? on Err/None, return it from this function; otherwise unwrap it
x as u8 a cast that keeps the low bits and never complains
name!(…) a macro, not a function — println!, vec!, assert_eq!
todo!() a body you haven't written yet — the file compiles, and panics if that code ever runs
A | B (in a pattern) either pattern — one match arm handles both cases
_ "I do not care": an ignored binding, or a catch-all match arm
'a a lifetime name, on a type that borrows
::<T> the turbofish — name a generic when nothing else can be inferred

Lab 06 owns ? and error types; lab 07 owns lifetimes; lab 09 owns generics and the turbofish.

Numeric literals

let a = 0x0d;            // hex
let b = 0b1000_0000;     // binary; `_` is a separator anywhere in a literal
let c = 12_288;
let d = 0u8;             // a suffix pins the type
let e: i64 = i64::MAX;   // constants live on the type

Different from most languages: the type of a bare literal is inferred from how you use it, defaulting to i32, and a suffix (0u8, 1i64) pins it when nothing else does. Widths are explicit in every integer type — u8 u16 u32 u64 and their i twins — and usize is the one whose width follows the machine, which is what indexes and lengths use.

Lab 02 owns integer widths, as, and byte order.