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 inline — mod name { … }, braces — or a file —
mod 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.