What is Rust, and why should you care?
Rust is a systems programming language that runs close to the metal, has no garbage collector, compiles to native machine code — and yet guarantees memory safety at compile time. That combination was considered impossible for 40 years.
Let's start at the very beginning. Every program you run is ultimately a set of instructions for a CPU and a set of bytes in memory. Most languages sit far above that reality: Python, JavaScript and Java run inside an interpreter or virtual machine that manages memory for you (a garbage collector, or GC). That's wonderfully convenient — and it costs you speed, memory, and predictable timing. C and C++ sit right on the metal and are blazing fast, but they hand you the responsibility of never freeing memory twice, never writing past the end of an array, and never letting two threads touch the same variable at once. Get it wrong and your program leaks, crashes, or gets exploited by an attacker.
Rust refuses that trade-off. It gives you C-level control over memory layout, allocation and instructions, while a strictly-typed compiler proves — before your program ever runs — that you have no use-after-free, no double-free, no data races, and no null pointer dereferences. If it compiles, an entire class of the world's most damaging bugs simply cannot exist.
The three pillars of Rust
Safety
No null, no dangling pointers, no buffer overflows, no data races — all rejected at compile time. Memory is freed deterministically the moment a value goes out of scope, via Drop.
Speed
Zero-cost abstractions: high-level expressiveness that compiles down to the same assembly a C programmer would write by hand. No runtime, no GC pauses, no hidden allocations.
Fearless concurrency
The type system knows which values can safely cross thread boundaries (Send / Sync). The compiler rejects data races the same way it rejects type errors.
Rust is "C++ but the compiler refuses to let you shoot yourself in the foot, and doesn't charge you for the privilege." It's a language built around a single idea: you should not have to choose between control and correctness.
A short history
A side project at Mozilla
Graydon Hoare starts designing Rust as a personal project, frustrated by crashes caused by memory errors in browsers. The name comes from a group of fungi — puccinia rust — known for being stubbornly hard to eradicate.
Mozilla adopts it for Servo
Mozilla sponsors the language officially and builds the Servo browser engine to prove Rust could replace C++ in performance-critical infrastructure. The famous borrow checker arrives during this era and reshapes the whole design.
Rust 1.0 — stability guaranteed
The first stable release. From this day forward, code written for Rust 1.0 still compiles on modern Rust. A strict backwards-compatibility promise that the project has kept ever since.
Editions keep the language evolving
Rust uses editions — opt-in language versions — so new ergonomics can land without breaking old code. Different crates in one project can even use different editions.
The industry switches on
Rust enters the Linux kernel, Android, Windows core libraries, and AWS's firecracker. Rust Foundation founded by Mozilla, AWS, Google, Huawei, Microsoft. From "interesting experiment" to critical infrastructure.
Everywhere performance and reliability matter
Operating systems, browsers, game engines, embedded devices, WebAssembly, CLI tools, databases (ClickHouse, SurrealDB, TiKV), proxies (Cloudflare Pingora), and half the tools developers use daily: ripgrep, fd, bat, exa, diesel, tokio.
Where Rust sits in the language landscape
Every language lives somewhere on two axes: how close it runs to the hardware (raw performance and control) and how much the language protects you (safety, ergonomics, abstraction). Rust's whole thesis is that these two axes should not be a trade-off.
Illustrative positioning, not a measurement. C and C++ sit at maximum control but low built-in protection; managed languages sit high on protection but pay a runtime cost. Rust is deliberately alone in the top-right corner.
What makes Rust's guarantee technically possible
Ownership system
Every value has exactly one owner. When the owner goes out of scope, the value is dropped (freed). No reference counting in most code, no GC, no manual free().
Borrow checker
References must always be valid. At any moment you may have either one mutable reference or any number of shared references — never both. This is what kills data races at compile time.
No null references
Tony Hoare called null references his "billion-dollar mistake". Rust has no null. Absence is expressed explicitly with Option<T>, and the compiler forces you to handle the "nothing" case.
Algebraic types & exhaustiveness
enum can carry data, and match must cover every possible case. Add a new variant and the compiler points at every place that must now deal with it.
Deterministic destruction (RAII)
Resources are released at a known point in the program, not whenever a GC feels like it. Files close, locks unlock, memory frees — exactly at the end of the scope.
Zero-cost abstractions
Generics are monomorphized (specialised per type at compile time), iterators inline into loops, and closures become plain function pointers. "What you don't use, you don't pay for. What you do use, you couldn't hand-code any better."
Rust in the real world
Rust is strict, not hard. The difficulty is front-loaded: the first few weeks feel like arguing with a very opinionated compiler. But those rules are exactly the rules you were silently violating in C++ or debugging for days in Python. After the mental model clicks, most developers report that Rust code works the first time it compiles, and the compiler becomes the best documentation they've ever had.
Compare: C++ is "easy to write, extremely hard to write correctly". Rust is "hard to write for 3 weeks, then dramatically easier to keep correct forever."
How Rust compares at a glance
| Property | Rust | C / C++ | Go | Java / C# | Python / JS |
|---|---|---|---|---|---|
| Memory management | Ownership + Drop, deterministic | Manual (malloc/free, RAII) | Garbage collector | Garbage collector | Reference counting / GC |
| Null pointers | Impossible (Option<T>) | Ubiquitous | Zero-values + nil | null everywhere | null/undefined |
| Data races | Compile error | Undefined behaviour | Runtime detector (race detector) | Runtime (locks required) | GIL / event loop hides it |
| Typical speed | Native, ~C++ level | Native, fastest tier | Fast (≈1.5–2.5× C) | Fast after JIT warm-up | 10–100× slower |
| Startup time | Milliseconds | Milliseconds | Milliseconds | 100s of ms (JVM warm-up) | Fast, but slow execution |
| Runtime / VM | None | None | Small runtime + scheduler | Full VM / CLR | Interpreter / V8 |
| Compile speed | Slow (incremental is good) | Slow (C++ headers) | Very fast | Moderate | No compilation |
| Concurrency model | Threads + async/await, typed | Threads (raw), no safety | Goroutines + channels | Threads, virtual threads | Async / single-threaded |
| Error handling | Result + ?, typed | Exceptions / error codes | Multiple return values | Exceptions | Exceptions |
| Package manager | cargo + crates.io | Fragmented | go modules | Maven / NuGet | pip / npm |
| Learning curve | Steep first 3 weeks | Moderate to learn, hard to master | Gentle | Moderate | Gentle |
| Binary size (hello world) | ~300 KB–2 MB | ~10–30 KB (C) | ~7 MB | Requires runtime | Requires interpreter |
| Table values are typical, order-of-magnitude comparisons. Details and full benchmarks arrive in Chapter 17. | |||||
Installing Rust & your first program
Rust ships with one of the best toolchains in any language: rustup (installer/updater), rustc (the compiler), and cargo (build system, package manager, test runner and task runner — all in one).
Step 1 — install
On Linux or macOS, open a terminal and run:
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
On Windows, download rustup-init.exe from rustup.rs — it will also install the Visual Studio C++ Build Tools if needed. Then verify everything works:
rustc --version
cargo --version
rustup update # upgrade to the latest stable, anytime
rustup default stable # or: stable / beta / nightly
rustup component add clippy rustfmt rust-src
You will almost never call rustc directly. cargo is the command you live in — it calls rustc for you, tracks dependencies, resolves versions, and keeps build artifacts in a target/ folder so rebuilds are incremental and fast. rustup manages the toolchain versions themselves (stable / beta / nightly, per-project if needed).
Step 2 — hello, world
Create a project. This makes a folder with a git repo, a Cargo.toml manifest, and a src/ directory:
cargo new hello_rust
cd hello_rust
cargo run
That last line compiles and runs the program, printing Hello, world!. Now let's understand every character of that file.
fn main() {
println!("Hello, world!");
}
fn name(params) -> RetType { body }. Every executable Rust program starts at the function called main — no exceptions.
(), the "unit" type — Rust's version of void).
rustfmt).
! means this is a macro, not a function. Macros generate code at compile time. println! formats text and writes a line to standard output.
Step 3 — how a cargo project is laid out
hello_rust/
├── Cargo.toml # the manifest: name, version, dependencies, metadata
├── Cargo.lock # exact resolved versions (commit this for binaries!)
├── .gitignore # ignores target/
├── src/
│ ├── main.rs # binary crate root — this is your program
│ └── lib.rs # (optional) library crate root
├── tests/ # integration tests (black-box, one file = one test binary)
├── examples/ # small runnable examples: cargo run --example foo
├── benches/ # benchmarks (criterion)
└── target/ # ALL build output. never edit, safe to delete
[package]
name = "hello_rust"
version = "0.1.0"
edition = "2021" # language version: 2015 | 2018 | 2021 | 2024
[dependencies]
serde = { version = "1.0", features = ["derive"] }
rand = "0.8"
[dev-dependencies]
criterion = "0.5" # only used by tests & benchmarks
[profile.release]
opt-level = 3 # maximum optimisation
lto = true # link-time optimisation
codegen-units = 1
panic = "abort"
Every cargo command you'll actually use
| Command | What it does | When you use it |
|---|---|---|
cargo new name | Creates a new binary project | Starting something |
cargo init | Initialises cargo in an existing folder | Adopting cargo |
cargo build | Compiles to target/debug | Checking it compiles |
cargo build --release | Compiles optimised, to target/release | Shipping / benchmarking |
cargo run | Build + execute | Every 30 seconds, constantly |
cargo check | Type-checks without code generation (much faster) | The inner loop while coding |
cargo test | Runs unit, integration and doc tests | After every change |
cargo doc --open | Builds & opens local docs for your deps | Learning an API offline |
cargo clippy | 500+ lints — the "senior dev" reviewing your code | Before every commit |
cargo fmt | Formats all code to the official style | Before every commit |
cargo add crate | Adds a dependency to Cargo.toml | Pulling in a library |
cargo update | Updates Cargo.lock within semver limits | Refreshing deps |
cargo bench | Runs benchmarks | Measuring performance |
cargo tree | Prints the dependency graph | Debugging bloat / conflicts |
cargo expand | Shows what your macros generate | Debugging macros / derive |
cargo clean | Deletes target/ | Rarely — when something is cursed |
Make it interactive
use std::io; // bring the I/O module into scope
fn main() {
println!("What is your name?");
let mut name = String::new(); // a growable, heap-allocated string
io::stdin()
.read_line(&mut name) // &mut = "please fill THIS in"
.expect("failed to read line"); // handle the error (see Chapter 09)
let name = name.trim(); // shadow the variable with a cleaned version
println!("Hello, {name}! Welcome to Rust."); // inline variable interpolation
}
The Rust Playground runs Rust entirely in your browser — including compiling with Release mode, viewing the generated assembly, and running benchmarks. Perfect for experimenting with every example in this handbook.
Set up your editor (do this once)
VS Code
Install the rust-analyzer extension. You get inline types, "implement missing trait" quick-fixes, borrow-checker hints, and go-to-definition across crates.
Neovim / Helix / Emacs
Use the rust-analyzer language server binary directly (installed via rustup component add rust-analyzer) with your LSP client.
RustRover (JetBrains)
A full IDE dedicated to Rust with first-class refactoring, macro expansion and debugger support. Free for non-commercial use.
Variables, mutability & data types
Rust variables are immutable by default. This single decision eliminates entire categories of bugs and lets the compiler optimise aggressively — and you opt in to change with one keyword.
let, mut, const — and why nothing is mutable by default
fn main() {
let x = 5; // immutable: that's it, x is 5 forever
// x = 6; // ❌ compile error E0384: cannot assign twice
let mut y = 5; // explicitly mutable
y = 6; // ✅ fine
println!("y = {y}"); // y = 6
// constants: ALWAYS immutable, must be ANNOTATED, chosen at compile time,
// may be declared anywhere (even at module scope), conventionally SCREAMING_CASE.
const MAX_POINTS: u32 = 100_000;
const SECONDS_IN_DAY: u64 = 24 * 60 * 60;
println!("{MAX_POINTS} / {SECONDS_IN_DAY}");
// static: like const, but has a fixed memory address and can implement Sync.
static GREETING: &str = "hello from a static";
let spaces = " "; // type &str (a string slice) — inferred from the literal
let mut spaces = spaces.len(); // ⚠️ this is a NEW variable (shadowing), type usize
spaces += 1;
println!("{spaces}");
}
Shadowing is not the same as mut
Writing let x = ... a second time does not change the old variable — it creates a brand new one that shadows the previous name. This is extremely common and useful:
fn main() {
let input = " 42 ";
let input = input.trim(); // now a &str without whitespace
let input: u32 = input.parse().expect("not a number"); // now a number!
println!("{input} + 1 = {}", input + 1); // 42 + 1 = 43
// Shadowing lets you CHANGE TYPE. `mut` does not:
let spaces = " ";
// let spaces = spaces.len(); // ✅ allowed: new variable, new type
let mut sp = " ";
// sp = sp.len(); // ❌ E0308: expected &str, found usize
}
mut when…- You genuinely need to modify the same value in place
- You're accumulating into a
Vec, counter, or buffer - The value's type stays the same
- You're transforming a value (trim, parse, convert)
- You want to keep the original immutable
- You need to change the type of a name
The primitive types
Integers — sized explicitly, never ambiguous
| Type | Bits | Range (signed) | Typical use |
|---|---|---|---|
i8 / u8 | 8 | −128…127 / 0…255 | Bytes, raw data, RGB values |
i16 / u16 | 16 | ±32 768 / 0…65 535 | Audio samples, ports |
i32 / u32 | 32 | ±2.1 billion / 0…4.29 billion | Default integer type |
i64 / u64 | 64 | ±9.2×10¹⁸ / 0…1.8×10¹⁹ | Timestamps, large counters, IDs |
i128 / u128 | 128 | enormous | Crypto, big fixed-point maths |
isize / usize | 32 or 64 | platform pointer size | Array indices, lengths, allocations |
1_000_000. Literals may carry a suffix: 92u8, 3.14f64, 0xFFu32, 0b1010, 0o777.Floats, booleans & characters
fn main() {
// Floating point: f64 is the default (same speed as f32 on modern CPUs, twice precision)
let pi = 3.141592653589793; // f64
let small: f32 = 2.5; // explicit f32 when memory matters (GPU, embedded)
println!("{} {}", pi, 1.0_f64.sqrt());
// Booleans are exactly 1 byte
let is_rust_fun: bool = true;
let nope = false;
// char is a 4-byte Unicode scalar value — NOT a byte!
let letter = 'A';
let emoji = '🦀'; // yes, a char
let heart = '❤';
println!("{letter} {emoji} {heart} — all chars are 4 bytes");
// Arithmetic and explicit casting with `as`
let a: i32 = 7;
let b: i32 = -3;
println!("{} {} {} {}", a + b, a - b, a * b, a / b); // 4 10 21 -2 (integer division!)
println!("remainder: {}", a % b); // 1
println!("7.0 / 2.0 = {}", 7.0 / 2.0); // 3.5 (floats divide normally)
println!("cast: {}", a as f64 / 2.0); // 3.5
println!("bool as int: {}", is_rust_fun as i32); // 1
}
In a debug build (cargo build), overflowing an integer panics — your program stops with a clear message. In a release build (--release), it wraps around silently (255 + 1 → 0 for u8), matching C behaviour and enabling maximum performance. This difference surprises people — always test both.
When you want defined behaviour in every profile, use the checked/wrapping/saturating APIs: x.checked_add(1) → Option, x.wrapping_add(1) → always wraps, x.saturating_add(1) → clamps at the limit, x.overflowing_add(1) → (value, did_overflow).
Compound types
Tuples — fixed-size groups of mixed types
fn main() {
let point: (i32, f64, char) = (10, 2.5, 'x');
// Destructure with a pattern:
let (x, y, z) = point;
println!("{x} {y} {z}");
// Or access with zero-based dot indices:
println!("{} {}", point.0, point.2);
// The empty tuple () is the "unit type" — it means "no meaningful value"
let nothing: () = ();
println!("() occupies {} bytes", std::mem::size_of_val(¬hing)); // 0 bytes
// A 1-tuple needs a trailing comma:
let one = (42,);
println!("{:?}", one);
}
Arrays vs slices vs Vec — know the difference
fn main() {
// ARRAY: fixed length, lives on the stack, one contiguous block, type [T; N]
let months: [&str; 12] = ["Jan","Feb","Mar","Apr","May","Jun",
"Jul","Aug","Sep","Oct","Nov","Dec"];
let zeros = [0; 5]; // [0, 0, 0, 0, 0] — repeat initialiser
println!("{months:?} {zeros:?}");
// Indexing is CHECKED at runtime — out of bounds panics instead of corrupting memory
println!("{}", months[2]); // "Mar"
// println!("{}", months[99]); // 💥 panic: index out of bounds: len is 12, index is 99
// Prefer .get() when the index might be invalid — it returns Option<&T>, never panics
match months.get(99) {
Some(m) => println!("{m}"),
None => println!("no month at that index"),
}
// SLICE: a *view* into part of an array/string — just (pointer, length), 2 words
let slice = &months[1..4]; // ["Feb", "Mar", "Apr"]
println!("{slice:?} len={}", slice.len());
println!("size of the slice handle itself: {}", std::mem::size_of_val(&slice)); // 16 on 64-bit (ptr + length)
// Vec<T>: heap-allocated, growable. See Chapter 08.
let mut nums: Vec<i32> = vec![1, 2, 3];
nums.push(4);
println!("{nums:?}");
}
Stack — fast, automatic, fixed size
Heap — flexible, slower, explicitly managed
Key insight: String and Vec<T> are fat handles that live on the stack and point at data on the heap. That's why passing them around brings ownership questions — which is exactly what Chapter 05 solves.
Type inference & annotations
Rust is statically and strongly typed, but it infers most types so you rarely write them. You only annotate when:
- Declaring
constorstaticitems (always required) - Function parameters and return types (always required — this is a deliberate API design choice)
- The type can't be deduced:
let x: u64 = "42".parse().unwrap(); - You want to pick a specific numeric type:
let n: u8 = 200; - Using the turbofish:
let v: Vec<i32> = (0..5).collect();or.collect::<Vec<i32>>()
7 / 2 equals 3, not 3.5 — both operands are integers. To get a float, cast explicitly: 7.0 / 2.0 or 7 as f64 / 2.0. This catches beginners constantly.
Functions & control flow
Rust is an expression-oriented language. Almost everything returns a value — including if, match, and plain blocks { }. Once this clicks, Rust code becomes remarkably elegant.
Function anatomy
// ┌── fn keyword ┌─ parameter: name: Type (ANNOTATIONS REQUIRED)
// │ │
fn add(a: i32, b: i32) -> i32 {
a + b // ← no semicolon: this is an EXPRESSION, returned automatically
}
fn shout(text: &str) -> String { // return type after the arrow
format!("{text}!!!") // last expression is the return value
}
fn do_something() { // no -> means it returns () (the unit type)
println!("side effect only");
// an implicit `; ()` is appended here
}
fn early_return(x: i32) -> i32 {
if x < 0 {
return 0; // explicit early return with `return` keyword
}
x * 2 // tail expression
}
// Never returns (diverging function) — used for panic!, exit(), infinite loops
fn never() -> ! {
panic!("this function never returns a value")
}
fn main() {
println!("{}", add(2, 3)); // 5
println!("{}", shout("hello")); // hello!!!
do_something();
println!("{}", early_return(-7)); // 0
}
Statements vs expressions — the rule that trips everyone up
fn main() {
// A STATEMENT performs an action and returns () — it does NOT return a value
let y = 6; // statement
// AN EXPRESSION evaluates to a value
let z = { // a block is an expression!
let inner = 3 * 2;
inner + 1 // ← no semicolon → this block evaluates to 7
};
println!("z = {z}"); // 7
// Adding a semicolon turns an expression into a statement returning ()
let w = { 5; }; // type is () — the 5 is thrown away
println!("w = {w:?}"); // ()
// ❌ This is the classic beginner error:
// let bad = (let x = 5); // error: `let` is a statement, not an expression
}
if / else as an expression
fn main() {
let temperature = 28;
// Conditions MUST be bool — Rust never coerces numbers/strings to boolean
if temperature > 25 {
println!("hot");
} else if temperature > 10 {
println!("pleasant");
} else {
println!("cold");
}
// if temperature { } // ❌ E0308: expected `bool`, found integer
// Use `if` as an expression to choose a value — no ternary operator needed
let mood = if temperature > 25 { "melting" } else { "fine" };
println!("{mood}");
// Both branches must have the SAME type
let n = if temperature > 25 { 10 } else { "ten" }; // ❌ E0308: mismatched types
}
Loops — three flavours, all with a twist
fn main() {
// ── loop: repeats forever… but can RETURN A VALUE ────────────────────
let mut counter = 0;
let result = loop {
counter += 1;
if counter == 10 {
break counter * 2; // break with a value → the loop evaluates to 20
}
};
println!("result = {result}"); // 20
// ── while: conditional looping ───────────────────────────────────────
let mut n = 3;
while n != 0 {
println!("{n}!");
n -= 1;
}
println!("LIFTOFF 🚀");
// ── for: iterate over anything (ranges, arrays, iterators) ───────────
for i in 0..4 { // 0,1,2,3 (end is exclusive)
print!("{i} ");
}
println!();
for i in 0..=4 { // 0,1,2,3,4 (inclusive)
print!("{i} ");
}
println!();
for (index, letter) in ["a","b","c"].iter().enumerate() {
print!("[{index}]={letter} ");
}
println!();
// for loops over ITERATORS are the idiomatic Rust way (and they're optimised
// to exactly the same machine code as the equivalent `while` loop)
let sum: i32 = (1..=100).sum();
println!("sum 1..=100 = {sum}"); // 5050
// ── labelled breaks: escape nested loops ────────────────────────────
'outer: for x in 0..5 {
for y in 0..5 {
if x + y == 6 { break 'outer; } // exits BOTH loops
if y == 2 { continue 'outer; } // continues the OUTER loop
}
}
}
loop
Infinite by design. The only way to return a value from a loop. Compilers can't always prove a while terminates, but they know loop is intentional.
while
Standard conditional loop. Note: Rust has no do...while — use loop { ...; if !cond { break } }.
for
Always prefer this. It consumes an iterator, is bounds-safe, and cannot produce an off-by-one bug the way C's for(i=0;i<=n;i++) can.
A for loop over an iterator is not slower than a manual index loop — in release builds LLVM inlines the iterator calls and produces identical machine code, usually faster because bounds checks are eliminated when the compiler can prove the index is safe. This is the "zero-cost abstraction" principle in action.
Ownership & borrowing — the heart of Rust
This is the chapter that makes Rust Rust. If you understand ownership and borrowing, you understand 80% of what makes the language unique. Read it slowly. Re-read it. Everyone does.
The problem ownership solves
Every program needs to decide who frees memory, and when. History has given us exactly two mainstream answers, and both are flawed:
Manual (C / C++)
Maximum speed, maximum risk. You call malloc and free yourself. Forget one → memory leak. Free twice → heap corruption. Use after freeing → security hole exploited by attackers. These are the #1 cause of critical CVEs.
Garbage collection (Java, Go, Python, JS)
Convenient, costly. A background process finds unused memory. But it consumes 10–30% of your CPU and RAM, causes unpredictable latency pauses at the worst possible moment, and still doesn't prevent logic bugs or data races.
Ownership (Rust)
Fast and safe. A set of three compile-time rules determines exactly when each value is freed. No runtime cost. No leaks. No double-free. No pauses. The compiler checks it all before the program exists.
The three rules of ownership
Rule 3 in action: deterministic destruction
fn main() {
{ // s is not yet valid
let s = String::from("hello"); // s is valid from here onward
println!("{s}"); // use s — it's alive
} // ← scope ends: `drop(s)` is called HERE automatically
// s is no longer valid — trying to use it is a compile error
println!("s is gone, and the memory was already freed.");
}
The call to drop happens at a precise, known point in your program — the closing brace. Not "sometime later when the GC runs". This predictability is why Rust is used for real-time audio, game engines, and operating systems.
Rule 2 in action: move semantics
Here is the moment most beginners stare at the screen. Some values (like numbers) can be trivially duplicated; others (like String) own a chunk of heap memory and cannot.
let a = 5;
let b = a; // copy
println!("{a} {b}"); // both validi32 has the Copy trait. Its size is known and it lives entirely on the stack, so assigning it makes a cheap bitwise duplicate. Both variables remain usable.
let s1 = String::from("hello");
let s2 = s1; // MOVE: s1 is now invalid
println!("{s2}");
println!("{s1}"); // ❌ E0382 borrow of moved valueIf both were allowed to own the heap buffer, both would try to free it → double-free. Rust prevents this by transferring ownership and making s1 a compile error.
Before: let s2 = s1;
After: ownership handed over
Notice: only the 24-byte stack handle is copied; the heap buffer is not duplicated. A move in Rust is nearly free — it's the ownership bookkeeping that changes, not the data.
Which types are Copy?
| Copy (stack-only, cheap to duplicate) | Move (owns a resource) |
|---|---|
All integers (i32, u64…), floats (f64)bool, charTuples of Copy types: (i32, f64)Fixed arrays of Copy types: [i32; 4]References &T (a reference is Copy, but what it points to isn't moved)Option<CopyType>
|
String (owns heap text)Vec<T>, HashMap, Box<T>File, TcpStream, Mutex (own OS resources)Any struct containing the above Any type implementing Drop
|
Copy only if all its fields are Copy — and you cannot implement Copy on a type that implements Drop.Getting around a move: clone() vs Copy
fn main() {
let s1 = String::from("hello");
let s2 = s1.clone(); // DEEP copy of the heap data — explicit and deliberate
println!("{s1} {s2}"); // both valid
// `clone()` is always available on types implementing Clone.
// It is EXPLICIT by design: Rust wants you to know when you're allocating.
// Compare with Python/Java where every assignment may secretly copy or retain.
}
When you can't make the borrow checker happy, the tempting fix is to sprinkle .clone() everywhere. It works — and it silently destroys Rust's performance advantage. Treat every clone as a smell. Ask instead: can I pass a reference? can I restructure so the value is returned instead of copied? Chapter 13 (Rc, Arc, Cow) gives you better tools.
Ownership and functions
Passing a value to a function works exactly like assigning it to a variable: moves it in. Returning a value moves it back out.
fn takes_ownership(s: String) { // s comes into scope: it now owns the String
println!("got: {s}");
} // s is dropped here → memory freed ✅
fn makes_copy(x: i32) { // i32 is Copy, so nothing is moved
println!("got: {x}");
}
fn gives_ownership() -> String { // creates a String and hands it to the caller
String::from("mine now")
}
fn takes_and_gives_back(s: String) -> String { // borrows it, then gives it back
println!("borrowed: {s}");
s
}
fn main() {
let s = String::from("hello");
takes_ownership(s); // s is MOVED into the function
// println!("{s}"); // ❌ E0382: s was moved
let x = 5;
makes_copy(x); // fine — x is Copy
println!("{x}"); // still fine ✅
let s1 = gives_ownership(); // s1 owns the returned String
let s2 = takes_and_gives_back(s1); // s1 moved in, result moved into s2
// println!("{s1}"); // ❌ moved
println!("{s2}"); // ✅
}
Think of a String like a house key: there is exactly one. You can hand it to someone else, but then you no longer have it. You can make a copy of the key (clone), or you can let someone borrow the house temporarily (a reference) — but you can't both give away the key and keep using the house.
Borrowing: references that don't take ownership
Instead of moving a value into a function, you can lend it. A reference &T is a pointer you promise not to abuse. The value is not dropped when the reference goes out of scope, because the reference never owned it.
fn calculate_length(s: &String) -> usize { // s is a reference: it borrows
s.len() // s points to a String it does NOT own
} // s goes out of scope… but nothing is dropped
fn append_greeting(s: &mut String) { // mutable borrow: may modify
s.push_str(", world!");
}
fn main() {
let s1 = String::from("hello");
let len = calculate_length(&s1); // pass a reference — s1 is only borrowed
println!("'{s1}' is {len} chars"); // ✅ s1 is still valid! No move happened
let mut s2 = String::from("hello");
append_greeting(&mut s2); // pass a MUTABLE reference
println!("{s2}"); // hello, world!
// Reference syntax recap:
// &T → shared/immutable borrow ("read-only lens")
// &mut T → exclusive/mutable borrow ("read-write lens")
// *&T → dereference: access the value a reference points to
let n = 42;
let r = &n;
println!("{}", *r + 1); // 43 — explicit deref
println!("{}", r + 1); // 43 — Rust auto-derefs in most contexts
}
The borrowing rules — memorise these
Rule A — the invariant rule
In any given scope you may have either exactly one &mut T reference, or any number of &T references. Never both simultaneously.
Rule B — validity rule
Every reference must always point to valid data. A reference may never outlive the value it points to (no dangling pointers — ever).
Why such a strange rule? Because this is exactly the definition of a data race: two threads reading and writing the same memory with no synchronisation. By making it impossible to hold a mutable and a shared reference at the same time, Rust eliminates data races structurally, at compile time, without any runtime cost.
let s = String::from("hi");
let r1 = &s;
let r2 = &s; // fine: 2 shared borrows
println!("{r1} {r2}");let mut s = String::from("hi");
let r1 = &s;
let r2 = &mut s; // ❌ E0502
println!("{r1}");let mut s = String::from("hi");
let r1 = &mut s;
r1.push('!');
let r2 = &mut s; // ok: r1 is dead
println!("{r2}");let mut s = String::from("hi");
let r1 = &mut s;
let r2 = &mut s; // ❌ E0499
println!("{r1} {r2}");Earlier versions of Rust ended a borrow at the closing brace of the scope. Since Rust 2018, a borrow ends at its last use. That's why the third box above compiles: r1 is never used again, so the borrow is already over when r2 is created. If your code looks like it should work, check whether you're accidentally using a variable after its borrow ends.
Slices: borrows with a length
A slice &[T] or &str is a reference plus a length, pointing at a contiguous run of elements. Slices are the idiomatic way to write "give me part of your data" without taking ownership.
// Taking &str instead of &String is more flexible:
// it accepts string literals, &String, and slices alike.
fn first_word(s: &str) -> &str {
let bytes = s.as_bytes();
for (i, &b) in bytes.iter().enumerate() {
if b == b' ' {
return &s[..i]; // slice from start up to (not including) i
}
}
&s[..] // no space: the whole string
}
fn sum(numbers: &[i32]) -> i32 { // works with arrays AND Vecs
numbers.iter().sum()
}
fn main() {
let sentence = String::from("hello wonderful world");
let word = first_word(&sentence);
println!("first word: {word}"); // hello
let array = [1, 2, 3, 4, 5];
let vector = vec![10, 20, 30];
println!("{}", sum(&array)); // 15 — array auto-coerces to slice
println!("{}", sum(&vector)); // 60 — Vec auto-coerces to slice
// Slicing syntax: &collection[a..b] (b exclusive), &collection[a..=b] (inclusive)
let mid = &array[1..4]; // [2, 3, 4]
println!("{mid:?}");
// ⚠️ Slices must land on valid UTF-8 boundaries for str:
let s = "héllo";
// let bad = &s[0..2]; // 💥 panic: byte index 2 is not a char boundary
println!("{:?}", &s[0..1]); // "h" — safe because é starts at byte 1..3
}
&str over &String in function parametersThis is a real Rust convention you'll see everywhere. &String forces the caller to own a String. &str accepts a literal ("hi"), a String, or any slice of either — it's strictly more general. The same applies to &[T] instead of &Vec<T>. Writing general signatures is a hallmark of idiomatic Rust.
The five most common ownership errors
| Code | Error | Meaning & fix |
|---|---|---|
let s2 = s1; use(s1) | E0382 use of moved value | s1 was moved into s2. Fix: clone(), or use s2, or pass &s1. |
s = 5 where let s | E0384 cannot assign twice | Variables are immutable. Fix: let mut s. |
&s and &mut s at once | E0502 cannot borrow as mutable | Finish using the shared borrow first (NLL ends it at last use). |
two &mut s live at once | E0499 cannot borrow twice | Only one writer at a time. Restructure into sequential blocks. |
let r; { s } r = &s; | E0597 does not live long enough | The reference would dangle. Fix: move the value's declaration outward. |
Lifetimes: keeping references honest
Lifetimes sound scary and are actually simple: they are just labels the compiler uses to prove that no reference will ever point at freed memory. You rarely write them — the compiler usually fills them in for you.
Why do they exist?
Consider a function that returns a reference. Which input does the output borrow from? If the compiler can't tell, it can't prove the returned reference stays valid. A lifetime annotation answers that question:
// 'a is just a NAME for "the region where x and y are valid"
// ┌───┘ ┌───┘
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() { x } else { y }
}
// Reads as: "given two string slices that live AT LEAST as long as 'a,
// return a string slice that also lives at least as long as 'a."
// In other words: the returned reference is valid for as long as BOTH inputs are.
fn main() {
let s1 = String::from("long string");
let result;
{
let s2 = String::from("xyz");
result = longest(s1.as_str(), s2.as_str());
println!("longest inside the block: {result}"); // ✅ fine
}
// println!("{result}"); // ❌ E0597: `s2` does not live long enough
// because 'a had to cover both s1 and s2
}
The whole point: the annotation &'a str doesn't change how your code runs at all. It exists purely so the compiler can prove safety. In release builds there is zero overhead — lifetimes are erased entirely after checking.
When you must write a lifetime
- A function that returns a reference and takes more than one reference parameter.
- A struct that stores references (then the struct itself needs a lifetime parameter).
- Whenever the compiler's help message says
expected named lifetime parameter.
// A struct that borrows data must declare how long that borrow lasts.
// The instance of Excerpt can never outlive the data it points to.
struct Excerpt<'a> {
part: &'a str, // a borrowed string slice
highlight: usize,
}
impl<'a> Excerpt<'a> {
// Lifetime elision: one &self input → the output lifetime is assumed to be self's
fn announce(&self) -> &str {
self.part
}
}
fn main() {
let novel = String::from("Call me Ishmael. Some years ago...");
let first = novel.split('.').next().expect("no sentence");
let e = Excerpt { part: first, highlight: 7 };
println!("{}", e.announce());
} // e drops, then novel drops — order is always reverse of declaration
Lifetime elision rules — why you usually write none
The compiler applies three automatic rules. If they disambiguate every reference, you don't need annotations:
¶m gets its own fresh lifetime: fn f(x: &str, y: &str) becomes fn f<'a,'b>(x: &'a str, y: &'b str).fn f(x: &str) -> &str works with no annotations.&self or &mut self, the output borrows from self.The 'static lifetime
// 'static means "lives for the entire duration of the program".
// String literals are 'static: they're baked into the compiled binary.
fn returns_static() -> &'static str {
"I live forever"
}
fn main() {
let s: &'static str = returns_static();
println!("{s}");
// ⚠️ Common beginner instinct: "I'll just add 'static to make it compile."
// That is almost always wrong. 'static is not a fix — it's a promise the
// compiler will demand you keep. Fix the ownership structure instead.
}
For your first months of Rust, you will write lifetimes almost never. The compiler's elision rules cover the vast majority of code, and when it doesn't, the error message hands you the exact annotation to add. Focus on ownership and borrowing first; lifetimes will feel like punctuation rather than a new concept.
Structs, enums & pattern matching
Rust's type system lets you model your data so precisely that illegal states become unrepresentable. If a function only accepts a validated email, a validated email type makes it impossible to pass anything else.
Structs — named groups of data
// Deriving traits gives you useful behaviour for free:
// Debug → println!("{:?}"), Clone → .clone(), PartialEq → ==
#[derive(Debug, Clone, PartialEq)]
struct User {
username: String,
email: String,
sign_in_count: u64,
active: bool,
}
// Tuple struct: useful for lightweight wrappers / newtypes
struct Color(u8, u8, u8);
struct Meters(f64);
// Unit struct: no data, but has a type identity (great as markers/traits)
struct AlwaysEqual;
fn build_user(email: String, username: String) -> User {
User {
email, // field init shorthand: same name as the variable
username,
sign_in_count: 1,
active: true,
}
}
fn main() {
let mut user = build_user(String::from("a@b.com"), String::from("ada"));
user.email = String::from("new@b.com"); // mutate: needs `mut`
// Struct update syntax: fill remaining fields from another instance
let user2 = User { email: String::from("x@y.com"), ..user.clone() };
println!("{user:?}"); // pretty-print with :? (or {:#?} for multi-line)
println!("{}", user2.username);
let black = Color(0, 0, 0);
println!("({}, {}, {})", black.0, black.1, black.2);
let d = Meters(10.0);
println!("{}m", d.0);
}
Methods & associated functions with impl
#[derive(Debug)]
struct Rectangle { width: f64, height: f64 }
impl Rectangle {
// ── associated function ("static method") — called as Rectangle::square(3.0)
fn square(size: f64) -> Self { // Self = Rectangle
Self { width: size, height: size }
}
// ── methods take `self` in one of three flavours:
fn area(&self) -> f64 { // borrow: read-only, most common
self.width * self.height
}
fn scale(&mut self, factor: f64) { // mutable borrow: modify in place
self.width *= factor;
self.height *= factor;
}
fn into_parts(self) -> (f64, f64) { // take ownership: consume the value
(self.width, self.height)
}
// ── multiple impl blocks are allowed (used for traits and conditional impls)
}
impl Rectangle {
fn can_hold(&self, other: &Rectangle) -> bool {
self.width > other.width && self.height > other.height
}
}
fn main() {
let mut rect = Rectangle::square(5.0);
println!("area = {}", rect.area()); // 25
rect.scale(2.0);
println!("scaled = {}", rect.area()); // 100
println!("{:?}", rect); // Rectangle { width: 10.0, height: 10.0 }
let (w, h) = rect.into_parts(); // rect is consumed here
println!("{w} x {h}");
}
Enums — every variant, and the data it carries
This is one of Rust's most powerful features. Enum variants can hold any data, including different types per variant, tuples, structs, or even other enums. This replaces class hierarchies, sentinel values and null entirely.
#[derive(Debug)]
enum Shape {
Circle(f64), // variant with one field: radius
Rectangle { width: f64, height: f64 }, // variant with named fields
Triangle(f64, f64, f64), // variant with three fields
Nothing, // unit variant
}
// The most important enum in the entire language —
// it replaces null with a type you are FORCED to check.
enum Option<T> {
None, // no value
Some(T), // a value of type T
}
#[derive(Debug)]
enum Message {
Quit,
Move { x: i32, y: i32 },
Write(String),
ChangeColor(i32, i32, i32),
}
// Enums can have methods too
impl Message {
fn summary(&self) -> String {
match self {
Message::Quit => String::from("quit"),
Message::Move { x, y } => format!("move to {x},{y}"),
Message::Write(text) => format!("write: {text}"),
Message::ChangeColor(r, g, b) => format!("colour {r},{g},{b}"),
}
}
}
fn area(shape: &Shape) -> f64 {
match shape {
Shape::Circle(r) => std::f64::consts::PI * r * r,
Shape::Rectangle { width, height } => width * height,
Shape::Triangle(a, b, c) => {
let s = (a + b + c) / 2.0;
(s * (s - a) * (s - b) * (s - c)).sqrt() // Heron's formula
}
Shape::Nothing => 0.0,
}
}
fn main() {
let shapes = [
Shape::Circle(2.0),
Shape::Rectangle { width: 3.0, height: 4.0 },
Shape::Triangle(3.0, 4.0, 5.0),
Shape::Nothing,
];
for s in &shapes {
println!("{s:?} has area {:.2}", area(s));
}
let m = Message::Write(String::from("hi"));
println!("{}", m.summary());
}
Pattern matching with match
match is like a switch on steroids: it destructures data, must be exhaustive (cover every case), and each arm is an expression.
fn main() {
// ── literals, ranges, and catch-alls ────────────────────────────────
let n = 13;
match n {
1 => println!("one"),
2 | 3 | 5 | 7 | 11 => println!("small prime"), // multiple patterns with |
12..=19 => println!("teenager"), // inclusive range
_ => println!("something else"), // catch-all (required!)
}
// ── binding values with @ and destructuring ─────────────────────────
let point = (3, -7);
match point {
(0, 0) => println!("origin"),
(x, 0) => println!("on the x axis at {x}"),
(0, y) => println!("on the y axis at {y}"),
(x, y) if x == y => println!("diagonal"), // match GUARD
(x, y) => println!("point ({x}, {y})"),
}
// ── destructuring enums, structs and references ─────────────────────
#[derive(Debug)]
enum Coin { Penny, Nickel, Dime, Quarter(String) }
fn value(c: &Coin) -> u8 {
match c {
Coin::Penny => 1,
Coin::Nickel => 5,
Coin::Dime => 10,
Coin::Quarter(state) => { println!("quarter from {state}"); 25 }
}
}
println!("{}", value(&Coin::Quarter(String::from("Texas"))));
// ── nested patterns with .. ─────────────────────────────────────────
let triple = (1, 2, 3, 4, 5);
match triple {
(first, .., last) => println!("first={first}, last={last}"),
}
}
Concise alternatives to full match
fn main() {
let config_max: Option<u8> = Some(3);
// if let: when you only care about ONE case
if let Some(max) = config_max {
println!("max is {max}");
} else {
println!("no maximum");
}
// while let: loop while a pattern keeps matching
let mut stack = vec![1, 2, 3];
while let Some(top) = stack.pop() {
print!("{top} "); // 3 2 1
}
println!();
// let-else: handle the failure case and continue (Rust 1.65+)
fn get_id() -> Option<u32> { Some(42) }
let Some(id) = get_id() else {
println!("no id available");
return;
};
println!("id = {id}");
// matches!: a bool macro — great inside .filter() or assertions
let x = Some(3);
println!("{}", matches!(x, Some(3))); // true
let nums = [1, 2, 3, 4, 5];
let evens: Vec<_> = nums.iter().filter(|&&n| matches!(n, x if x % 2 == 0)).collect();
println!("{evens:?}");
}
When you add a new enum variant, the compiler immediately lists every match in your codebase that must now handle it. In a language with booleans or null, that same change silently breaks code you forgot about. Teams switching to Rust routinely report that this single feature eliminates a whole class of production incidents.
Option<T> versus null
Java / C# / JS / Python null | Rust Option<T> | |
|---|---|---|
| Is absence explicit? | No — any variable might be null at any time | Yes — Option<T> is a different type |
| What happens if you forget? | NullPointerException at runtime, possibly in production, possibly at 3am | Compile error — you cannot get a T out of an Option<T> without a match |
| Runtime cost | Hidden null checks everywhere in generated code | Zero — niche optimisation makes Option<&T> the same size as &T |
| Composition | Manual null checks, often forgotten | .map(), .and_then(), .unwrap_or(), ? |
| Documentation | None — read the source to see if null is possible | The type signature is the documentation |
Collections & strings
The standard library's collections live in std::collections: Vec, HashMap, HashSet, BTreeMap, VecDeque and more. And then there's the String question — which deserves its own section.
Vec<T> — the growable list
fn main() {
// Creating
let mut v: Vec<i32> = Vec::new(); // empty, type inferred from use
let mut v2 = vec![1, 2, 3]; // macro with initial values
let zeros = vec![0u8; 1024]; // 1024 zero bytes
// Adding & removing
v.push(10);
v.push(20);
v.push(30);
let last = v.pop(); // Option<i32> — Some(30) or None if empty
println!("{last:?}");
// Reading — two ways:
let third = &v2[2]; // panics if out of bounds (fast path)
let maybe = v2.get(99); // returns Option<&i32> — never panics
match maybe {
Some(val) => println!("found {val}"),
None => println!("no element there"),
}
// Iterating — note: &v (borrow), v.iter(), or v.into_iter() (consume)
for x in &v2 { print!("{x} "); } // 1 2 3
println!();
for x in &mut v2 { *x *= 10; } // mutate in place
println!("{v2:?}"); // [10, 20, 30]
// Storing different types: use an enum or Box<dyn Trait>
#[derive(Debug)]
enum Cell { Int(i32), Text(String) }
let row = vec![Cell::Int(1), Cell::Text(String::from("hello"))];
println!("{row:?}");
// Common operations
let mut nums = vec![5, 2, 8, 1, 9];
nums.sort(); // [1, 2, 5, 8, 9]
nums.dedup(); // removes consecutive duplicates
nums.retain(|&n| n > 2); // keep only matching elements
println!("{nums:?}"); // [5, 8, 9]
println!("{}", nums.iter().sum::<i32>()); // 22
let doubled: Vec<i32> = nums.iter().map(|n| n * 2).collect();
println!("{doubled:?}"); // [10, 16, 18]
}
This is a classic borrow-checker wall:
let mut v = vec![1, 2, 3];
let first = &v[0]; // shared borrow begins
v.push(4); // ❌ E0502: cannot borrow `v` as mutable
println!("{first}"); // …because the borrow is still used hereThe compiler is right: push may reallocate, invalidating first. Fix it by copying the value out (let first = v[0];) or ending the borrow before mutating. This bug is a silent heap corruption in C++.
HashMap<K, V> — key/value lookups
use std::collections::{HashMap, HashSet, BTreeMap};
fn main() {
let mut scores: HashMap<String, i32> = HashMap::new();
scores.insert(String::from("Blue"), 10);
scores.insert(String::from("Yellow"), 50);
// Insert-if-absent / update-if-present — the `entry` API prevents double lookups
scores.entry(String::from("Blue")).or_insert(25); // stays 10 (already there)
scores.entry(String::from("Red")).or_insert(99); // inserted as 99
// Count words — the classic `entry` idiom
let text = "hello world wonderful hello";
let mut counts: HashMap<&str, usize> = HashMap::new();
for word in text.split_whitespace() {
*counts.entry(word).or_insert(0) += 1;
}
println!("{counts:?}");
// Iterating yields (&K, &V)
for (team, score) in &scores {
println!("{team}: {score}");
}
// Lookup returns Option<&V> — never a null, never a panic
let blue = scores.get("Blue"); // Option<&i32>
println!("{blue:?}");
// Ownership rules apply! These would move the strings out of the map:
// scores.insert(String::from("x"), 1); let k = String::from("x"); scores.get(&k);
// BTreeMap: sorted keys, O(log n) — use when you need ordered iteration
let ordered: BTreeMap<&str, i32> = [("b", 2), ("a", 1), ("c", 3)].into();
println!("{ordered:?}");
// HashSet: uniqueness with O(1) membership checks
let mut seen: HashSet<String> = HashSet::new();
seen.insert(String::from("alice"));
println!("{}", seen.contains("alice")); // true
}
Choosing a collection
| Type | Ordering | Lookup | Insert | Use when… |
|---|---|---|---|---|
Vec<T> | Insertion | O(1) by index | Amortised O(1) | Default choice for a list. Cache-friendly, fastest iteration. |
VecDeque<T> | Insertion | O(1) | O(1) both ends | Queues, FIFO buffers, sliding windows. |
HashMap<K,V> | None (random) | O(1) avg | O(1) avg | Fastest lookups; iteration order varies per run. |
BTreeMap<K,V> | Sorted by key | O(log n) | O(log n) | Need sorted keys, ranges, or deterministic output. |
HashSet<T> | None | O(1) avg | O(1) avg | Uniqueness, membership tests. |
LinkedList<T> | Insertion | O(n) | O(1) ends | Almost never — poor cache locality; prefer Vec or VecDeque. |
indexmap, dashmap and smallvec crates cover specialised needs.String vs &str — the complete picture
Rust's string handling is famously strict, and the reason is Unicode correctness. Here is every fact you need:
String
Owned, heap-allocated, growable, UTF-8. Stored as three words: pointer, length, capacity. Created with String::new(), String::from("hi") or "hi".to_string().
&str
A borrowed view into UTF-8 bytes: pointer + length, no allocation. A string literal "hi" is a &'static str. Use it in function parameters.
fn main() {
// Creating & converting
let s1 = String::from("hello");
let s2 = s1.clone();
let s3 = "literal".to_string();
let s4 = format!("{}-{}", "a", "b"); // like println! but returns a String
let borrowed: &str = &s1; // borrow a String as a slice
let owned: String = borrowed.to_owned(); // back to an owned String
// Building
let mut s = String::from("hello");
s.push(' '); // single char
s.push_str("world"); // string slice
s += "!"; // via AddAssign
let combined = format!("{s} again"); // format! never moves its inputs
println!("{combined}");
// ⚠️ Slicing by BYTES — this trips everyone up
let s = "Здравствуйте"; // Cyrillic: 2 bytes per character
println!("chars: {}", s.chars().count()); // 12
println!("bytes: {}", s.len()); // 24
// let slice = &s[0..2]; // 💥 panics: not a char boundary
// Iterate safely
for c in "नमस्ते".chars() { print!("{c}"); } // by Unicode scalar value
println!();
for b in "abc".bytes() { print!("{b} "); } // 97 98 99
println!();
// Searching & transforming
let sentence = "the quick brown fox jumps over the lazy dog";
println!("{}", sentence.contains("brown")); // true
println!("{}", sentence.starts_with("the")); // true
println!("{}", sentence.replace("dog", "cat"));
let words: Vec<&str> = sentence.split_whitespace().collect();
println!("{} words", words.len()); // 9
println!("{}", sentence.to_uppercase());
println!("{:?}", sentence.split(',').collect::<Vec<_>>());
}
Why strings can't be indexed like arrays
String "hi" in ASCII
String "naïve" with an accent
Rust forbids s[i] on String because a byte index may fall in the middle of a character, producing invalid UTF-8 — which is a safety invariant of the type. Use s.chars(), s.bytes(), or a slice aligned to char boundaries.
Formatting cheat sheet
| Placeholder | Meaning | Example | Output |
|---|---|---|---|
{} | Display (human-readable) | format!("{}", 42) | 42 |
{:?} | Debug (developer output) | format!("{:?}", vec![1,2]) | [1, 2] |
{:#?} | Debug, pretty multi-line | format!("{:#?}", p) | indented tree |
{name} | Named argument | format!("{x} of {y}", x=1, y=2) | 1 of 2 |
{0} {1} {0} | Positional arguments | format!("{0}-{1}-{0}", "a", "b") | a-b-a |
{:.2} | Precision (2 decimals) | format!("{:.2}", 3.14159) | 3.14 |
{:05} | Zero-pad to width 5 | format!("{:05}", 42) | 00042 |
{:10} | Pad to width 10 | format!("{:10}|", "hi") | hi | |
{:<10} {:^10} | Left / centre alignment | format!("{:^7}", "hi") | hi |
{:x} {:#x} {:o} {:b} | Hex, prefixed hex, octal, binary | format!("{:#x}", 255) | 0xff |
{:e} | Scientific notation | format!("{:e}", 100000.0) | 1e5 |
{:.3?} | Debug with limited precision (floats in structs) | format!("{:.3?}", f) | rounded floats |
Display ({}) for your own types with impl std::fmt::Display for MyType, and derive Debug ({:?}) with #[derive(Debug)].Error handling without exceptions
Rust has no exceptions and no try/catch. Errors are ordinary values you must acknowledge. This feels verbose at first, then becomes a superpower: every failure mode is visible in the function signature.
Two categories of error
Unrecoverable — panic!
For bugs: index out of bounds, integer overflow in debug, invariant violated. The thread unwinds (or aborts), prints a backtrace, and exits. Similar to an exception you should never catch.
Recoverable — Result<T, E>
For expected failures: file not found, network timeout, invalid user input. Returned as a value, handled by the caller, and impossible to ignore thanks to #[must_use].
fn main() {
// Explicit panic with a message
// panic!("crashed and burned");
// These all panic automatically:
let v = vec![1, 2, 3];
// let x = v[99]; // 💥 index out of bounds
// let y: u8 = 255; let z = y + 1; // 💥 overflow (debug builds only)
// let n: i32 = "abc".parse().unwrap(); // 💥 called `Option::unwrap()` on `Err`
// A graceful way to stop the program when there's no sensible recovery:
let config = std::fs::read_to_string("config.toml")
.expect("config.toml must exist for the app to run"); // `.expect(msg)` = unwrap + message
println!("{config}");
}
thread 'main' panicked at src/main.rs:4:13: index out of bounds: the len is 3 but the index is 99 — that gives you the file, the line, the column, and the reason. Run with RUST_BACKTRACE=1 for a full stack trace, or RUST_BACKTRACE=full for maximum detail. In 2024 Rust added panic backtraces in debug builds by default.
Result<T, E> — the workhorse
use std::num::ParseIntError;
// The signature TELLS you this can fail. No documentation needed.
fn parse_age(input: &str) -> Result<u8, ParseIntError> {
input.trim().parse::<u8>() // `parse` already returns Result
}
fn main() {
// Pattern matching is the most explicit way to handle it
match parse_age("42") {
Ok(age) => println!("age is {age}"),
Err(e) => println!("could not parse: {e}"),
}
// if let for one-sided handling
if let Ok(age) = parse_age("29") {
println!("got {age}");
}
// Combinators: transform without branching
let doubled = parse_age("21").map(|age| age * 2); // Result<u8, _>
println!("{doubled:?}"); // Ok(42)
// Fallbacks
let age = parse_age("oops").unwrap_or(0); // 0 on failure
let age = parse_age("oops").unwrap_or_else(|_| 18); // compute a default
let age = parse_age("oops").unwrap_or_default(); // 0 (Default trait)
println!("{age}");
// `?` — the propagating operator (the idiomatic way)
fn process() -> Result<u8, ParseIntError> {
let a = parse_age("10")?; // on Err: return early from `process`
let b = parse_age("20")?;
Ok(a + b)
}
println!("{:?}", process()); // Ok(30)
}
The ? operator in depth
let x = may_fail()?;let x = match may_fail() {
Ok(v) => v,
Err(e) => return Err(From::from(e)),
};Result<…> or Option<…>. The error type must be convertible via From — which is how one ? handles many error kinds.use std::fs::File;
use std::io::{self, Read};
use std::num::ParseIntError;
// A custom error type — see `thiserror` for a derive macro that writes this for you
#[derive(Debug)]
enum AppError {
Io(io::Error),
Parse(ParseIntError),
Invalid(String),
}
// Implementing `From` lets `?` convert automatically
impl From<io::Error> for AppError {
fn from(e: io::Error) -> Self { AppError::Io(e) }
}
impl From<ParseIntError> for AppError {
fn from(e: ParseIntError) -> Self { AppError::Parse(e) }
}
fn read_number_from(path: &str) -> Result<i32, AppError> {
let mut file = File::open(path)?; // io::Error → AppError via From
let mut contents = String::new();
file.read_to_string(&mut contents)?; // same
let n = contents.trim().parse::<i32>()?; // ParseIntError → AppError
if n < 0 {
return Err(AppError::Invalid(String::from("must be positive")));
}
Ok(n)
}
fn main() {
match read_number_from("number.txt") {
Ok(n) => println!("read {n}"),
Err(e) => println!("failed: {e:?}"),
}
}
Box<dyn Error>For quick scripts and binary entry points, you don't need a custom error type:
use std::error::Error;
fn main() -> Result<(), Box<dyn Error>> {
let content = std::fs::read_to_string("data.txt")?; // works for ANY error type
let n: i32 = content.trim().parse()?;
println!("{n}");
Ok(())
}For libraries, prefer the thiserror crate (typed errors) and let anyhow handle ad-hoc errors at the application layer.
Option<T> toolbox
fn main() {
let x: Option<i32> = Some(5);
let e: Option<i32> = None;
// Transform
println!("{:?}", x.map(|n| n * 2)); // Some(10)
println!("{:?}", e.map(|n| n * 2)); // None
// Chain operations that themselves return Option
println!("{:?}", x.and_then(|n| Some(n + 1))); // Some(6)
// Provide defaults
println!("{}", e.unwrap_or(0)); // 0
println!("{}", e.unwrap_or_else(|| expensive())); // computed only when None
println!("{}", e.unwrap_or_default()); // 0
// Combine two Options
let a = Some(1); let b = Some(2);
println!("{:?}", a.zip(b)); // Some((1, 2))
println!("{:?}", a.and(b)); // Some(2) (both must exist)
println!("{:?}", a.or(e)); // Some(1) (first one that exists)
// Convert
println!("{:?}", x.ok_or("missing")); // Result<i32, &str> = Ok(5)
let r: Result<i32, String> = Ok(3);
println!("{:?}", r.ok()); // Some(3)
// Side effects without unwrapping
if let Some(n) = x { println!("have {n}"); }
x.inspect(|n| println!("found {n}"));
println!("{}", x.is_some_and(|n| n > 3)); // true
}
fn expensive() -> i32 { 42 }
| Situation | Do this | Not this |
|---|---|---|
| You can genuinely handle it | match / if let / unwrap_or | .unwrap() |
| It's a bug if it's absent | .expect("reason why this is always Some") | .unwrap() with no explanation |
| Caller should decide | Return Result<T, E> | Return T and panic inside |
| Only the presence matters | .is_some() / .is_none() | Comparing to null |
| Library code | Always Result — never panic on user input | Panic on expected failures |
.unwrap() in production code is a bug report waiting to happen. .expect() with a clear message is acceptable when the invariant is genuinely guaranteed.Generics & traits
Generics let one piece of code work for many types. Traits define shared behaviour — Rust's equivalent of interfaces, but more powerful and with zero runtime overhead.
Generics — write once, use for any type
// Without generics you'd write this twice (or three times, or ten times):
fn largest_i32(list: &[i32]) -> i32 { let mut m = list[0]; for &n in list { if n > m { m = n; } } m }
fn largest_f64(list: &[f64]) -> f64 { let mut m = list[0]; for &n in list { if n > m { m = n; } } m }
// With generics: one implementation
fn largest<T: PartialOrd>(list: &[T]) -> &T {
let mut biggest = &list[0];
for item in list {
if item > biggest { biggest = item; }
}
biggest
}
// Generic structs
struct Point<T> { x: T, y: T }
struct Pair<A, B> { first: A, second: B }
// Generic methods — the <T> after `impl` declares the type parameter
impl<T> Point<T> {
fn x(&self) -> &T { &self.x }
}
// Specialised: only for Point<f64>
impl Point<f64> {
fn distance_from_origin(&self) -> f64 {
(self.x.powi(2) + self.y.powi(2)).sqrt()
}
}
fn main() {
let nums = vec![34, 50, 25, 100, 65];
println!("largest = {}", largest(&nums)); // 100
let ints = Point { x: 5, y: 10 };
let floats = Point { x: 1.5, y: 4.2 };
println!("{} {}", ints.x(), floats.distance_from_origin());
let pair = Pair { first: 1, second: "one" };
println!("{:?} {:?}", pair.first, pair.second);
}
Monomorphisation: why generics are free
fn max<T: Ord>(a: T, b: T) -> T { ... }
max(1i32, 2i32);
max("a", "b");fn max_i32(a: i32, b: i32) -> i32 { ... }
fn max_str(a: &str, b: &str) -> &str { ... }Trade-off: monomorphisation increases compile time and binary size (one copy per type). Rust accepts this because runtime speed is the priority. Dynamic dispatch (dyn Trait) is the opt-out when binary size matters.
Traits — shared behaviour
// Define a trait: a named set of methods
trait Summary {
// Required method
fn summarize(&self) -> String;
// Default method — implementors may override it
fn headline(&self) -> String {
format!("Read more… ({})", self.summarize())
}
}
struct Article { title: String, author: String, content: String }
struct Tweet { username: String, body: String }
impl Summary for Article {
fn summarize(&self) -> String {
format!("{}, by {}", self.title, self.author)
}
// headline() uses the default
}
impl Summary for Tweet {
fn summarize(&self) -> String {
format!("@{}: {}", self.username, self.body)
}
fn headline(&self) -> String {
format!("New tweet from @{}", self.username) // override the default
}
}
// ── Trait bounds: "any type that implements Summary" ─────────────────────
fn notify(item: &impl Summary) { // clean syntax
println!("Breaking: {}", item.summarize());
}
fn notify_verbose<T: Summary>(item: &T) { // identical meaning
println!("Breaking: {}", item.summarize());
}
// Multiple bounds with `+`
fn both(item: &(impl Summary + std::fmt::Display)) {}
// Where clauses make complex bounds readable
fn complex<T, U>(t: &T, u: &U) -> String
where
T: Summary + Clone,
U: Summary + std::fmt::Debug + ?Sized,
{
format!("{} and {:?}", t.summarize(), u)
}
fn main() {
let a = Article { title: String::from("Rust 1.80"), author: String::from("Ferris"),
content: String::from("…") };
let t = Tweet { username: String::from("rustlang"), body: String::from("new release!") };
notify(&a);
notify(&t);
println!("{}", t.headline());
}
Static vs dynamic dispatch
impl Trait / T: Trait (static) | Box<dyn Trait> / &dyn Trait (dynamic) | |
|---|---|---|
| Resolved | At compile time (monomorphised) | At runtime, via a vtable pointer |
| Speed | Maximum — fully inlinable | Slight indirection cost (usually negligible) |
| Binary size | Grows with each type used | One copy of the code total |
| Collections | One concrete type per container | Vec<Box<dyn Shape>> can mix types |
| Object safety | No such requirement | Trait must be "object safe" (no generic methods, no Self: Sized) |
| Use when | Default choice. Performance-critical code | Plugin systems, heterogeneous lists, reducing compile times |
use std::fmt::Debug;
trait Shape {
fn area(&self) -> f64;
fn name(&self) -> &str { "shape" }
}
struct Circle(f64);
struct Rect { w: f64, h: f64 }
impl Shape for Circle {
fn area(&self) -> f64 { std::f64::consts::PI * self.0 * self.0 }
fn name(&self) -> &str { "circle" }
}
impl Shape for Rect {
fn area(&self) -> f64 { self.w * self.h }
fn name(&self) -> &str { "rectangle" }
}
fn main() {
// Static dispatch: generic, fast, but `shapes` must be homogeneous
fn total_static(items: &[&dyn Shape]) -> f64 {
items.iter().map(|s| s.area()).sum()
}
// Dynamic dispatch: one Vec can hold DIFFERENT concrete types
let shapes: Vec<Box<dyn Shape>> = vec![
Box::new(Circle(1.0)),
Box::new(Rect { w: 2.0, h: 3.0 }),
];
for s in &shapes {
println!("{}: {:.2}", s.name(), s.area());
}
println!("total: {:.2}", total_static(&[&Circle(1.0), &Rect{w:2.0,h:3.0}]));
// impl Trait in return position: "some type that implements Display"
fn make_greeting() -> impl std::fmt::Display {
String::from("hello")
}
println!("{}", make_greeting());
}
Essential standard-library traits
| Trait | Gives you | Derive? |
|---|---|---|
Debug | {:?} formatting — essential for debugging | #[derive(Debug)] |
Display | {} formatting for humans — not derivable, implement by hand | ❌ |
Clone | .clone() — explicit deep copy | #[derive(Clone)] |
Copy | Implicit bitwise copy on assignment (requires Clone) | #[derive(Copy, Clone)] |
PartialEq / Eq | == and != | ✅ |
PartialOrd / Ord | <, >, .sort(), BTreeMap keys | ✅ |
Hash | Usable as a HashMap/HashSet key | ✅ |
Default | T::default() — a sensible empty value | ✅ |
From / Into | Conversions: T::from(x), x.into() | ❌ |
TryFrom / TryInto | Fallible conversions returning Result | ❌ |
Iterator | .next(), and 60+ combinators for free | ❌ |
Deref / DerefMut | Smart-pointer behaviour, method auto-deref | ❌ |
Drop | Custom cleanup at end of scope (RAII) | ❌ |
Fn / FnMut / FnOnce | Closure calling conventions | ❌ |
Send / Sync | Thread-safety markers (auto-derived) | auto |
AsRef<T> / AsMut<T> | Cheap borrow conversions (e.g. String → &str) | ❌ |
From and you get Into for free. Implement PartialEq + Eq + Hash together consistently or HashMap will misbehave.use std::fmt;
#[derive(Debug, PartialEq)]
struct Color { r: u8, g: u8, b: u8 }
// Display must be implemented by hand — Rust wants your human output to be deliberate
impl fmt::Display for Color {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "#{:02x}{:02x}{:02x}", self.r, self.g, self.b)
}
}
// From<&str> gives us Into<Color> automatically
impl From<&str> for Color {
fn from(hex: &str) -> Self {
let n = u32::from_str_radix(hex.trim_start_matches('#'), 16).unwrap_or(0);
Color { r: ((n >> 16) & 0xFF) as u8, g: ((n >> 8) & 0xFF) as u8, b: (n & 0xFF) as u8 }
}
}
fn main() {
let c = Color { r: 249, g: 115, b: 22 };
println!("Display: {c}"); // #f97316
println!("Debug: {c:?}"); // Color { r: 249, g: 115, b: 22 }
let c2: Color = "#22c55e".into(); // uses From<&str>
println!("{c2}"); // #22c55e
println!("{}", c == c2); // false (PartialEq)
}
The newtype pattern & extension traits
// NEWTYPE: a tuple struct wrapping a primitive gives you type safety at ZERO cost.
struct Meters(f64);
struct Seconds(f64);
fn speed(distance: Meters, time: Seconds) -> f64 {
distance.0 / time.0
}
// Now `speed(Seconds(10.0), Meters(100.0))` is a COMPILE ERROR.
// You cannot accidentally mix units — a huge win for numerical code.
// EXTENSION TRAIT: add methods to types you don't own
trait StrExt {
fn shout(&self) -> String;
}
impl StrExt for str {
fn shout(&self) -> String {
format!("{}!!!", self.to_uppercase())
}
}
// ASSOCIATED TYPES: cleaner when a trait has exactly one sensible type per implementor
trait Container {
type Item; // one type, chosen by the implementor
fn insert(&mut self, item: Self::Item);
fn first(&self) -> Option<&Self::Item>;
}
fn main() {
let d = Meters(100.0);
let t = Seconds(9.58);
println!("speed = {:.2} m/s", speed(d, t));
println!("{}", "hello".shout());
}
Modules, crates & packages
Rust's module system is explicit and privacy-first: everything is private unless you mark it pub. Combined with cargo, this gives you one of the cleanest dependency stories in any language.
The vocabulary
Package
A Cargo.toml describing one or more crates. What you publish.
Crate
A compilation unit. Either a binary (has main.rs) or a library (has lib.rs).
Module
A namespace inside a crate. Controls scoping and privacy.
Path
How you name an item: crate::a::b::Item (absolute) or self::x, super::x.
// Declaring modules: the compiler looks for either a `garden` directory
// or a `garden.rs` file next to this one.
pub mod garden; // src/garden.rs (or src/garden/mod.rs)
// Inline module — good for small ones
mod front_of_house {
pub mod hosting {
pub fn add_to_waitlist() {}
fn seat_at_table() {} // private: invisible outside `hosting`
}
}
use crate::front_of_house::hosting; // import: now you can write `hosting::…`
use self::garden::Vegetables; // explicit self::
use std::collections::HashMap; // external crate
use std::fmt::Result as FmtResult; // rename to avoid collisions
use std::io::{self, Write}; // import `io` AND `io::Write`
// Re-export: make an internal type part of your public API
pub use garden::Vegetables;
fn eat_at_restaurant() {
hosting::add_to_waitlist(); // via the `use`
crate::front_of_house::hosting::add_to_waitlist(); // absolute path
}
// Privacy rules:
// • Items are PRIVATE by default, including struct fields.
// • A child module may access its ancestors' private items.
// • A parent may NOT access a child's private items.
// • `pub(crate)` = visible within the current crate only (very useful).
Real-world file layout
src/
├── lib.rs # crate root: `pub mod gui;` `pub mod models;`
├── main.rs # thin binary that calls into the library crate
├── gui/
│ ├── mod.rs # declares `pub mod buttons;` `pub mod panels;`
│ ├── buttons.rs
│ └── panels.rs
├── models/
│ ├── mod.rs
│ ├── user.rs
│ └── order.rs
└── utils.rs
# NOTE: in Rust 2018+ you may use `gui.rs` + a `gui/` folder instead of `mod.rs`
# (both work; `foo.rs` + `foo/bar.rs` is now the recommended style)
Dependencies & crates.io
cargo add serde --features derive # adds a dependency with a feature flag
cargo add tokio --features full
cargo search regex # search crates.io from the terminal
cargo tree --duplicates # find duplicated dependencies
cargo update -p serde # update one crate only
[dependencies]
serde = { version = "1.0", features = ["derive"] }
reqwest = { version = "0.12", features = ["json"] }
chrono = "0.4" # caret requirement: ^0.4 (semver compatible)
uuid = { version = "1", features = ["v4"] }
[dependencies.my_internal_lib]
path = "../my_internal_lib" # local path dependency
[features]
default = ["json"]
json = ["serde"]
cli = ["clap"]
[workspace]
members = ["core", "cli", "web"] # multi-crate workspace: one Cargo.lock, shared build
| Version spec | Meaning | Allows updates to |
|---|---|---|
"1.2.3" | Caret: ^1.2.3 (the default) | 1.2.3 → 1.9.9, not 2.0 |
"~1.2.3" | Tilde: patch updates only | 1.2.3 → 1.2.9 |
"=1.2.3" | Exact pin | Nothing |
"1.2" | Equivalent to ^1.2 | 1.2.0 → 1.9.9 |
"*" | Any version (avoid!) | Everything |
Cargo.lock for binaries and applications (reproducible builds). Do not commit it for libraries — let the consumer resolve versions.- Check downloads & recent release date on crates.io
- Read the docs on docs.rs — quality varies hugely
- Look for "reverse dependencies" (how many crates rely on it)
- Run
cargo denyorcargo auditfor licence and vulnerability checks - Prefer small, focused crates over heavyweight frameworks when learning
Closures & iterators
This is where Rust code starts to feel genuinely expressive. Closures capture their environment; iterators chain transformations lazily — and both compile down to the same fast code as a hand-written loop.
Closures — anonymous functions
fn main() {
// Syntax: |params| body (Type annotations optional — inferred from use)
let add = |a: i32, b: i32| a + b;
let square = |x: i32| { x * x }; // braces when you need multiple statements
let greeting = || println!("hi"); // no params
println!("{} {}", add(2, 3), square(4));
// Closures capture variables from their enclosing scope
let offset = 10;
let add_offset = |x: i32| x + offset; // borrows `offset`
println!("{}", add_offset(5)); // 15
// move closures TAKE ownership of captured variables
let name = String::from("Ferris");
let greet = move || println!("hello, {name}"); // `name` moved into the closure
greet();
// println!("{name}"); // ❌ E0382: name was moved into the closure
// This is essential for threads and async: the closure must outlive the scope
let data = vec![1, 2, 3];
let handle = std::thread::spawn(move || {
println!("thread sees {data:?}"); // owns `data` now
});
handle.join().unwrap();
}
The three closure traits
fn(&self)Reads captured variables only. Can be called many times. Most common.fn(&mut self)Modifies captures (e.g. a counter). Callable many times.fn(self)Consumes a capture (moves out of it). Callable only once.Fn is also FnMut, and every FnMut is also FnOnce. The compiler picks the most restrictive that fits your closure's body.fn apply<F: Fn(i32) -> i32>(f: F, x: i32) -> i32 { f(x) }
fn call_twice<F: FnMut()>(mut f: F) { f(); f(); }
fn consume_once<F: FnOnce() -> String>(f: F) -> String { f() }
fn main() {
println!("{}", apply(|x| x * 2, 21)); // 42
let mut count = 0;
call_twice(|| { count += 1; }); // FnMut closure
println!("count = {count}"); // 2
let name = String::from("Ferris");
let result = consume_once(move || name); // FnOnce: consumes `name`
println!("{result}");
}
Iterators — lazy, composable, fast
An iterator is any type implementing Iterator, which requires just one method:
pub trait Iterator {
type Item;
fn next(&mut self) -> Option<Self::Item>;
// …50+ provided methods come free
}
Adapters like .map() and .filter() do nothing when called. They build a description of a computation. Only a consumer — .collect(), .sum(), .for_each(), or a for loop — actually pulls values through the pipeline, one at a time. This means you can compose an infinite iterator and still terminate.
fn main() {
let numbers = vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
// ── adapters (lazy) ────────────────────────────────────────────────
let evens_doubled: Vec<i32> = numbers.iter()
.filter(|&&n| n % 2 == 0) // &&n because .iter() yields &i32 and filter passes &&i32
.map(|n| n * 2)
.collect(); // ← consumer: this is when it runs
println!("{evens_doubled:?}"); // [4, 8, 12, 16, 20]
// ── enumerate, zip, chain ──────────────────────────────────────────
for (i, value) in numbers.iter().enumerate() {
if i > 2 { break; }
print!("[{i}]={value} ");
}
println!();
let names = vec!["ada", "grace", "alan"];
let years = vec![1815, 1906, 1912];
for (name, year) in names.iter().zip(years.iter()) {
println!("{name} ({year})");
}
let chained: Vec<i32> = (1..=3).chain([10, 20]).collect();
println!("{chained:?}"); // [1, 2, 3, 10, 20]
// ── fold / reduce: the universal accumulator ───────────────────────
let sum = numbers.iter().fold(0, |acc, &n| acc + n);
println!("sum = {sum}"); // 55
// ── find, any, all, position ───────────────────────────────────────
let first_big = numbers.iter().find(|&&n| n > 7);
println!("{first_big:?}"); // Some(8)
println!("{}", numbers.iter().any(|&n| n == 5)); // true
println!("{}", numbers.iter().all(|&n| n > 0)); // true
println!("{:?}", numbers.iter().position(|&n| n == 4)); // Some(3)
// ── sorting & grouping ─────────────────────────────────────────────
let mut words = vec!["pear", "apple", "fig"];
words.sort_by_key(|w| w.len()); // by length
println!("{words:?}"); // ["fig", "pear", "apple"]
let (min, max) = numbers.iter().fold((i32::MAX, i32::MIN), |(lo, hi), &n| {
(lo.min(n), hi.max(n))
});
println!("min={min} max={max}");
// ── infinite iterators + take = finite work ────────────────────────
let squares: Vec<u64> = (1u64..)
.map(|n| n * n)
.take(5)
.collect();
println!("{squares:?}"); // [1, 4, 9, 16, 25]
// ── chunks, windows, step_by ───────────────────────────────────────
println!("{:?}", numbers.chunks(3).collect::<Vec<_>>());
println!("{:?}", numbers.windows(3).collect::<Vec<_>>());
println!("{:?}", numbers.iter().step_by(3).collect::<Vec<_>>());
}
Ownership modes of iteration
| Method | Yields | Ownership | Use when |
|---|---|---|---|
iter() | &T | Borrows | Reading values, by far the most common |
iter_mut() | &mut T | Mutable borrow | Modifying each element in place |
into_iter() | T | Consumes | Taking ownership (moves each element out) |
for x in &collection | &T | Borrows | Sugar for collection.iter() |
for x in collection consumes the collection. If you need it afterwards, iterate with &collection.Because of monomorphisation and inlining, this chain:
let total: i32 = data.iter().filter(|&&x| x > 0).map(|x| x * 2).sum();compiles to essentially the same assembly as a manual for loop with an if and a multiply. In benchmarks it is frequently faster than C++ STL iterators because the compiler can reason about the whole pipeline at once. "Zero-cost abstraction" is not marketing — it is measured.
Smart pointers & interior mutability
When plain references and ownership aren't enough — shared ownership, recursive types, dynamic dispatch — Rust offers explicit, self-documenting smart pointers. Each one tells you exactly what memory behaviour to expect.
Box<T> — heap allocation
use std::mem;
fn main() {
// 1. A value on the heap, with a stack pointer to it
let b = Box::new(5);
println!("{b}");
// `b` behaves exactly like the i32 it holds, thanks to Deref
println!("{}", b + 1); // 6
// 2. BREAKING infinite-size recursion
// This would be infinitely large, so it's a compile error:
// enum List { Cons(i32, List), Nil }
// Box gives the type a known, finite size:
#[derive(Debug)]
enum List { Cons(i32, Box<List>), Nil }
use List::{Cons, Nil};
let list = Cons(1, Box::new(Cons(2, Box::new(Cons(3, Box::new(Nil))))));
println!("{list:?}");
// 3. Trait objects: dynamically sized values
let shape: Box<dyn std::fmt::Display> = Box::new("a boxed string");
// 4. Large data you want off the stack
let big = Box::new([0u8; 1_000_000]);
// A Box is 8 bytes on the stack (just the pointer)
println!("size of Box: {}", mem::size_of_val(&b)); // 8
println!("size of List: {}", mem::size_of_val(&list)); // 16
}
Rc<T> & Arc<T> — shared ownership
Normally a value has one owner. Rc (reference-counted) allows many: it keeps a count on the heap, and the value is freed when the count reaches zero. Arc is the same but atomically thread-safe (slightly slower).
Two stack handles, one heap value
Rules & trade-offs
Rc is single-threaded shared ownership (GUI trees, graphs, DAGs). Arc is its multi-threaded sibling, used constantly in concurrent code.use std::rc::Rc;
use std::sync::Arc;
use std::thread;
fn main() {
// ── Rc: shared ownership on one thread ──────────────────────────────
let shared = Rc::new(String::from("shared data"));
let a = Rc::clone(&shared); // note: Rc::clone, not .clone() — makes the intent visible
let b = Rc::clone(&shared);
println!("{} {} {}", shared, a, b);
println!("refcount = {}", Rc::strong_count(&shared)); // 3
// ── Arc: the same, but safe to send across threads ─────────────────
let numbers = Arc::new(vec![1, 2, 3, 4, 5]);
let mut handles = vec![];
for i in 0..3 {
let nums = Arc::clone(&numbers);
handles.push(thread::spawn(move || {
let sum: i32 = nums.iter().sum();
println!("thread {i} sums to {sum}");
}));
}
for h in handles { h.join().unwrap(); }
}
RefCell<T> — interior mutability
RefCell lets you mutate a value even when you only hold a shared reference — by moving the borrow check from compile time to runtime. Break the rules and your program panics instead of corrupting memory.
use std::cell::RefCell;
use std::rc::Rc;
fn main() {
let data = RefCell::new(vec![1, 2, 3]);
// Runtime borrow rules: same as compile-time, but checked when you call
{
let mut borrowed = data.borrow_mut(); // exclusive runtime borrow
borrowed.push(4);
} // borrow ends here
println!("{:?}", data.borrow()); // [1, 2, 3, 4]
// data.borrow_mut() while borrowed → 💥 panic: already borrowed
// The classic combination: Rc<RefCell<T>> = shared AND mutable
let shared_list = Rc::new(RefCell::new(Vec::new()));
let a = Rc::clone(&shared_list);
let b = Rc::clone(&shared_list);
a.borrow_mut().push(10);
b.borrow_mut().push(20);
println!("{:?}", shared_list.borrow()); // [10, 20]
}
Interior mutability in the standard library
| Type | Threads | Cost | Typical use |
|---|---|---|---|
Cell<T> | Single (not Sync) | None | Copy types: counters, flags. .get() / .set() |
RefCell<T> | Single | Runtime checks | Complex values needing mutation through a shared ref |
Mutex<T> | Multi | Locking | Shared mutable state across threads |
RwLock<T> | Multi | Locking | Many readers, occasional writer |
Atomic* | Multi | Atomic ops | Counters, flags, lock-free structures |
OnceCell / OnceLock | Both | One-time init | Global singletons, lazy config |
Deref & Drop — how smart pointers behave like plain ones
use std::ops::Deref;
use std::fmt;
// A custom smart pointer
struct MyBox<T>(T);
impl<T> MyBox<T> {
fn new(x: T) -> MyBox<T> { MyBox(x) }
}
impl<T> Deref for MyBox<T> {
type Target = T;
fn deref(&self) -> &T { &self.0 } // enables `*my_box` and method auto-deref
}
impl<T: fmt::Debug> Drop for MyBox<T> {
fn drop(&mut self) {
println!("dropping MyBox with {:?}", self.0);
}
}
fn hello(name: &str) { println!("Hello, {name}!"); }
fn main() {
let x = MyBox::new(String::from("Rust"));
hello(&x); // auto-deref: &MyBox<String> → &String → &str
// (the compiler inserts derefs — zero runtime cost)
} // "dropping MyBox with ..." printed HERE, deterministically
Two Rcs pointing at each other keep their reference counts above zero forever. The ownership system prevents unsafety, not leaks. Fix it with Weak<T> for the "child → parent" direction — a non-owning link that doesn't increase the strong count:
use std::rc::{Rc, Weak};
use std::cell::RefCell;
struct Node { value: i32, parent: RefCell<Weak<Node>>, children: RefCell<Vec<Rc<Node>>> }
let leaf = Rc::new(Node { value: 3, parent: RefCell::new(Weak::new()), children: RefCell::new(vec![]) });
let branch = Rc::new(Node { value: 5, parent: RefCell::new(Weak::new()), children: RefCell::new(vec![Rc::clone(&leaf)]) });
*leaf.parent.borrow_mut() = Rc::downgrade(&branch); // weak link: no cycleWhich smart pointer should I use?
| I need… | Use |
|---|---|
| Heap-allocated value, single owner | Box<T> |
| Shared ownership, one thread | Rc<T> |
| Shared ownership, many threads | Arc<T> |
| Shared and mutable, one thread | Rc<RefCell<T>> |
| Shared and mutable, many threads | Arc<Mutex<T>> or Arc<RwLock<T>> |
| A copy-on-write string/vec | Cow<'a, T> |
| A lazily-initialised global | OnceLock<T> / LazyLock |
| A non-owning back-reference | Weak<T> |
Rc/Arc too early is a common mistake. Start with plain ownership and references — they're simpler and faster — and add sharing only when the design truly demands it.Fearless concurrency
Rust's most quotable claim: "fearless concurrency". The same ownership and type rules that guarantee memory safety also make data races a compile error. No other mainstream language does this.
Why this matters
A data race is when two threads access the same memory concurrently, at least one writes, and there's no synchronisation. In C++ the result is undefined behaviour — a heisenbug that vanishes in the debugger. In Go/Java it corrupts state at runtime. In Rust, it simply does not compile.
Threads (std::thread)
OS threads, one per core. Best for CPU-bound work. Fully safe and supported on stable Rust today.
Message passing (channels)
"Don't communicate by sharing memory; share memory by communicating." mpsc gives you multi-producer, single-consumer channels in the standard library.
Shared state (Mutex / RwLock)
When you must share, wrap the value in a lock. The type system forces you to acquire the lock before touching the data.
Async / await
For thousands of concurrent I/O tasks (servers, clients). Managed by an executor such as tokio or async-std.
Spawning threads
use std::thread;
use std::time::Duration;
fn main() {
let mut handles = vec![];
for i in 0..4 {
// `move` transfers ownership of `i` into the thread — required because
// the thread may outlive the current scope.
let handle = thread::spawn(move || {
for step in 0..3 {
println!("thread {i}: step {step}");
thread::sleep(Duration::from_millis(10));
}
i * 10 // the closure's return value
});
handles.push(handle);
}
// Join: wait for each thread and collect its result
for h in handles {
let result = h.join().unwrap();
println!("thread returned {result}");
}
// Scoped threads (Rust 1.63+): borrow data safely without `move`,
// guaranteed to finish before the scope ends.
let data = vec![1, 2, 3, 4];
thread::scope(|s| {
let half = &data[..2];
s.spawn(|| println!("first half: {half:?}"));
s.spawn(|| println!("sum: {}", data.iter().sum::<i32>()));
});
// `data` is still usable here — the threads were scoped
println!("{data:?}");
}
Message passing with channels
use std::sync::mpsc;
use std::thread;
use std::time::Duration;
fn main() {
let (tx, rx) = mpsc::channel(); // transmitter, receiver
// Multiple producers: just clone the transmitter
let tx2 = tx.clone();
thread::spawn(move || {
let messages = vec![String::from("hi"), String::from("from")];
for m in messages {
tx.send(m).unwrap();
thread::sleep(Duration::from_millis(20));
}
});
thread::spawn(move || {
tx2.send(String::from("the thread!")).unwrap();
});
// rx.recv() blocks until a message arrives; try_recv() returns Result immediately.
// When all senders drop, iteration ends cleanly.
for received in rx {
println!("got: {received}");
}
}
Shared state with Mutex<T> and Arc<T>
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
// Arc for shared ownership across threads, Mutex for safe mutation
let counter = Arc::new(Mutex::new(0));
let mut handles = vec![];
for _ in 0..10 {
let counter = Arc::clone(&counter);
handles.push(thread::spawn(move || {
let mut num = counter.lock().unwrap(); // acquire the lock (guard)
*num += 1;
// lock released automatically when `num` (the MutexGuard) drops here
}));
}
for h in handles { h.join().unwrap(); }
println!("result = {}", *counter.lock().unwrap()); // 100
}
counter.lock() returns a MutexGuard. It unlocks in its Drop implementation — meaning the lock is always released, even if your code panics or returns early. There is no way to forget to unlock, and no way to hold a lock after the data is gone.
Send and Sync — the traits behind "fearless"
Rc, RefCell, raw pointers.T: Sync ⟺ &T: Send. Mutex<T> and Arc<T> are Sync; RefCell is not.thread::spawn requires Send + 'static. If your type isn't safe to share, the compiler says so at the spawn site — not in a crash report six weeks later.// ❌ This is a compile error — Rc is not Send:
use std::rc::Rc;
use std::thread;
let r = Rc::new(5);
// thread::spawn(move || println!("{r}")); // error: `Rc<i32>` cannot be sent between threads safely
// Fix: use std::sync::Arc instead — the fix is visible in the type.Async / await
Threads are perfect for CPU work, but a server handling 10,000 simultaneous connections can't afford 10,000 OS threads. Async Rust lets one thread drive thousands of tasks, each of which pauses at .await points and resumes later.
use tokio::time::{sleep, Duration};
// `async fn` returns a Future: a lazily-evaluated state machine, NOT a result
async fn fetch_user(id: u32) -> String {
sleep(Duration::from_millis(100)).await; // yields control back to the executor
format!("user-{id}")
}
async fn fetch_all() -> Vec<String> {
// Run both concurrently and await both results
let (a, b) = tokio::join!(fetch_user(1), fetch_user(2));
vec![a, b]
}
// The `#[tokio::main]` macro builds the runtime (executor + I/O driver + timer)
#[tokio::main]
async fn main() {
let users = fetch_all().await; // .await drives the future to completion
println!("{users:?}");
// Spawn independent tasks that run concurrently
let mut handles = vec![];
for i in 0..100 {
handles.push(tokio::spawn(async move {
fetch_user(i).await
}));
}
for h in handles {
println!("{}", h.await.unwrap());
}
}
Threads vs async — when to use which
| OS Threads | Async / Future | |
|---|---|---|
| Best for | CPU-bound work: parsing, encoding, maths, compression | I/O-bound work: servers, websockets, file/network polling |
| Concurrency limit | ~Hundreds (each is an OS resource) | Millions of tasks per process |
| Memory per task | ~8 MB stack (reserved) per thread | ~Hundreds of bytes per future |
| Scheduling | OS kernel | Executor (tokio, async-std, smol) |
| Code style | Plain synchronous code | async/.await, "coloured functions" |
| Blocking is… | Expected | Forbidden — it stalls the whole executor |
| Maturity | Complete on stable | Stable syntax; ecosystem still converging |
async fn. Use tokio::task::spawn_blocking to push CPU-heavy work onto a separate thread pool.Async Rust is powerful but genuinely more complex: Send bounds on futures, Pin, the "coloured function" problem (async functions can only be called from async contexts), and no async traits in dyn until recently. Learn synchronous Rust first. Come back to async when you're building a real network service — by then the type system will feel like an ally.
Data parallelism in one line
use rayon::prelude::*;
fn main() {
let mut data: Vec<u64> = (0..10_000_000).collect();
// Just change `.iter()` to `.par_iter()` — that's the whole migration.
// Rayon splits the work across all CPU cores automatically.
let sum: u64 = data.par_iter().map(|x| x * x).sum();
data.par_sort_unstable(); // parallel sort, often 4-8x faster
println!("{sum}");
}
Unsafe Rust & foreign function interface
Rust's guarantees are opt-out, not opt-in. unsafe gives you five extra abilities — and shifts the correctness burden back onto you, in a small, greppable amount of code.
What unsafe unlocks
1. Dereference raw pointers
*const T and *mut T. Raw pointers may alias, may be null, may dangle — all bets are off.
2. Call unsafe functions
Includes calling into C code via extern "C". The caller must uphold the documented invariants.
3. Access mutable statics
static mut globals. Inherently racy across threads — prefer Atomic* or OnceLock.
4. Implement unsafe traits
Traits where incorrect implementation causes UB, like Send, Sync, or GlobalAlloc.
unionUnions let several fields share the same memory. Reading the wrong variant is undefined behaviour, so it requires unsafe.
unsafe does NOT turn offThe borrow checker still runs. In an unsafe block you cannot suddenly have two &mut references to the same variable. unsafe unlocks five specific abilities — it is not a "disable all checks" switch. Also: the block is small, explicit, and greppable (rg "unsafe"), so you know exactly where the risk lives. Most crates contain zero unsafe.
fn main() {
let mut num = 5;
// Creating raw pointers is SAFE. Dereferencing them is not.
let r1 = &num as *const i32;
let r2 = &mut num as *mut i32;
unsafe {
println!("r1 = {r1:?}, value = {}", *r1); // deref requires unsafe
*r2 = 10; // write through a raw pointer
}
println!("num = {num}"); // 10
// Calling an unsafe function
unsafe fn dangerous() {
// …the contract must be documented and upheld by every caller
}
unsafe { dangerous(); }
// Safe abstraction over unsafe internals — the idiomatic pattern.
// The `unsafe` is contained in ONE small function with a documented invariant.
fn split_at_mut<T>(slice: &mut [T], mid: usize) -> (&mut [T], &mut [T]) {
let len = slice.len();
let ptr = slice.as_mut_ptr();
assert!(mid <= len);
unsafe {
(
std::slice::from_raw_parts_mut(ptr, mid),
std::slice::from_raw_parts_mut(ptr.add(mid), len - mid),
)
}
}
let mut v = vec![1, 2, 3, 4];
let (a, b) = split_at_mut(&mut v, 2);
println!("{a:?} {b:?}"); // [1, 2] [3, 4]
}
Calling C (and being called by C)
// Declare an external C function (linked at build time)
extern "C" {
fn abs(input: i32) -> i32;
fn sqrt(input: f64) -> f64;
}
// Make a Rust function callable from C
#[no_mangle]
pub extern "C" fn rust_add(a: i32, b: i32) -> i32 {
a + b
}
// Layout compatible with C structs
#[repr(C)]
pub struct CPoint {
pub x: f64,
pub y: f64,
}
// A callback that C code can invoke
pub type Callback = extern "C" fn(i32) -> i32;
fn main() {
unsafe {
println!("abs(-3) = {}", abs(-3));
println!("sqrt(2) = {:.4}", sqrt(2.0));
}
}
bindgen
Generates Rust FFI bindings automatically from C header files. The standard way to consume an existing C library.
cbindgen
Generates C headers from your Rust code, so C/C++/Python/Node projects can call into your library.
PyO3 / Neon / napi-rs
Write Python extensions, Node native modules or C# bindings in Rust with a safe, ergonomic API.
cargo-c
Builds your Rust crate as a C-compatible shared/static library with a proper .pc file.
Miri (cargo +nightly miri test) interprets your code and detects undefined behaviour, use-after-free, and invalid pointer arithmetic at test time. ASan/LSan/TSan (address/leak/thread sanitizers) work with the nightly toolchain. cargo-geiger reports how much unsafe every one of your dependencies contains. If you write unsafe, these are not optional.
Tooling, testing & documentation
Rust's tooling is arguably its greatest hidden feature. Everything you need is built in and works out of the box: formatter, linter, test runner, documentation generator, package manager and benchmark harness.
Unit tests in the same file
pub fn add(a: i32, b: i32) -> i32 { a + b }
pub fn greeting(name: &str) -> String {
format!("Hello, {name}!")
}
// Tests live in a module gated behind #[cfg(test)] — compiled ONLY for `cargo test`
#[cfg(test)]
mod tests {
use super::*; // import everything from the parent module
#[test]
fn test_add() {
assert_eq!(add(2, 3), 5);
assert_ne!(add(2, 2), 5);
}
#[test]
fn greeting_contains_name() {
let g = greeting("Rust");
assert!(g.contains("Rust"), "greeting was: {g}");
}
#[test]
#[should_panic(expected = "divided by zero")]
fn test_panic() {
let _ = 1 / 0;
}
#[test]
#[ignore] // skip unless run with: cargo test -- --ignored
fn expensive_test() { }
// Returning Result from a test lets you use `?` — elegant for fallible tests
#[test]
fn returns_result() -> Result<(), std::num::ParseIntError> {
let n: i32 = "42".parse()?;
assert_eq!(n, 42);
Ok(())
}
}
Integration tests, doc tests & coverage
// tests/ holds BLACK-BOX tests: they use your crate as an external user would.
use my_library; // the crate name
#[test]
fn full_workflow() {
assert_eq!(my_library::add(2, 2), 4);
}
/// Adds two numbers.
///
/// # Examples
///
/// ```
/// let result = my_library::add(2, 3);
/// assert_eq!(result, 5);
/// ```
pub fn add(a: i32, b: i32) -> i32 { a + b }
//! Crate-level documentation (goes at the top of lib.rs)
//! ```
//! use my_library::add;
//! ```
cargo test # run everything (unit + integration + doc)
cargo test test_add # filter by name
cargo test -- --threads=1 # single-threaded output is easier to read
cargo test -- --nocapture # show println! output
cargo test --release # test optimised code too
cargo tarpaulin --out Html # code coverage report (install: cargo install cargo-tarpaulin)
cargo llvm-cov # faster coverage using LLVM instrumentation
Documentation as a first-class citizen
| Tool | Command | What it does |
|---|---|---|
| Local docs | cargo doc --open | Builds HTML docs for your code and all dependencies, opens in browser |
| Public docs | docs.rs | Auto-generated for every published crate |
| Linting | cargo clippy | 500+ idiomatic lints with explanations and auto-fixes (--fix) |
| Formatting | cargo fmt | Official style guide, deterministic, zero arguments with your team |
| Language server | rust-analyzer | Inline type hints, refactoring, macro expansion, quick fixes |
| Debugging | lldb / gdb / IDE debuggers | Full source-level debugging with pretty printers for Rust types |
| Benchmarks | cargo bench (criterion) | Statistical benchmarking with regression detection and charts |
| Macro inspection | cargo expand | Shows exactly what #[derive] and macros generate |
| Unsafe analysis | cargo geiger | Counts unsafe usage across your whole dependency tree |
| Audit | cargo audit / cargo deny | Known vulnerabilities, duplicate deps, licence violations |
| Release | cargo publish | Uploads to crates.io (runs your tests first) |
Release profile tuning
[profile.release]
opt-level = 3 # 0-3, or "s"/"z" for size optimisation
lto = "fat" # link-time optimisation: slower build, faster binary
codegen-units = 1 # maximum optimisation across the whole crate
panic = "abort" # smaller binary (no unwinding machinery)
strip = true # remove debug symbols from the final binary
incremental = false # release builds don't need incremental compilation
[profile.dev]
opt-level = 0 # fast compilation for the inner loop
debug = true # keep symbols for the debugger
Before every commit, run:
cargo fmt --check && cargo clippy -- -D warnings && cargo testWire it into a git pre-commit hook or CI (GitHub Actions has a one-line dtolnay/rust-toolchain setup) and your codebase will stay consistent for years.
Performance & how Rust compares to other languages
Claims are cheap. This chapter shows why Rust is fast, then compares measured numbers across execution speed, memory, binary size, startup latency, web throughput and compile time — including the places where Rust loses.
The figures below are illustrative order-of-magnitude ranges drawn from publicly published benchmarks (The Computer Language Benchmarks Game, TechEmpower Web Framework Benchmarks, and vendor engineering blogs). Real numbers vary with hardware, compiler version, and how carefully each program is written. Use them to understand relative scale, not to score points.
Why Rust is fast — the six reasons
No garbage collector
Zero background CPU usage, zero pause spikes, zero heap metadata overhead. Memory is freed at a compile-time-known point. GC languages typically use 2–5× the memory and lose 15–30% of CPU to collection.
Monomorphised generics
Generic code is specialised per concrete type at compile time. Java erases types and boxes; Rust inlines them into direct calls.
LLVM backend
Rust compiles through the same optimiser as Clang/C++: inlining, vectorisation, loop unrolling, SIMD, LTO. Decades of optimisation research, free.
Predictable memory layout
Structs are laid out contiguously like C arrays, not as scattered objects. Arrays of Vec3 iterate with perfect cache locality, unlike arrays of pointers-to-objects in Java.
No hidden allocations
Nothing allocates behind your back. No boxing for interfaces, no string concatenation temporaries, no autoboxing of integers. You see every Vec::new().
Fearless parallelism
Because the compiler proves thread safety, you can parallelise aggressively — rayon turns a serial iterator into a multi-core one in a single token change.
Reading the chart: Rust sits in the same tier as C and C++ — within a few percent on most workloads, occasionally faster thanks to stronger aliasing information (&mut tells LLVM that data does not alias, something C's restrict only hints at). Go is a small constant slower due to its runtime and GC. Managed languages pay for JIT warm-up and heap indirection. Python/JS are a different category entirely.
This is why Rust is chosen for containers and edge functions: a Rust service runs comfortably in 30 MB where the equivalent Go or Java service needs 200 MB+. Smaller footprints mean denser deployments and lower cloud bills.
unsafe.cargo check instead of build in your inner loop (3–5× faster),
incremental compilation (already on), sccache, cargo-nextest, splitting into a workspace of small crates,
and cranelift as a fast backend for debug builds (cargo +nightly build -Zcodegen-backend=cranelift).
The case study everyone quotes: Discord
Discord's Read States service was written in Go and suffered latency spikes: every few seconds, garbage collection would stop the world and the response time graph showed visible "sawtooth" spikes. They rewrote the hot path in Rust and the spike simply disappeared — the latency graph became flat, and the same hardware handled more load with less memory.
Their conclusion: "Rust gives us the performance of C++ with the safety of Go, and the compiler refuses to ship code with memory errors." Similar stories come from Cloudflare (Pingora replaced NGINX with ~70k lines of Rust and cut CPU usage), Figma (multiplayer server), and 1Password.
Rust vs C++ — the closest competitor
| Area | Rust | C++ | Verdict |
|---|---|---|---|
| Raw performance | Excellent | Excellent | Tie |
| Memory safety | Guaranteed by the compiler | Manual; UB is everywhere | Rust |
| Undefined behaviour | Impossible in safe code | Rampant and often invisible | Rust |
| Compile times | Slow | Very slow (headers, templates) | Rust slightly |
| Tooling | cargo: one unified tool | CMake, Conan, vcpkg, Make… fragmented | Rust |
| Package ecosystem | crates.io: 150k+ crates, consistent | Huge but fragmented across registries | Rust cohesion |
| Legacy / library depth | Growing | 40 years of everything | C++ |
| Language complexity | Large, but consistent | Enormous, many traps and legacy corners | Rust |
| Job market | Smaller, high-paying, growing fast | Massive, established | C++ today |
| Learning curve | Steep then smooth | Moderate to learn, endless to master | Depends |
| Interoperability | Excellent C FFI | Native C++ ABI | C++ for C++ code |
Rust vs Go
| Area | Rust | Go |
|---|---|---|
| Design goal | Maximum performance + safety | Maximum simplicity + fast builds |
| Learning curve | Steep (ownership, traits, lifetimes) | Gentle — teachable in a week |
| Memory model | Ownership, no GC | Tracing GC, no manual control |
| Speed / memory | Faster, far less memory | ~1.5–2.5× slower, 3–4× more RAM |
| Concurrency | Threads + async, compile-time checked | Goroutines — beautifully simple |
| Compile speed | Slow | Very fast |
| Error handling | Result + ?, typed, exhaustive | if err != nil — simple but repetitive |
| Abstraction | Generics + traits + macros | Minimal by design |
| Best for | Systems, performance-critical, embedded, CLI, Wasm | Network services, infrastructure, CLIs, quick delivery |
Rust vs managed languages (Java, C#, Python, JS/TS)
| Concern | Rust | Java / C# | Python / JS |
|---|---|---|---|
| Execution speed | Native, top tier | Fast after JIT warm-up | 10–100× slower |
| Memory footprint | Tiny | Large (VM + heap) | Moderate–large |
| Startup | Milliseconds | 100 ms – seconds | Fast, but slow work after |
| Latency predictability | Deterministic | GC pauses (much better with ZGC/G1) | GC pauses, jitter |
| Concurrency scaling | Excellent (no GIL) | Excellent (virtual threads in modern Java) | Python: GIL-limited · JS: single-threaded event loop |
| Productivity / prototyping | Slower initially | Fast with good tooling | Fastest |
| Ecosystem breadth | Strong in systems domains | Massive, enterprise-grade | Massive, especially web/ML |
| Deployment | Single static binary | Needs runtime (or AOT) | Needs interpreter/Node |
| Hiring pool | Smaller, highly skilled | Very large | Largest |
Beyond speed: the other measurable wins
Binary size & distribution
Rust binaries can be shrunk dramatically with
opt-level = "z", LTO, panic = "abort", strip = true, and a tiny #[global_allocator] such as dlmalloc. For WebAssembly, size matters enormously — and Rust is the leading Wasm language.
When Rust is the wrong choice
Rapid prototyping
If you need a throwaway script or MVP by tonight, Python or TypeScript will beat you there. Rust's guarantees cost design time up front.
Very large existing codebases
Rewriting working Java/C++/Python services "for performance" is usually a bad trade. Rewrite only measured hot paths.
Domains with dominant ecosystems
Data science, ML training and web front-ends are dominated by Python and JavaScript respectively. Rust can accelerate the slow parts (see PyO3, Polars, Leptos) but is not the default.
Teams that can't invest in learning
Rust pays off over years. If the team is 3 people shipping a 6-month product with high turnover, the ramp-up cost may dominate.
Performance problems are usually architectural — an N+1 query, a bad algorithm, excessive I/O — not a language problem. Always measure first. The right outcome of profiling is often "move 5% of the code to Rust" (a WASM module, an FFI extension) rather than a full rewrite.
Side-by-side: the same task in 6 languages
Count word frequencies — a task that touches strings, collections, iteration and error handling:
use std::collections::HashMap;
fn main() {
let text = "the quick brown fox jumps over the lazy dog the fox";
let counts: HashMap<&str, usize> = text
.split_whitespace()
.fold(HashMap::new(), |mut acc, word| {
*acc.entry(word).or_insert(0) += 1;
acc
});
let mut sorted: Vec<_> = counts.into_iter().collect();
sorted.sort_by(|a, b| b.1.cmp(&a.1));
for (word, n) in sorted {
println!("{word:>6}: {}", "█".repeat(n));
}
}
| Language | Approx. lines | Speed | Notes |
|---|---|---|---|
| Rust | 14 | Baseline (fastest) | Explicit, typed, no allocations hidden |
| C++ | 16 | ~same | unordered_map, stringstream |
| Go | 15 | ~2× slower | Very readable, map[string]int |
| Python | 7 | ~40× slower | collections.Counter — wonderfully terse |
| JavaScript | 11 | ~5× slower | Map, reduce |
| Java | 20 | ~1.6× slower | Verbose stream pipeline |
Reading the compiler's mind
Rust's compiler is famous for being helpful. It doesn't just say "error" — it explains the rule, points at the exact line, and frequently hands you the fix. Here's how to read its output and the ten errors you will absolutely meet.
How to read a Rust error message
error[E0382]: borrow of moved value: `s1`
--> src/main.rs:5:15
|
2 | let s1 = String::from("hello");
| -- move occurs because `s1` has type `String`
3 | let s2 = s1;
| -- value moved here
4 | println!("{s2}");
5 | println!("{s1}");
| ^^^^ value borrowed here after move
|
= note: move occurs because `s1` has type `String`, which does not implement the `Copy` trait
help: consider cloning the value if the performance cost is acceptable
|
3 | let s2 = s1.clone();
| ++++++
+ markers showing exactly what to add. Often one keystroke with rust-analyzer.The 10 errors every beginner meets
E0382 — use of moved value
let s = String::from("hi");
let t = s;
println!("{s}"); // error: borrow of moved value: `s`Why: String owns heap memory; assignment moves ownership to t, invalidating s.
Fixes: use t instead · clone: let t = s.clone(); · borrow: let t = &s; · restructure so the function returns the value back.
E0384 — cannot assign twice to immutable variable
let x = 5;
x = 6; // error: cannot assign twice to immutable variable `x`Why: let bindings are immutable by default — this is a feature.
Fixes: let mut x = 5; · or shadow: let x = 6; (creates a new binding).
E0502 / E0499 — cannot borrow as mutable (also as immutable)
let mut v = vec![1, 2, 3];
let first = &v[0]; // shared borrow starts
v.push(4); // error[E0502]: cannot borrow `v` as mutable
println!("{first}"); // …because the shared borrow is used hereWhy: one mutable borrow XOR many shared borrows. This is what prevents data races.
Fixes: copy the value out (let first = v[0];) · finish using the shared borrow first · restructure the code into sequential phases · use split_at_mut to borrow two disjoint parts.
E0282 — type annotations needed
let x = "5".parse().unwrap(); // error: type annotations needed
let y: i32 = "5".parse().unwrap(); // ✅ annotate
let z = "5".parse::<i32>().unwrap(); // ✅ turbofishWhy: parse can produce any type implementing FromStr, and there is no other clue.
E0277 — the trait bound is not satisfied
#[derive(Debug)]
struct Point { x: f64, y: f64 }
let p = Point { x: 1.0, y: 2.0 };
println!("{}", p); // error: `Point` doesn't implement `std::fmt::Display`Why: {} requires Display; Point only derives Debug.
Fixes: use {:?} · or impl fmt::Display for Point by hand (it can't be derived).
General form: "the trait bound X: Trait is not satisfied" — either implement Trait for X, or relax the bound on your function.
E0597 / E0596 — does not live long enough / cannot borrow as mutable
let r;
{
let x = 5;
r = &x; // error: `x` does not live long enough
}
println!("{r}");Why: the reference would outlive the value. Rust forbids dangling pointers at compile time.
Fixes: declare x in the outer scope · return the value instead of a reference · use String/Vec and move the data.
E0599 — no method named X on type Y
Why: The method doesn't exist, or its trait isn't in scope. Very common with Iterator, Read, Write, FromStr.
Fixes: check the spelling · add use std::io::Read; (or the relevant trait) · check you're not holding &T when you need &mut T · check the docs for the correct method name.
E0308 — mismatched types
let x: i32 = 5;
let y: f64 = x; // error: expected `f64`, found `i32`
let y = x as f64; // ✅ explicit castWhy: Rust never implicitly converts numeric types — no hidden truncation or overflow.
Fixes: as cast · .parse() · Into/From conversions · change the annotation.
E0601 — main function not found
Why: you're building a binary crate without fn main().
Fixes: add fn main() {} · or add [lib] to Cargo.toml if you meant to build a library.
"temporary value dropped while borrowed"
let s = String::from("a").split('.').next().unwrap();
// error: temporary value dropped while borrowedWhy: the temporary String created inline is destroyed at the end of the statement, but you kept a borrow of it.
Fixes: bind it first: let raw = String::from("a"); let s = raw.split('.').next().unwrap();
Projects & a realistic study plan
You cannot learn Rust by reading alone. This chapter gives you a 30-day plan and projects that each teach exactly one hard concept, in the order that makes them stick.
A 30-day study plan
| Days | Focus | Do this | You'll be able to… |
|---|---|---|---|
| 1–3 | Tooling & syntax | Install rustup, read Chapters 2–4, run everything in the Playground | Write functions, use let/mut, loops, match |
| 4–7 | Ownership (hard!) | Chapter 5 twice. Deliberately trigger E0382/E0502 and read the messages | Explain move vs copy, borrow rules, slices |
| 8–10 | Structs & enums | Chapter 7. Build a tiny CLI calculator | Model data with enums, use match exhaustively |
| 11–14 | Collections & errors | Chapters 8–9. Build a word-frequency counter | Use Vec/HashMap, propagate errors with ? |
| 15–18 | Generics & traits | Chapter 10. Implement Display + From for your types | Write generic functions, understand bounds |
| 19–21 | Modules & cargo | Chapter 11. Split your project into modules, publish docs | Structure a real project |
| 22–24 | Closures & iterators | Chapter 12. Refactor loops into iterator chains | Write idiomatic, fast functional code |
| 25–27 | Smart pointers | Chapter 13. Build a doubly-linked list / tree with Rc<RefCell>> | Know when to reach for Box/Rc/Arc |
| 28–29 | Concurrency | Chapter 14. Parallelise a data pipeline with rayon | Spawn threads, use Arc<Mutex>, channels |
| 30 | Review & ship | Take a project from idea to cargo publish --dry-run | Ship a complete, tested, documented Rust crate |
Project ladder — each one teaches a specific concept
1. Temperature converter CLI
Concepts: std::env::args, parsing, Result, basic functions. An afternoon's work.
2. Guessing game
Concepts: rand crate, loop, match on cmp::Ordering, Option, user input. It's the Rust Book's classic first project.
3. Word frequency analyser
Concepts: HashMap, file I/O, iterators, sorting, error propagation with ?.
4. Markdown → HTML converter
Concepts: string slicing, enum state machine, pattern matching, String vs &str.
5. JSON parser from scratch
Concepts: recursive enums, Box, custom error types, iterators. The best single exercise in this list.
6. Multi-threaded file indexer
Concepts: thread::spawn, channels, Arc<Mutex>, WalkDir, error aggregation.
7. Redis clone (mini-redis)
Concepts: tokio, async, TCP sockets, protocol parsing, connection state. Follow the tokio tutorial.
8. Your own ls/grep/jq
Concepts: clap, cross-platform APIs, serde, release profiles, publishing binaries.
9. A small interpreter (Lox/Brainfuck)
Concepts: trees, Rc<RefCell>, trait objects, visitor patterns, lifetimes.
10. WebAssembly game or tool
Concepts: wasm-bindgen, web-sys, JavaScript interop, size optimisation.
Best learning resources
| Resource | Best for | Style |
|---|---|---|
| The Rust Programming Language | Everyone — the canonical text | Free book, read cover to cover |
| Rustlings | Drilling syntax & compiler errors | Dozens of tiny exercises |
| Rust by Example | Looking up "how do I…?" | Code-first snippets |
| Comprehensive Rust (Google) | Structured classroom-style course | Slides + exercises |
| Rust Language Cheat Sheet / Practice | Intermediate exercises | Problem sets by topic |
| Exercism Rust track | Getting mentored feedback | Free, human mentors |
| docs.rs | Learning any crate's API | Auto-generated, with examples |
| Advent of Code | Practising under light pressure | Great fit for Rust's strengths |
| Zero To Production In Rust (book) | Building real web backends | Paid, project-based, excellent |
| Rust for Rustaceans (Jon Gjengset) | Going from intermediate to advanced | Paid, deep, opinionated |
Habits that make the borrow checker your friend
- Design the ownership first. Before writing code, ask "who owns this data, and for how long?"
- Return values instead of mutating arguments. It's simpler, more testable, and avoids most borrow conflicts.
- Prefer
&Tand&[T]over taking ownership ofTin functions. - Read the error message completely. The last two lines usually contain the fix.
- Use
cargo checkin the inner loop — it's 3–5× faster thancargo build. - Keep functions small while learning: fewer live borrows means fewer conflicts.
- Don't fight with
clone()silently — fix the ownership design instead. - Let the compiler drive. Every error is teaching you the model. It gets easier at roughly the two-week mark.
Cheat sheets & glossary
Keep this chapter open in a second tab. It's the condensed version of everything above — designed for quick lookup while you code.
Syntax quick reference
// ─── VARIABLES ────────────────────────────────────────────────────────────
let x = 5; // immutable
let mut y = 5; // mutable
let z: f64 = 2.5; // explicit type
const N: u32 = 100; // compile-time constant
let shadow = "hi"; let shadow = shadow.len(); // shadowing (may change type)
// ─── TYPES ────────────────────────────────────────────────────────────────
i8 i16 i32 i64 i128 isize // signed integers
u8 u16 u32 u64 u128 usize // unsigned integers
f32 f64 // floats
bool char // 1 byte / 4 bytes (Unicode scalar)
() // unit type (like void)
(i32, f64) // tuple
[i32; 4] // fixed array
&[i32] // slice
Vec<i32> // growable list
String / &str // owned string / string slice
HashMap<K, V> HashSet<T> // hash collections
Option<T> Result<T, E> // absence / fallibility
Box<T> Rc<T> Arc<T> // smart pointers
// ─── FUNCTIONS ────────────────────────────────────────────────────────────
fn add(a: i32, b: i32) -> i32 { a + b } // tail expression
fn nothing() { } // returns ()
fn never() -> ! { panic!() } // diverging
fn generic<T: Trait>(x: &T) -> &T { x } // generics
|a, b| a + b // closure
// ─── CONTROL FLOW ─────────────────────────────────────────────────────────
if cond { } else if cond2 { } else { }
let v = if cond { 1 } else { 2 }; // if is an expression
let v = loop { break 42; }; // loop with a value
while cond { }
for i in 0..10 { } // exclusive range
for x in &collection { } // borrow iteration
'outer: for x in 0..5 { break 'outer; } // labelled break
// ─── MATCHING ─────────────────────────────────────────────────────────────
match value {
Pattern => expr,
1 | 2 => expr, // alternation
10..=20 => expr, // range
x if x > 5 => expr, // guard
_ => expr, // catch-all (required)
}
if let Some(x) = opt { } // single-case match
while let Some(x) = iter.next() { }
let Some(x) = opt else { return; }; // let-else
// ─── DATA ─────────────────────────────────────────────────────────────────
struct User { name: String } // named fields
struct Point(f64, f64); // tuple struct
struct Marker; // unit struct
enum Color { Red, Rgb(u8,u8,u8) } // data-carrying enum
impl User {
fn new() -> Self { … } // associated fn: User::new()
fn name(&self) -> &str { &self.name } // method: u.name()
}
// ─── TRAITS ───────────────────────────────────────────────────────────────
trait Greet { fn hi(&self) -> String; } // required method
trait Greet { fn hi(&self) -> String { … } } // default method
impl Greet for User { … } // implement
fn f<T: Greet + Clone>(x: &T) { … } // bounds
fn f(x: &impl Greet) { … } // impl Trait
fn f(x: &dyn Greet) { … } // dynamic dispatch
Box<dyn Error> // trait object
// ─── ERROR HANDLING ───────────────────────────────────────────────────────
fn f() -> Result<i32, Box<dyn Error>> {
let n = may_fail()?; // propagate
Ok(n)
}
opt.unwrap_or(0); opt.expect("why"); opt.map(f); opt.and_then(f);
res.unwrap_or_else(|e| 0); res.map_err(f); res.ok_or("err");
// ─── CONCURRENCY ──────────────────────────────────────────────────────────
thread::spawn(move || { … }); // OS thread
let (tx, rx) = mpsc::channel(); // channel
Arc::new(Mutex::new(v)) // shared mutable state
async fn f() { g().await; } // async
// ─── MODULES ──────────────────────────────────────────────────────────────
mod foo; // declares a module
use crate::foo::Bar; // import
pub use foo::Bar; // re-export
pub(crate) fn helper() { } // crate-visible
// ─── ATTRIBUTES ───────────────────────────────────────────────────────────
#[derive(Debug, Clone, PartialEq)]
#[cfg(test)]
#[allow(dead_code)]
#[inline]
#[must_use]
#[repr(C)]
#[tokio::main]
Common methods worth memorising
| Task | Code |
|---|---|
| String → number | "42".parse::<i32>()?; |
| Number → string | n.to_string() · format!("{n}") |
| Read a file to string | std::fs::read_to_string("f.txt")? |
| Write a file | std::fs::write("f.txt", contents)? |
| Command-line args | std::env::args().skip(1).collect::<Vec<_>>() |
| Environment variable | std::env::var("HOME") → Result<String> |
| Current time | std::time::Instant::now() · elapsed() |
| Iterate a map | for (k, v) in &map { } |
| Sort a vec | v.sort() · v.sort_by_key(|x| x.len()) |
| Filter + map + collect | it.filter(..).map(..).collect::<Vec<_>>() |
| Sum / min / max | it.sum::<i32>() · it.min() · it.max() |
| Option → Result | opt.ok_or("error")? |
| Result → Option | res.ok() · res.err() |
| JSON (serde) | serde_json::to_string(&value)? |
| HTTP request | reqwest::get(url).await?.text().await? |
| Parallel map | data.par_iter().map(..).sum() (rayon) |
Glossary A–Z
impl block without self. Called as Type::function().&T) and mutable (&mut T) flavours.lib.rs) or a binary (main.rs).drop(&mut self) runs automatically when a value goes out of scope — Rust's deterministic destructors.&dyn Trait uses a vtable at runtime instead of compile-time monomorphisation.if and match are all expressions in Rust.extern "C".Drop.*const T / *mut T. Unchecked, may be null or dangling — dereferencing requires unsafe.Result<T, E> — either Ok(T) or Err(E). Rust's primary error-handling mechanism.pointer, length) into a contiguous sequence: &[T] or &str.collect::<Vec<i32>>(). The ::<> shape.Test yourself
Answer each question in your head first, then open the answer. Getting one wrong is more valuable than getting it right — it points at exactly what to review.
1. Why does let s2 = s1; invalidate s1 when s1 is a String, but not when it's an i32?
String owns a heap allocation and does not implement Copy, so assignment moves ownership — otherwise both variables would free the same memory (double-free). i32 implements Copy: it lives entirely on the stack and duplication is trivial, so assignment makes a bitwise copy and both stay valid.
2. What are the three rules of ownership?
1. Every 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 (freed).
3. You have &v[0] and then call v.push(1) — the compiler refuses. Why?
push may reallocate the Vec's heap buffer to make room, which would invalidate the reference to the first element. The borrow checker is preventing a dangling pointer. Copy the value out, or end the borrow before mutating.
4. What is the difference between &T and &mut T at the rule level?
At any moment you may have either exactly one &mut T, or any number of &T — never both. This is what makes data races impossible in safe Rust.
5. Why does Rust have no null, and what replaces it?
Null references allow dereferencing nothing at runtime (the "billion-dollar mistake"). Rust replaces it with Option<T>, an enum with Some(T) and None. The compiler forces you to handle the None case before you can access the value.
6. What does the ? operator actually do?
On Ok(v)/Some(v) it unwraps to v. On Err(e)/None it returns early from the enclosing function, converting the error via From if needed. It's shorthand for a match with an early return.
7. When would you use dyn Trait instead of generics?
When you need a runtime choice of type: a Vec holding different implementations of a trait, a plugin system, or when you want to reduce compile time and binary size. Otherwise static dispatch (generics) is faster and the default.
8. What is the difference between String and &str?
String is owned, heap-allocated and growable. &str is a borrowed slice (pointer + length) into UTF-8 bytes — no allocation, no ownership. Function parameters should generally take &str for flexibility.
9. Why can't you index a String with s[0]?
Because a byte index may fall in the middle of a multi-byte UTF-8 character, producing invalid UTF-8 — a safety invariant of String. Use s.chars(), s.bytes(), or slice on char boundaries.
10. What is the difference between Rc<T> and Arc<T>?
Both provide shared ownership via reference counting. Rc uses non-atomic counters and is single-threaded only (!Send). Arc uses atomic operations, is thread-safe, and is very slightly slower.
11. What does "fearless concurrency" actually mean technically?
Because data can only be mutated when there is exactly one mutable reference, and references must be Send/Sync to cross thread boundaries, the compiler rejects data races at compile time. Concurrency bugs that are runtime errors elsewhere are compile errors in Rust.
12. What are monomorphisation's costs and benefits?
Benefit: generic code is specialised per type and inlined, giving zero-overhead abstractions and peak runtime performance. Cost: longer compile times and larger binaries, since one copy of the code is generated per concrete type used.
13. When should you reach for unsafe?
Almost never, and never to silence the borrow checker. Use it for FFI, raw pointer manipulation, intrusive data structures, or performance-critical internals — always wrapped in a small, well-tested, documented safe API.
14. In what area does Rust clearly lose to Go and C++?
Compile speed (especially clean builds) and learning curve. Go also wins on hiring pool and sheer simplicity of goroutines for typical network services. Rust's advantages are runtime performance, memory footprint and correctness guarantees.
You understand Rust's mental model. Go build something real — the syntax will follow naturally. If you got fewer than 8, re-read Chapter 05 and Chapter 10 — those two concepts explain almost everything else in the language.
The single most important thing to remember: the borrow checker is not your enemy. It is the compiler telling you, right now, about the bug that would have woken you up at 3am in production.