Quick Rust: A Quick Guide for Everything Rust When You’re too Lazy to Relearn It
Hello there! Thanks for checking out my book.
I tend to be in the habit of documenting my learning process as I go; so when I started learning Rust, I obviously did that exact same thing; but to my surprise, I started enjoying Rust and its ecosystem so much that my “documenting” turned out to be a lot more detailed and polished than I anticipated. This book is the result of that.
Initially this book began as me taking .md notes on stuff I knew I’d be too lazy to relearn later. So, by definition, this book is for people who already know Rust and want to quickly revisit a topic they may have forgotten.
This isn’t meant to replace The Rust Programming Language book. It’s the book I wish I had after finishing it; a place to quickly refresh concepts without rereading entire chapters or digging through scattered documentation.
At some point, I found out about an amazing tool called mdBook which helps you create deployable online books from .md files and it seemed like the perfect way to turn my notes into something others could use.
Each chapter begins with the relevant computer science background before diving into how Rust implements those ideas.
Disclaimer: LLMs were used extensively while creating this book; for research, fact-checking and editing. Every effort has been made to ensure the material is accurate. This book is freely available and was created primarily as a learning resource and personal reference.
Introduction
The Rust Programming Language
Rust is a systems programming language focused on performance, memory safety, concurrency, and reliability. It was originally created at Mozilla and is now maintained by the Rust Foundation and the open-source community.
Rust is often compared to languages like C and C++ because it gives low-level control over memory and hardware while still producing highly optimized native binaries. However, unlike C/C++, Rust prevents many common bugs at compile time through its ownership and borrowing system. This includes things like null pointer dereferences, use-after-free bugs, double frees, data races, and dangling pointers.
One of Rust’s main goals is “fearless concurrency.” The compiler enforces rules that make multithreaded code much safer than in many traditional systems languages.
Cargo
Cargo is Rust’s build system and package manager. It is one of the biggest reasons Rust has such a pleasant developer experience.
Cargo handles tooling concerns such as:
- Project creation
- Dependency management
- Building
- Running
- Testing
- Documentation generation
- Publishing crates
# To create new projects
cargo new my_project
# To create new library
cargo new my_lib --lib
# To build the projects
cargo build
# To build for specific profiles
cargo build --release
# To checkwhether a code builds successfully without building it
cargo check
# To generate documentation for the current project and its dependencies; the --open flag opens the generated docs in the browser
cargo doc --open
Cargo projects have a Cargo.toml manifest file in the root which contains metadata and dependencies about the project and a Cargo.lock file that cargo uses to manage dependency version resolution for reproducible builds.
Cargo Terminology
In the Rust eco-system and Cargo there are a few overlapping terms that refer to the concept of a unit of code and how these units are organized. The following explains and clarifies them.
-
Package
- A package is the broadest unit, it encompasses crates and modules.
-
Crate:
- A Crate is Rust’s fundamental compilation unit; when Rust compiles, it compiles crates.
- There are two types of Crates:
-
Binary Crate: Binary crates produce executables for our apps.
// src/main.rs fn main() { let name = "Ali"; println!("Hello, {name}"); } -
Library Crate: A library crate produces reusable code that other crates can use.
#![allow(unused)] fn main() { // src/lib.rs pub fn greet(name: String) { println!("Hello, {name}"); } }
-
-
Module:
- Modules are ways to organize code inside crates; we can use them to create namespaces and split code inside of crates. A crate can have many modules.
So, a Cargo package can contain many binary crates but no more than one library crate; though that single library crate can contain many modules.
my_package/ # Package
Cargo.toml # Package Manifest
src/
lib.rs # Library Crate
main.rs # Binary Crate
utils.rs # Module
auth.rs # Module
src/bin/
tool1.rs # Binary Crate
tool2.rs # Binary Crate
tool3.rs # Binary Crate
More on Cargo, Crates and managing complex projects later…
Common Programming Concepts in Rust
Variables
By default, all variables in rust are immutable, meaning that their value cannot be changed afterwards; this encourages safer code. In order to declare variables that can change we have to specify them as such. Rust also has constants which are pretty similar to consts in other languages; they are declared with the const keyword, must be initialized with constant expressions, are evaluated at compile time, cannot mutate whatsoever etc.
The following snippet show a few example of variable declarations:
#![allow(unused)]
fn main() {
// Standard immutable variable
let var1: i32 = 1234;
// Mutable variable
let mut var2: i32 = 1234;
var2 = 5678;
// Constant variable
const MAX_PLAYERS: u32 = 100;
}
Shadowing
Shadowing means declaring a new variable with the same name as a previous variable; essentially, shadowing the previous variable. Shadowing also allows us to change the type, mutability of a variable, type conversions, and ownership transitions.
#![allow(unused)]
fn main() {
let x = 5;
let x = x + 1;
let x = x * 2;
println!("{x}"); // Prints 12 to the terminal
let x: String = "Not a number anymore".to_string();
x.push_str("Mutating x"); // Causes compiler error
let mut x: String = x;
x.push_str("Mutating x");
}
Types in Rust
Rust’s type system has two main families of data types; Scalar Types and Compound Types.
Scalar Types
Scalar types represent a single value. Rust has four main scalar types:
- Integers:
#![allow(unused)]
fn main() {
let signed32bit: i32 = -1234;
let unsigned32bit: u32 = 1234;
// All available int types:
// i8, i16, i32, i64, i128, isize (Architecture dependant)
// u8, u16, u32, u64, u128, usize (Architecture dependant)
}
- Floating-point numbers:
#![allow(unused)]
fn main() {
let a32bit_float: f32 = 3.14;
let a64bit_float: f64 = 3.14;
}
- Booleans:
#![allow(unused)]
fn main() {
let b1: bool = true;
let b2: bool = false;
}
- Characters:
#![allow(unused)]
fn main() {
// Characters use ''; strings use "".
let letter: char = 'A';
let emoji: char = '🔥';
}
Compound Types
Compound types group multiple values. Rust has two built-in compound types:
- Tuples:
#![allow(unused)]
fn main() {
// Tuples can contain values of different types
let person: (&str, i32, bool) = ("Ali", 22, true);
println!(
"Name: {}, Age: {}, Married: {}",
person.0, person.1, person.2);
}
- Arrays:
#![allow(unused)]
fn main() {
// Arrays are contiguous blocks of memory.
// Have fixed size.
// Are stack allocated by default
let numbers: [i32; 5] = [1, 2, 3, 4, 5];
println!("{}", numbers[0]);
}
Expressions vs Statements
Rust distinguishes between statements and expressions very clearly. A statement performs an action but does not result in (return) a value. An expression on the other hand always results in a value.
#![allow(unused)]
fn main() {
let x = 1; // Statement
fn funny_func() -> i32 { 5 } // Statement
1 + 1 // Expression
5 + funny_func() // Expression
// In Rust, this:
5 // no semicolons
// is the same as this:
return 5;
}
Preludes
A prelude in Rust is a collection of commonly used items that are automatically imported into scope. The standard prelude allows you to use many common types and traits without manually importing them.
For example:
#![allow(unused)]
fn main() {
// These are available automatically because of the standard prelude.
Vec<T>
Option<T>
Result<T, E>
Drop
Copy
// That’s why this works without imports
let numbers = Vec::new();
}
Under the hood, Rust implicitly imports the standard library’s prelude into every module. The prelude mainly exists for ergonomics. Without it, many common programs would require constant imports.
Traits (more on traits later) in the prelude are especially important because trait methods only work when the trait is in scope. For example, iterator methods like .map() and .filter() work because iterator traits are in the prelude.
So generally, The standard library prelude import is automatic. Library preludes are manual convenience imports; their purpose is reducing repetitive use statements and improving ergonomics.
Ownership & Borrowing
Ownership
Ownership is Rust’s core memory management model. It replaces the need for a garbage collector while still preventing many memory safety bugs at compile time. In Rust, every value has an owner. The owner is the variable responsible for cleaning up that value when it is no longer needed. When s goes out of scope, Rust automatically frees the memory.
#![allow(unused)]
fn main() {
{
let s = String::from("hello");
} // s goes out of scope here, memory is freed
}
Rust’s ownership system is built around three main rules These rules allow Rust to know exactly when memory should be cleaned up, entirely at compile time.
- Each value has exactly one owner.
- A value can only have one owner at a time.
- When the owner goes out of scope, the value is dropped (memory freed).
Ownership mainly matters for heap-allocated data like: Simple stack values like integers often behave differently because they implement Copy (more on Copy later).
#![allow(unused)]
fn main() {
String
Vec<T>
Box<T>
// & custom heap structures
}
Stack vs Heap
Simply put, the stack and heap are two different memory regions used by programs and runtimes.
- The stack is very fast, ordered, automatically managed. Stack memory works like a stack of plates (push values on top, pop values off the top). Data on the stack must have a known fixed size at compile time.
- The heap is more flexible, slower, manually managed in many languages, and used for dynamically sized data. Heap allocation is needed when size is unknown at compile time, data may grow/shrink, data must outlive certain scopes (very important), large allocations are needed.
Usually, Heap allocation requires, requesting memory from the allocator, tracking ownership, freeing memory later. Rust’s ownership system automates this safely without garbage collection.
RAII: Resource Acquisition Is Initialization
In C++, RAII is a major idiom where resources are tied to object lifetime. it’s when an object acquires resources in its constructor and releases them in its destructor.
// When the object leaves scope, cleanup happens automatically.
{
File file("data.txt");
} // destructor automatically closes file
Rust uses a very similar idea with the ownership system. When a value goes out of scope, Rust automatically calls its drop logic.
#![allow(unused)]
fn main() {
// Internally, String implements the Drop trait.
{
let s = String::from("hello");
} // drop called automatically
}
Unlike C++, Rust additionally enforces ownership rules statically, preventing:
- double free
- use after free
- dangling pointers
- Basically RAII + strict compile-time ownership enforcement
Move
A move transfers ownership from one variable to another.
#![allow(unused)]
fn main() {
let s1 = String::from("hello");
let s2 = s1;
s1 // invalid
s2 // owns the String
}
In the above example, s1 owns heap memory. If both s1 and s2 owned the same memory, Rust could accidentally free it twice; so Rust invalidates s1 after a move.
Moves happen:
- During assignment (like above)
- When passing ownership into functions/methods
- When returning ownership in many pattern matching situations
But not all types move. Simple stack-only types like integers implement the Copy trait; so, instead of moving, their values are copied.
A type cannot implement the Copy trait if it implements the Drop trait. The reason why has to do with a combination of references and the heap (more on this coming up).
References
Simply put, A reference allows you to access a value without taking ownership of it. Allowing safe access while avoiding unnecessary moves or cloning.
#![allow(unused)]
fn main() {
let mut s: String = String::from("hello");
// We use the & operator to pass references
let s_ref: &String = &s;
println!("{s_ref}");
// &mut operator for mutable references
let s_mut_ref: &mut String = &mut s;
s_mut_ref.push_str(", world!");
}
The above example contains a mutable variable; mutable variables can have mutable references (their values can be changed using their references) but the opposite is not true.
Borrowing
Borrowing means temporarily accessing a value through references without taking ownership.
fn calc_len(s: &String) -> i32 {
// Borrows s and returns its length
return s.len();
}
fn main() {
let s = String::from("hello");
let len = calc_len(&s);
println!("{len}");
}
There are 4 very important rules for borrowing:
- You can have any number of immutable references.
- You can have only one mutable reference at a time.
- Mutable and immutable references cannot coexist simultaneously.
- References must always be valid (no use-after-frees).
The reasons for these rules boil down to preventing data races and invalid memory access.
The Borrow Checker
The borrow checker is the part of the compiler that enforces Rust’s ownership and borrowing rules. It statically analyzes code before execution and verifies references never outlive data, no dangling references exist, borrowing rules are obeyed, and mutable aliasing violations do not occur.
The borrow checker is one of Rust’s defining features. It enables static enforcement of memory/thread safety, no garbage collection overhead, and no runtime borrow tracking in normal safe code.
The following snippet highlight the difference in semantics between moving and borrowing.
fn x_moved(x: String) {
// x is moved to this scope and is freed afterwards
println!("{x}");
}
fn x_moved_and_returned(x: String) -> String {
// x is moved to this scope and is
// returned to the calling scope afterwards
println!("{x}");
return x;
}
fn x_borrowed(x: &String) {
println!("{x}");
}
fn x_borrowed_and_changed(x: &mut String) {
x.push_str("goodbye, world!");
println!("{x}");
}
fn main() {
let mut x: String = String::from("hello, world!");
x_borrowed(&x);
println!("{x}"); // x still available
// we couldn't have done the following
// if x wasn't a mutable variable in the first place
x_borrowed_and_changed(&mut x);
println!("{x}"); // x still available with its value changed
x = x_moved_and_returned(x);
println!("{x}"); // x still available
x_moved(x);
// Causes compiler error because x was dropped
println!("{x}");
}
Dangling References
A dangling reference is a reference pointing to memory that has already been freed. This is a major source of bugs in languages like C and C++. Rust prevents dangling references entirely with the help of the borrow-checker, ownership semantics, and lifetimes.
#![allow(unused)]
fn main() {
// This won't compile since 's' is destroyed at function end
// and the returned reference would point to freed memory.
fn dangle() -> &String {
let s = String::from("hello");
&s // return &s;
}
// Instead, ownership should be returned:
fn no_dangle() -> String {
let s = String::from("hello");
s // return s;
}
}
Structuring Related Data with Structs
Structures
Structs are custom data types that let you group related data together under a single type. They are similar to structs/classes in other languages, but Rust structs only store data by themselves. Behavior is added separately through impl blocks.
There are three types of structs in Rust:
- Normal Structs:
// A normal struct is defined using the struct keyword
struct User {
username: String,
email: String,
active: bool,
sign_in_count: u64,
}
fn main() {
let username = String::from("ali");
// Instantiated like this
let user1 = User {
username, // field init shorthand syntax
email: String::from("example@email.com"),
active: true,
sign_in_count: 1,
};
// Properties are accessed with the . syntax
println!("{}", user1.email);
// Struct .. syntax
let user2 = User {
email: String::from("example@email.com"),
..user1 // *moves* the rest from user1 to here
}
}
- Tuple-Structs: Tuple structs are structs whose fields are unnamed. They behave like tuples but create a distinct type. They’re kind of like “Positional Records” in C# but the fields are unnamed.
// This is how we define tuple-structs
struct Rgb(u8, u8, u8);
struct Point(i32, i32, i32);
fn main() {
// Instantiated like this:
let black: Rgb = Rgb(0, 0, 0);
let origin: Point = Point(0, 0, 0);
// Access happens the same as it happens with tuples
let r = black.0;
let g = black.1;
let b = black.2;
}
// Useful for when field names are unnecessary
// or you want lightweight wrappers around your values.
struct UserId(u64);
struct ProductId(u64);
- Unit-Like Structs: Unit-like structs are structs with no fields; useful to use as marker types, zero sized types, or implementing traits without storing state, etc. They’re called unit-like because they resemble the unit type “()”.
// Defined like this
struct Logger;
impl Logger { // More on this in the next section
fn log(&self, msg: &str) {
println!({msg});
}
}
fn main() {
// Instantiated like this
let logger = Logger;
logger.log("hello, world!");
}
Methods
Methods are functions associated with a type. They are defined inside impl blocks, take “self” as the first parameter, and are called with the dot ‘.’ syntax.
struct Rectangle {
width: u32,
height: u32,
}
impl Rectangle {
fn area(&self) -> u32 {
return self.width * self.height;
}
}
fn main() {
let rect = Rectangle {
width: 30,
height: 50,
};
println!("The rectangle's area is {}", rect.area());
}
More on “self”:
“self” is shorthand for “self: Self”. The “Self” type itself is a placeholder for whatever type comes after the impl block.
#![allow(unused)]
fn main() {
struct Point(i32, i32, i32)
impl Point {
fn print_point(self: &Self) /*Self = Point*/ {
println!("{} {} {}", self.0, self.1, self.2);
}
}
}
Rust supports three common forms of self:
struct Example {
x: i32,
y: i32,
}
impl example {
// 1. Ownership consuming self
fn method1(self) {};
// 2. Immutable borrow
fn method2(&self) {};
// 3. Mutable borrow
fn method3(&mut self) {};
}
fn main() {
let e = Example {
x: 12,
y: 34,
};
// e becomes invalid after this. Because it was
// moved and freed inside of method1().
e.method1();
}
Associated Functions
Associated functions are functions tied to a type but without a self parameter. Associated functions are commonly used as constructors, utility functions, conversion helpers, factory methods, etc. They are similar to static methods in C#.
struct Rectangle {
width: u32,
height: u32,
}
impl Rectangle {
// Takes no self
fn square(size: u32) -> Rectangle {
Rectangle {
width: size,
height: size,
}
}
}
fn main() {
// Called like this
let sq = Rectangle::square(10);
// Instead of something sq.square()
}
Ownership & Structs
Structs fully participate in Rust ownership rules. Each field inside a struct owns its data unless the field itself is a reference.
struct User {
// Here, the struct owns the String.
username: String,
}
fn main() {
// When the struct is dropped, all owned
// fields are dropped automatically.
let user1 = User {
username: String::from("ali"),
};
// Moving a struct moves all non-Copy fields;
// after this point user1 is invalid
// because ownership moved it to user2.
let user2 = user1;
} // username String is dropped here
An important detail is partial moves.
struct Person {
name: String,
age: u32,
}
fn main() {
let person = Person {
name: String::from("Ali"),
age: 22,
};
// Here, person.name is moved out.
let name = person.name;
// But this works with no error because
// u32 implements Copy
println!("{}", person.age);
// As expected this does not work since
// the person struct was partially moved.
// aka. person does not own the value inside of
// name property anymore.
println!("{:?}", person);
}
Enums, Patterns & Matching
Enums
Enums (enumerations) are types that allow a value to be one of several possible variants. They allow you to model states and data safely and explicitly. Unlike many languages, Rust each variant of an enum can also contain data.
// Defined like this
enum IpAddrKind {
V4,
V6,
}
// Variants can contain data
enum IpAddr {
V4(u8, u8, u8, u8),
V6(String),
}
fn main() {
// Instantiated like this
let four = IpAddrKind::V4;
let six = IpAddrKind::V6;
let home = IpAddr::V4(127, 0, 0, 1);
let loopback = IpAddr::V6(String::from("::1"));
}
More on variants with data:
There are different styles of enum variants with data. The following snippet highlights them:
// Enum variants
enum EnumVariants {
A, // This is a Unit-like variant
B(i32, u8), // This is a Tuple-like variant
C(f32), // This is also a Tuple-like variant
D { id: i32, cont: u32 }, // This is a Struct-like variant
}
fn main() {
let unit_like: EnumVariants = EnumVariants::A;
let tuple_like1: EnumVariants = EnumVariants::B(1, 2);
let tuple_like2: EnumVariants = EnumVariants::C(3.14);
let struct_like: EnumVariants = EnumVariants::D {
id: 1234,
cont: 5678,
};
}
Patterns
Patterns and pattern matching are essential parts of Rust and I’ll cover them in more depth later; for now, think of patterns as Rust’s special way of describing/understanding the shape of data. Once we describe the shape of some data correctly, we can extract or bind it from/to variables and types.
#![allow(unused)]
fn main() {
// `(x, y)` is the pattern here. This reads as:
// "I expect a tuple with two elements,
// the first goes inside of 'x' the second goes in 'y'".
let (x, y) = (10, 20)
}
I’ll explain patterns more as I cover match, and the other pattern matching constructs.
The match Construct
match is Rust’s primary pattern matching construct.
It compares a value against patterns and executes matching branches.
match is expression-based and it returns values.
fn main() {
let number = 3;
// Each branch is called an arm.
match number {
1 => println!("one"),
2 => println!("two"),
3 => println!("three"),
_ => println!("other"), // Catch-all/Default
};
// Can be used with let as well
let result = match number {
1 => "one",
2 => "two",
3 => "three",
_ => "other",
};
}
Example with enums:
enum Message {
Quit,
Move { x: i32, y: i32 },
Write(String),
}
fn main() {
let msg = Message::Write(String::from("hello"));
match msg {
// This is a pattern; it reads as:
// "if `msg` is the `Quit` variant of the Message enum
// then do whatever the arrow's pointing to"
Message::Quit => println!("quit"),
// This is a pattern; it reads as:
// "if `msg` is the `Move` variant of
// the `Message` enum containing `x` and `y`,
// pass x and y into the incoming scope"
Message::Move { x, y } => {
println!("{x}, {y}");
}
// Also a pattern, you get the gist.
Message::Write(text) => {
println!("{text}");
}
}
}
- One very important rule to note is the fact that
matches are exhaustive; meaning, all possible cases should be either covered individually or with a_.
Other Pattern Matching Constructs
In Rust, there are other pattern matching constructs aside from match; mainly for reducing boilerplate.
1. if let
Useful for when we only care about one pattern and don’t want to cover every case.
#![allow(unused)]
fn main() {
let cmd = Command::Delete(42);
// With match
match cmd {
Command::Delete(id) => {
println!("Deleting {id}");
}
_ => {}
};
// With if let
if let Command::Delete(id) = cmd {
println!("Deleting {id}");
}
}
let Command::Delete(id) is a pattern; it’s describing a value that’s the Delete() variant of the Command enum.
if let Command::Delete(id) = cmd checkswhether the cmd
variable matches the pattern we described. If it does, the field inside its Delete(...) is bound to the id variable and the block executes.
2. if let ... else
Used to handle the else case when the if let block doesn’t execute.
#![allow(unused)]
fn main() {
let name = None;
// With match
match name {
Some(name) => println!("Hello {name}"),
None => println!("No name found"),
}
// With if let ... else
if let Some(name) = name {
println!("Hello {name}");
} else {
println!("No name found");
}
}
3. else if let
Used to check multiple patterns.
#![allow(unused)]
fn main() {
let cmd = Command::Read(10, 1);
// With match
match cmd {
Command::Write => println!("Write"),
Command::Read(fd, flags) => println!("Read: {fd}, {flags}"),
_ => println!("Something else"),
}
// with else if let
if let Command::Write = cmd {
println!("Write");
} else if let Command::Read(fd, flags) = cmd {
println!("Read: {fd}, {flags}");
} else {
println!("Something else");
}
}
4. let ... else
Similar to if let ... else but often cleaner.
#![allow(unused)]
fn main() {
// With match
fn greet1(name: Option<&str>) {
match name {
Some(name) => println!("hello {name}"),
None => println!("hello stranger"),
};
}
// With if let ... else
fn greet2(name: Option<&str>) {
if let Some(name) = name {
println!("hello {name}");
} else {
println!("hello stranger");
}
}
// With let ... else
fn greet3(name: Option<&str>) {
let Some(name) = name else {
println!("hello stranger");
return;
};
println!("hello {name}");
}
}
5. while let
Used when we want a loop to run as long a pattern matches.
#![allow(unused)]
fn main() {
let mut stack = vec![1, 2, 3, 4, 5];
while let Some(value) = stack.pop() {
println!("{value}");
} // Stops running when there are no more values to pop
}
Common Collections in Rust
Vectors
In Rust, Vectors are dynamically sized contiguous collections. They store elements of the same type in heap and can grow or shrink at runtime.
Internally, Vectors are pointers to heap memory with length, and capacity metadata.
fn main() {
// Initialized like this
let mut v1: Vec<i32> = Vec::new();
// or with the vec![] macro
let v2: Vec<i32> = vec![1, 2, 3, 4, 5];
// Reading
let third: i32 = v2[2];
let third: Option<&i32> = v2.get(2); // Safer
// Writing
v1.push(32);
v1.push(12);
v1.push(223);
v1.push(3456);
v1.push(44);
v1.push(5678);
// Deleting
let last: Option<i32> = v1.pop(); // Deletes the last item and returns it
let third: i32 = v1.remove(2); // Removes and returns item at
// index 2 and shifts the remaining elements to left;
// Updating
v1[3] = 0; // By indexing or
// Getting a mutable reference
if let Some(mut_ref) = v1.get_mut(2) {
// dereferencing and updating it
*mut_ref = 0;
}
}
Vectors vs Arrays vs Slices
The similarities between Vectors, Arrays, and Slices might be confusing so here’s a breakdown:
-
Arrays: Arrays have a fixed size that’s known at compile time, they are allocated on the stack by default, and cannot grow or shrink.
-
Vectors: Vectors have dynamic size, they can grow and shrink, are allocated on the heap so they can be reallocated if they grow or shrink too much for their current chunk of memory.
-
Slices: Unlike Arrays and Vectors, a Slice does not own it’s content; they are simply borrowed views(references) of other contiguous data. They can reference both Arrays and Vectors.
Text in Rust
In Rust, text content is stored as UTF-8. UTF-8 is a variable-width Unicode encoding; meaning it allows for different characters to occupy different numbers of bytes.
#![allow(unused)]
fn main() {
let english = "hello";
let russian = "Здравствуйте";
let emoji = "🔥";
println!("{}", emoji.len()); // => 4
}
Text in Rust is stored with the &str and String types.
- A
Stringis an owned, growable UTF-8 string whose contents are stored on the heap. - A &str is a borrowed view into UTF-8 string data owned by something else.
Internally, strings are basically byte arrays:
#![allow(unused)]
fn main() {
"hello" == [104, 101, 108, 108, 111]
}
But non-ASCII text like “🔥” becomes multiple bytes. This variable-width encoding makes string indexing and iteration impossible.
Strings & Indexing
As I mentioned before the variable-length of UTF-8 characters makes it impossible for us to index into rust strings. The same thing is true for iteration. Instead we can do byte access like so:
#![allow(unused)]
fn main() {
let bytes = "hello, world!".as_bytes();
println!("{}", bytes[2]);
for b in bytes {
// ...
}
}
In the end, strings are pretty complex collection types. Rust does a great job at highlighting that complexity and limiting us in what we can do with them. So, for complex string-related functionality, it’s recommended to use already-existing Crates.
Hash Maps
Hash Maps are Rust’s standard implementation of what other languages call a Dictionary. It stores key value pairs.
They provide fast, average-case lookups.
HashMap keys must implement the Eq and Hash traits.
use std::collections::HashMap;
fn main() {
// Instantiated like this
let mut scores: HashMap<String, i32> = HashMap::new();
// Writing/Adding
scores.insert("Math".to_string(), 100);
scores.insert("History".to_string(), 87);
scores.insert("Geography".to_string(), 78);
// Conditional Insertion with the entry API
// This will get a mutable reference for the key "CS" if it exists
// if not, it will insert it with the value of 100 then returns a
// mutable reference to the newly added key-value pair
let cs_score: &mut i32 = scores.entry("CS".to_string()).or_insert(100);
// Reading
let math_score: Option<&i32> = scores.get("Math");
let history_score: Option<&i32>: Option<&i32> = scores.get("History");
let geo_score: Option<&i32> = scores.get("Geography");
// Updating/Overwriting
if let Some(score_ref) = math_score
/* Update */
{
// *score_ref = 66;
// This will cause compiler error; but it'd be possible
// if we used get_mut("Math") above.
}
scores.insert("Math".to_string(), 23); // Overwrite
// Delete
let removed: Option<i32> = scores.remove("Math");
}
Error Handling
Error Handling in Software
Error handling is the process of detecting, representing, and responding to failures or unexpected situations in a program.
Real software constantly encounters problems such as, files not existing, network requests failing, invalid inputs, etc. Without error handling, programs would regularly crash, corrupt data, and so on. This is why good error handling is a must.
Error Handling with Rust
Rust strongly emphasizes explicit error handling.
Instead of hiding failures through exceptions or nulls, Rust typically encodes failure directly in types such as Result<T, E>.
Rust Generally separates errors in two different categories.
- Unrecoverable Errors
- Recoverable Errors
Unrecoverable Errors with panic!()
The panic!() macro is Rust’s mechanism for unrecoverable errors.
It immediately stops normal execution because the program has reached an invalid or impossible state.
A panic! is commonly caused by out-of-bounds indexing, failed assertions, unwrap() on invalid values, or explicitly panic!ing.
#![allow(unused)]
fn main() {
panic!("Something went terribly wrong!");
}
Unlike other languages like C, this:
#![allow(unused)]
fn main() {
let v = vec![1, 2, 3];
v[99];
}
panics because the index is invalid. Rust chooses safety over undefined behavior.
When a panic occurs:
- The current thread begins termination
- Cleanup may occur based onwhether it was configured (in Cargo.toml) as
unwindorabort - An error message is printed
- Optionally a backtrace is shown
Recoverable Errors with Result<T, E>
The Result<T, E> enum is Rust’s primary recoverable error type. It has two variants:
Ok(T): Represents success and contains a value.Err(E): Represents failure and contains the error.
fn divide(a: f64, b: f64) -> Result<f64, String> {
if b == 0.0 {
Err(String::from("division by zero"))
} else {
Ok(a / b)
}
}
fn main() {
match divide(10.0, 2.0) {
Ok(value) => println!("{value}"),
Err(error) => println!("{error}"),
}
}
It’s also possible to branch differently based on the kind of error:
use std::fs::File;
use std::io::ErrorKind;
fn main() {
let result = File::open("hello.txt");
match result {
Ok(file) => file,
Err(error) => match error.kind() {
ErrorKind::NotFound => {
panic!("File not found");
}
other_error => {
panic!("Problem opening file: {other_error:?}");
}
},
};
}
unwrap() & expect()
unwrap() and expect() are two methods that are used to extract success values from the Option<T> and the Result<T, E> types. Here, we’ll focus on Result<T, E>:
unwrap()
fn main() {
let x: Result<i32, &str> = Ok(5);
println!("{}", x.unwrap()); // => 5
let x: Result<i32, &str> = Err("error");
println!("{}", x.unwrap()); // panics
}
expect()does pretty much the same thing but with custom messages
fn main() {
let x: Result<i32, &str> = Ok(5);
println!("{}", x.expect("Oops! Something went wrong!")); // => 5
let x: Result<i32, &str> = Err("error");
println!("{}", x.expect("Oops! Something went wrong!")); // panics with custom message
}
- Please note that both
unwrap()andexpect()will panic if used on aErrorNone. For recoverable errors, proper error handling/propagation is preferred.
Error Propagation
Propagating an error is when instead of handling it locally (in the current scope), we return that error to the caller (the outer scope) to handle. This allows the higher level code to decide how to handle the error.
Obviously we have to propagate errors from inside of functions/methods; in other words, we return Result<T, E> and let the calling scope deal with it. here’s an example:
use std::fs::File;
use std::io::{self, Read};
// Note that we're returning the Result<T, E> type.
fn read_username() -> Result<String, io::Error> {
let file_result = File::open("hello.txt");
let mut file = match file_result {
Ok(f) => f,
// We return an error to the caller if the operation fails
Err(e) => return Err(e),
};
let mut username = String::new();
match file.read_to_string(&mut username) {
Ok(_) => Ok(username),
// We do the same here
Err(e) => Err(e),
}
}
fn main() {
let result = read_username();
let username = match result {
Ok(value) => value,
Err(error) => {
println!("Failed to read username with error: {error}");
println!("Exiting...");
return; // Terminate program
}
};
}
Rust provides the ? operator for concise error propagation. When used on a Result<T, E>, ? returns the value inside Ok(T) if the operation succeeds. If the result is Err(E), it immediately returns that error from the current function.
#![allow(unused)]
fn main() {
// Same function as above but with the ? operator
fn read_username() -> Result<String, io::Error> {
// If this fails, the error is propagated to the caller
// without us having to cover such case in a match construct.
let mut file = File::open("hello.txt")?;
let mut username = String::new();
// Same thing applies here
file.read_to_string(&mut username)?;
Ok(username)
}
}
To panic!() or not to panic!()
Obviously panic!ing at every error is not the most optimal way to handle them. A panic! is supposed to represent an absolutely unrecoverable state, bug, failure, etc; not ordinary failures that are expected and are recoverable.
In cases where we encounter unrecoverable errors, panic!s help us avoid data/state corruption, security vulnerabilities, and so one; but most other cases should be handled gracefully.
Generic Types
Overview
Generic types allow code to work with multiple types while preserving type safety. Instead of writing separate versions of the same logic for different types, you write one generalized version for parameterized types like so:
#![allow(unused)]
fn main() {
// For integers
fn largest_i32(list: &[i32]) -> &i32 {
let mut largest = &list[0];
for item in list {
if item > largest {
largest = item;
}
}
largest
}
// For floats
fn largest_f64(list: &[f64]) -> &f64 {
let mut largest = &list[0];
for item in list {
if item > largest {
largest = item;
}
}
largest
}
// Generics solve this duplication with type parameters
fn largest<T>(list: &[T]) -> &T {
// More on this implementation later
}
}
Here’s a few ways to call generic functions:
#![allow(unused)]
fn main() {
let items = [1, 2, 3, 4, 5];
// Most to least explicit
let x: i32 = largest::<i32>(&items);
let x = largest::<i32>(&items);
let x: &i32 = largest(&items);
let x = largest(&items);
}
In the above example, T is a type parameter. We can pass a slice of any type to the largest<T> function as long as it implements the PartialOrd trait. Technically, our largest<T> example is wrong, because we didn’t specify this constraint; but we’ll get to that shortly.
Essentially, Generics allow us to easily implement abstractions and write reusable, type safe code.
Monomorphization
One of Rust’s most important generic features is monomorphization.
Unlike languages such as C# or Java, Rust does not typically keep generic types around at runtime. Instead, during compilation, the compiler generates specialized versions of generic code for each concrete type that is used.
Monomorphization is the reason generic code in Rust is often described as a zero-cost abstraction. It provides abstraction during development while producing highly optimized concrete code during compilation.
Generic Constraints
Often, Generics need constraints because not all operations work on all types; for example, the largest<T> function from before;
I intentionally didn’t implement its body cause it wouldn’t compile.
What if a list of custom structs was passed into it? How do we find the largest of something, if we don’t know what that something even is?
This is where generic constraints come in. Usually, we specify them as Rust Traits.
I’ll cover Traits later; but for now, know that traits are basically contracts (similar to C# interfaces) that a type has to satisfy (implement).
The following is the correct version of the largest<T> function.
#![allow(unused)]
fn main() {
fn largest<T: PartialOrd>(list: &[T]) -> &T {
let mut largest = &list[0];
for item in list {
if item > largest {
largest = item;
}
}
largest
}
}
T: PartialOrd basically tells the compiler to make sure that every time a scope is calling largest, the values it’s passing into it are satisfying the PartialOrd contract. However, there are a few other ways to express this in Rust; I’ll also cover those the end of this chapter.
Generic Structs
Rust structs can also benefit from generic parameterization with the following syntax.
// Defined like this
struct Point<T> {
x: T,
y: T,
}
// Can also contain more than one parameter
struct Color<Channel, Alpha> {
r: Channel,
g: Channel,
b: Channel,
opacity: Alpha,
}
fn main() {
// Instantiated like this (Most to least explicit)
let p1: Point<i32> = Point::<i32> { x: 7, y: 8 };
let p2 = Point::<i32> { x: 5, y: 6 };
let p3: Point<i32> = Point { x: 3, y: 4 };
let p4 = Point { x: 1, y: 2 };
let color1: Color<u8, f32> = Color::<u8, f32> {
r: 0,
g: 0,
b: 0,
opacity: 1.0,
};
let color2 = Color {
r: 0,
g: 0,
b: 0,
opacity: 1.0,
};
}
Generic enums
Rust enums can also benefit from generic parameterization. The Option<T> and Result<T, E> are great examples of this:
#![allow(unused)]
fn main() {
enum Option<T> {
Some(T),
None,
}
enum Result<T, E> {
Ok(T),
Err(E),
}
}
Methods with Generic Types
Methods can use generics in a few different ways:
- Generic impl blocks:
#![allow(unused)]
fn main() {
struct Point<T> {
x: T,
y: T,
}
impl<T> Point<T> {
fn x(&self) -> &T {
&self.x
}
}
}
- Method-specific generic parameters:
struct Point<T, U> {
x: T,
y: U,
}
impl<T, U> Point<T, U> {
// Methods themselves can introduce additional generics.
fn mixup<V, W>(self, other: Point<V, W>) -> Point<T, W> {
Point {
x: self.x,
y: other.y,
}
}
}
fn main() {
// Pay attention to the concrete types
let p1: Point<i32, f64> = Point { x: 5, y: 10.4 };
let p2: Point<&str, char> = Point {
x: "hello",
y: 'c',
};
let p3: Point<i32, char> = p1.mixup(p2);
}
- Specialized implementations:
- These allow methods to exist only for specific concrete instantiations of a generic type.
struct Point<T> {
x: T,
y: T,
}
// Every method inside here is available for every Point<T>
impl<T> Point<T> {
fn x(&self) -> &T {
&self.x
}
}
// But methods inside here are only available for Point<i32>
impl Point<i32> {
fn print_type(&self) {
println!("My type is i32");
}
}
fn main() {
let p_int = Point {x: 12, y: 34};
let p_float = Point {x: 1.2, y: 3.4};
p_int.x();
p_float.x();
p_int.print_type(); // Only available for i32s
p_float.print_type(); // Causes error
}
Generic Combos
You may want to read about Traits before proceeding.
Rust has two distinct syntaxes for parameterizing generics. The <T: TraitName> and impl TraitName. There’s also the ability to write where clauses for the former syntax to clean it up. You can specify more than one trait with the + operator as well.
All of the above in combination may look confusing; so, as promised, here’s some examples in increasing complexity to get a feel for them:
#![allow(unused)]
fn main() {
use std::fmt::Display;
// SETTING UP TYPES AND TRAITS
// Any type that implements this must have a summarize method available.
trait Summary {
fn summarize(&self) -> String;
}
struct NewsArticle {
headline: String,
location: String,
author: String,
content: String,
}
// This is how we implement traits for structs
impl Summary for NewsArticle {
fn summarize(&self) -> String {
return format!("{}, by {} ({})", self.headline, self.author, self.location);
}
}
impl Display for NewsArticle {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
return std::fmt::Result::Ok(());
}
}
struct SocialPost {
username: String,
content: String,
reply: bool,
repost: bool,
}
impl Summary for SocialPost {
fn summarize(&self) -> String {
return format!("{}: {}", self.username, self.content);
}
}
impl Display for SocialPost {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
return std::fmt::Result::Ok(());
}
}
impl Clone for SocialPost {
fn clone(&self) -> Self {
return SocialPost {
username: "".to_string(),
content: "".to_string(),
reply: true,
repost: true,
};
}
}
// END OF SETUP. EXAMPLES START HERE
// Any type that implements the Summary trait
// (NewsArticle and SocialPost)
fn example1<T: Summary>(item: &T) -> String {
return item.summarize();
}
// Same as above; `item: &impl Summary` reads as:
// "Any reference type that implements the Summary trait"
// It's syntax sugar for `fn example<T: Summary>(item: &T)`
// ONLY WHEN FOR A SINGLE PARAMETER
fn example1b(item: &impl Summary) -> String {
return item.summarize();
}
// *********************************************************************
// Each `impl Trait` parameter introduces its own anonymous type parameter. Therefore
// `fn foo(a: impl Trait, b: impl Trait)` is equivalent to
// `fn foo<T, U>(a: T, b: U) where T: Trait, U: Trait`,
// not `fn foo<T>(a: T, b: T)`.
// *********************************************************************
// item1 and item2 are references to values whose type implements Summary.
// item3 is a value type that implements Display
fn example2<T1: Summary, T2: Display>(item1: &T1, item2: &T1, item3: T2) {}
// Same as above but T1 is a generic for multiple traits instead of one
fn example3<T1: Summary + Clone + Copy, T2: Display>(item1: &T1, item2: &T1, item3: T2) {}
// Same general concept using impl Trait syntax.
fn example4(item1: &impl Summary, item2: impl Display, item3: &impl Copy) {}
// Same as example3 but with the impl syntax
fn example5(
item1: impl Summary + Clone + Copy,
item2: impl Display,
// Use parenthesis for reference types
item3: &(impl Summary + Clone + Copy),
) {
}
// This is similar to example 3 but with a `where`
// clause used to clean up the its signature.
// Note that return type comes before the where clause
fn example6<T1, T2, T3>(item1: T1, item2: &T2, item3: &T3) -> i32
where
T1: Summary + Clone + Copy,
T2: Display,
T3: Summary + Clone + Display,
{
1234
}
// Similar to the last one but the return value is
// any type that implements the three specified traits.
fn example7<T1, T2, T3>(item1: T1, item2: &T2, item3: &T3) -> impl Summary + Clone + Display
where
T1: Summary + Clone + Copy,
T2: Display,
T3: Summary + Clone + Copy,
{
// Our SocialPost struct does in fact, satisfy
// all three traits we specified
return SocialPost {
content: "".to_string(),
reply: true,
repost: true,
username: "".to_string(),
};
}
}
Traits in Rust
Traits
Traits are Rust’s primary abstraction mechanism for polymorphism, code reuse, and defining shared behavior. A trait specifies, what functionality a type must provide, which methods exist, and optional default behaviors. Rust heavily favors composition through traits instead of inheritance.
#![allow(unused)]
fn main() {
// Defined with the trait keyword
// Any type implementing Vehicle promises it can
// perform the move_it() behavior.
trait Vehicle {
// Only the signature
fn move_it(&self) -> i32;
}
struct Car {
// ...
}
// This is how we implement traits on a type
impl Vehicle for Car {
fn move_it(&self) {
// Logic to move car
}
}
}
- Examples from the standard library:
#![allow(unused)]
fn main() {
Clone
// Allows a value to be explicitly
// duplicated by creating a deep or custom copy.
Copy
// Allows a value to be duplicated
// implicitly through a simple bitwise copy.
Debug
// Enables a type to be printed in
// a programmer-friendly format using `{:?}`.
Display
// Enables a type to be printed in
// a user-friendly format using `{}`.
Iterator
// Defines how a type produces a
// sequence of values one at a time via `next()`.
IntoIterator
// Allows a type to be converted
// into an iterator so it can be used in a `for` loop.
PartialEq
// Enables equality comparisons (`==` and `!=`) between values.
Ord
// Enables total ordering comparisons (`<`, `>`, `<=`, `>=`)
// between values.
Read
// Provides methods for reading
// bytes from a data source such as a file or stream.
Write
// Provides methods for writing
// bytes to a destination such as a file or stream.
}
- A type can implement many traits
#![allow(unused)]
fn main() {
impl Clone for Car { /* ... */ }
impl Debug for Car { /* ... */ }
impl Display for Car { /* ... */ }
}
Default Implementations
Traits can optionally provide default method implementations.
#![allow(unused)]
fn main() {
trait Vehicle {
// Unlike this,
fn honk(&self);
// This has a default implementation
fn move_it(&self) -> i32 {
println!("I'm a vehicle and I'm moving!");
return 0;
}
}
struct Car {
// ...
}
impl Vehicle for Car {
fn honk(&self) {
println!("HOOOONK!");
}
// Notice that we don't implement the `move_it` method
// and relied on its default implementation.
}
}
Traits & Generics
Traits are commonly used with generic type parameters in method/function signatures to enforce all sorts of constraints. Here’s an example:
#![allow(unused)]
fn main() {
fn notify(item: &impl Summary) -> i32 {
println!("{}", item.summarize());
return 0;
}
// Or
fn notify<T: Summary>(item: &T) -> i32 {
println!("{}", item.summarize());
return 0;
}
// Or
fn notify<T>(item: &T) -> i32
where
T: Summary,
{
println!("{}", item.summarize());
return 0;
}
}
- Checkout the last section of my Generic Types markdown for more complex examples.
Implementing Methods Conditionally with Trait Bounds
Trait bounds are constraints that specify which traits a generic type must implement in order to be used. I’ve been using them in my examples all this time but didn’t call them out explicitly.
#![allow(unused)]
fn main() {
// This is a Trait Bound
<T: Display + Copy>
// This is also a trait bound
x: &(impl Display + Copy)
}
If you remember from my notes on generics, we implemented methods for generic types like this:
#![allow(unused)]
fn main() {
Point<T> {
x: T,
y: T,
}
impl<T> Point<T> {
fn x(&self) -> &T {
&self.x
}
}
// Or
impl<T, U> Point<T, U> {
// Methods themselves can introduce additional generics.
fn mixup<V, W>(self, other: Point<V, W>) -> Point<T, W> {
Point {
x: self.x,
y: other.y,
}
}
}
}
We could also implement methods for a generic type only when it had a specific concrete type like so:
#![allow(unused)]
fn main() {
impl Point<i32> {
fn print_type(&self) {
println!("My type is i32");
}
}
}
We can combine generic impl blocks with trait bounds to conditionally implement methods that are only available when a generic type satisfies certain trait requirements.
#![allow(unused)]
fn main() {
impl<T: Display + PartialOrd> Pair<T> {
fn cmp_display(&self) {
if self.x >= self.y {
println!("Largest is x = {}", self.x);
} else {
println!("Largest is y = {}", self.y);
}
}
}
}
The trait bound T: Display + PartialOrd ensures that cmp_display is only available for Pair<T> when T implements both Display and PartialOrd.
Validating References with Lifetimes
Lifetimes
Lifetimes describe how long references are valid. They’re the system Rust uses to reason about these relationships; their purpose is to prevent dangling references.
A dangling reference happens when a reference points to data but the data has already been dropped. Rust prevents this entirely at compile time with the help of the borrow checker and lifetimes. Rust often infers lifetimes automatically, but sometimes relationships become ambiguous and require explicit annotations which I’ll discuss soon.
One important thing to note is that lifetimes do not change how long values live; they only describe relationships between references so the compiler can verify correctness. They are primarily a compile-time analysis tool.
Lifetime Annotation Syntax
Lifetime annotation syntax is used to explicitly describe relationships between references.
#![allow(unused)]
fn main() {
// Lifetime annotations use apostrophe-prefixed names
'a
'b
'my_lifetime
}
Consider the following function:
#![allow(unused)]
fn main() {
fn longest(x: &str, y: &str) -> &str {
if x.len() > y.len() {
x
} else {
y
}
}
}
This won’t compile because the function returns a reference but the compiler doesn’t knowwhether it’s x’s or y’s reference.
The borrow checker needs to know how long the returned reference is guaranteed too remain valid (aka. its lifetime). So, we use the annotation syntax to let the compiler know.
Like Types, we can pass generic lifetime parameters to our function/method signatures (as well as to our, structs, enums, etc).
#![allow(unused)]
fn main() {
// This will compile
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() {
x
} else {
y
}
}
}
In the above example, just like T is to types, 'a is also a generic parameter but for lifetimes. It could be named anything else as well.
So essentially, what we’re doing is that we have a generic lifetime known as 'a, we don’t know how long it lasts; we don’t know which scope it comes from either; it’s just a generic parameter that we use to annotate the relationship between our arguments and return types.
This:
#![allow(unused)]
fn main() {
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str { ... }
}
basically means that our return type (-> &'a str) lasts at least as long as x and y (both have the same lifetime here). So, the compiler will know that this code is safe to compile since our returned value won’t be freed before x and y.
Also keep in mind that since our return value could be either x or y, we HAVE to annotate a lifetime for BOTH of them; but if for example, our function only ever returned x we wouldn’t have to annotate a lifetime for y.
Lifetime Annotation with Structs
We can also use lifetime annotation with structs with the following syntax:
#![allow(unused)]
fn main() {
struct ExampleStruct<'a> {
prop1: &'a str
}
}
prop1 is a reference with lifetime 'a. Rust guarantees that any ExampleStruct<’a> cannot outlive the data referenced by prop1
- Read the the rest of this section after you’ve read Lifetime Elision
When we implement methods on a struct with lifetimes, we use the same syntax as that of generic type parameters. where we declare and use the lifetime parameters depends on whether they’re related to the struct fields or the method parameters and return values.
#![allow(unused)]
fn main() {
impl<'a> ExampleStruct<'a> {
// Lifetime Elision makes it possible to write methods like this.
// Where lifetimes are inferred.
fn example_method(&self, x: &str) -> &str { ... }
// Without the elision rules, we'd have to write
// the above method like this
fn example_method<'b, 'c>(&'b self, x: &'c str) -> &'b str { ... }
}
}
Here’s a breakdown of what each lifetime generic represents:
-
'a: How long the references stored inside ExampleStruct must remain valid. -
'b: How long the borrow ofselflasts for this method call. -
'c: How long the reference x remains valid. -
I will explain multiple lifetime parameters and their use-cases later. For now, let’s get familiar with the Elision rules.
Lifetime Elision
The patterns programmed into Rust’s analysis of references are called the lifetime elision rules. These aren’t rules for programmers to follow; they’re a set of particular cases that the compiler will consider, and if your code fits these cases, you don’t need to write the lifetimes explicitly.
The compiler uses three rules to figure out the lifetimes of the references when there aren’t explicit annotations. The first rule applies to input lifetimes, and the second and third rules apply to output lifetimes. If the compiler gets to the end of the three rules and there are still references for which it can’t figure out lifetimes, the compiler will stop with an error. These rules apply to fn definitions as well as impl blocks.
- The first rule is that the compiler assigns a lifetime parameter to each parameter that’s a reference.
#![allow(unused)]
fn main() {
// The compiler turns this:
fn example(a: &str, b: &str, c: &str) { ... }
// Into something like this:
fn example<'a, 'b, 'c>(a: &'a str, b: &'b str, c: &'c str) { ... }
}
- The second rule is that, if there is exactly one input lifetime parameter, that lifetime is assigned to all output lifetime parameters.
#![allow(unused)]
fn main() {
// The compiler turns this:
fn example(a: &i32) -> &i32 { ... }
// Into something like this:
fn example<'a>(a: &'a i32) -> &'a i32 { ... }
}
- The third rule is that, if there are multiple input lifetime parameters, but one of them is
&selfor&mut selfbecause this is a method, the lifetime ofselfis assigned to all output lifetime parameters. This third rule makes methods much nicer to read and write because fewer symbols are necessary.
#![allow(unused)]
fn main() {
// The compiler turns this:
fn example(&self, a: &str) -> &str { ... }
// Into something like this:
fn example<'a, 'b>(&'a self, a: &'b str) -> &'a str { ... }
}
- One subtle point: when people say “the compiler turns this into…”, that’s a useful mental model, but technically the Rust compiler applies the elision rules during lifetime analysis and infers the omitted lifetimes as if those annotations had been written. It doesn’t “turn” source code into something else.
After the compiler applies (or tries to) all three elision rules and there’s still ambiguity about lifetimes, it will then ask for explicitness and we’ll have to use the Annotation Syntax.
Consider the following:
#![allow(unused)]
fn main() {
// The two following signatures are functionally the same will compile
fn example1(s: &str) -> &str { ... }
fn example2<'a>(s: &'a str) -> &'a str { ... }
}
The lifetime for example1’s references is automatically inferred by the compiler using the lifetime elision rules. However, lifetime elision only works in specific cases. When the compiler cannot unambiguously determine the relationship between input and output lifetimes, explicit lifetime annotations are required.
This is why the following function does not compile:
#![allow(unused)]
fn main() {
fn longest(x: &str, y: &str) -> &str {
if x.len() > y.len() {
x
} else {
y
}
}
}
The function has two input lifetimes (one for x and one for y), but the return type contains an omitted lifetime. The lifetime elision rules cannot determine whether the returned reference should be tied to x’s lifetime or y’s lifetime, so the compiler reports an error and requires an explicit lifetime annotation.
Important Things to Note
Here is a list of list of important things to know in regards to lifetimes that I couldn’t fit anywhere else in this file.
-
The
'staticlifetime:'staticis the longest possible lifetime. Meaning that a reference remains valid for the entire duration of a program. A good example of this are string literals; string literals are stored in the programs binary; so, they have the'staticlifetime. -
Multiple generic lifetime parameters:
In practice, it’s uncommon to need more than one explicit lifetime parameter. Most functions either rely on lifetime elision or can be expressed using a single lifetime such as ’a. Multiple lifetime parameters become useful when working with multiple references that have independent lifetime relationships. By introducing separate lifetime parameters, we can express that different references may live for different lengths of time without unnecessarily constraining them to share the same lifetime.
-
If you’re still confused about lifetimes, you probably don’t understand Borrowing and the Borrow Checker. This comment by u/kohugaly does a great job clarifying things and how lifetimes fit into all this.
-
Also, this video by Jon Gjengset is a great watch for anyone who wants to learn lifetimes.
Traits Bounds, Generic Types, & Lifetime Annotations
Similar to what I did before, I’ll provide examples that use a combination of trait bounds, generic types and lifetime annotations to show them in practice and clarify the syntax.
- Generic function with trait bounds and lifetimes:
#![allow(unused)]
fn main() {
use std::fmt::Display;
fn longest_with_announcement<'a, T>(x: &'a str, y: &'a str, ann: T) -> &'a str
where
T: Display,
{
println!("Announcement: {ann}");
if x.len() > y.len() { x } else { y }
}
}
- Generic struct with lifetime:
#![allow(unused)]
fn main() {
struct Holder<'a, T>
where
T: Display,
{
value: &'a T,
}
}
- Generic impl block with trait bounds and lifetimes
#![allow(unused)]
fn main() {
use std::fmt::Display;
struct Container<'a, T> {
item: &'a T,
}
impl<'a, T> Container<'a, T>
where
T: Display,
{
fn print(&self) {
println!("{}", self.item);
}
}
}
- Returning references with generics and trait bounds
#![allow(unused)]
fn main() {
fn largest<'a, T>(list: &'a [T]) -> &'a T
where
T: PartialOrd,
{
let mut largest = &list[0];
for item in list {
if item > largest {
largest = item;
}
}
largest
}
}
- Methods using all three together
#![allow(unused)]
fn main() {
use std::fmt::Display;
struct Pair<'a, T> {
first: &'a T,
second: &'a T,
}
impl<'a, T> Pair<'a, T>
where
T: Display + PartialOrd,
{
fn largest(&self) -> &'a T {
if self.first >= self.second {
self.first
} else {
self.second
}
}
}
}
Writing Automated Tests
Overview
Like most other languages, Rust provides us with features to write & run tests and testable code. In this file I’ll cover these features. Tests are Rust functions that verify that the non-test code is functioning in the expected manner. The bodies of test functions typically perform the following actions:
- Setting up any needed data or state
- Run the code we’re testing
- Assert the results are what we expect
How to Write Tests
At its simplest, a test in Rust is a function that’s annotated with the test attribute.
To change a function into a test function, add the #[test] attribute on the line before fn.
When you run your tests with the cargo test command, Rust builds a test runner binary that runs the annotated functions and reports on whether each test function passes or fails.
#![allow(unused)]
fn main() {
#[cfg(test)] // More on this later
mod tests {
#[test]
fn test1 {
// ...
}
#[test]
fn test2 {
// ...
}
}
}
Rust provides us with useful macros to write tests. The most common being:
-
assert!()#![allow(unused)] fn main() { #[derive(Debug)] pub struct Rectangle { width: u32, height: u32, } impl Rectangle { pub fn can_hold(&self, other: &Rectangle) -> bool { self.width > other.width && self.height > other.height } } #[cfg(test)] mod tests { #[test] fn larger_can_hold_smaller() { let larger = Rectangle { width: 8, height: 7, }; let smaller = Rectangle { width: 5, height: 1, }; assert!(larger.can_hold(&smaller)); } } } -
assert_eq!()#![allow(unused)] fn main() { pub fn biggest(a: i32, b: i32) -> i32 { a.max(b) } #[cfg(test)] mod tests { #[test] fn biggest_returns_correct() { let result = biggest(-100, 100); assert_eq!( result, 100, "biggest must return 100 here but returned {result}" ); // You can pass a custom message as the third parameter to // assert!, assert_eq!, and assert_ne! } } } -
assert_ne!()#![allow(unused)] fn main() { pub fn biggest(a: i32, b: i32) -> i32 { a.max(b) } #[cfg(test)] mod tests { #[test] fn biggest_returns_correct() { let result = biggest(-100, 100); assert_ne!( result, -100, "biggest must not return -100 here but did!" ); // You can pass a custom message as the third parameter to // assert!, assert_eq!, and assert_ne! } } }
Checking for Panics with should_panic
We can use the #[should_panic] attribute to verify that a test causes a panic.
#![allow(unused)]
fn main() {
pub struct Guess {
value: i32,
}
impl Guess {
pub fn new(value: i32) -> Guess {
if value < 1 || value > 100 {
panic!("Guess value must be between 1 and 100, got {value}.");
}
Guess { value }
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[should_panic]
fn greater_than_100() {
Guess::new(200);
}
}
}
The test succeeds if the code inside the test function panics. However, #[should_panic] alone only checks that a panic occurred, not why it occurred. As a result, the test may still pass if an unrelated panic happens. To make the test more precise, we can use #[should_panic(expected = "...")] to verify that the panic message contains a specific substring:
#![allow(unused)]
fn main() {
pub struct Guess {
value: i32,
}
impl Guess {
pub fn new(value: i32) -> Guess {
if value < 1 {
panic!("Guess value must be greater than or equal to 1, got {value}.");
} else if value > 100 {
panic!("Guess value must be less than or equal to 100, got {value}.");
}
Guess { value }
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[should_panic(expected = "less than or equal to 100")]
fn greater_than_100() {
Guess::new(200);
}
}
}
Using Result<T, E> in Tests
Tests can return a Result<T, E> instead of using assertions directly:
#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn it_works() -> Result<(), String> {
let result = add(2, 2);
if result == 4 {
Ok(())
} else {
Err(String::from("two plus two does not equal four"))
}
}
}
}
A test passes when it returns Ok(()) and fails when it returns an Err. This approach also allows the use of the question mark ? operator, making it convenient to propagate errors in tests.
Note that tests returning a Result<T, E> cannot use the #[should_panic] attribute. If you need to verify that an operation returns an error, use assert!(value.is_err()) instead.
Controlling How Tests Are Run
Similar to cargo run, which compiles and runs your program, cargo test compiles your code in test mode and runs the generated test binary. Use cargo test --help to see Cargo’s test options, and cargo test -- --help to see options passed directly to the test binary after the -- separator.
Here are some of the main options:
-
Running Tests in Parallel or Consecutively
By default tests are run in parallel, you can use the following to customize the number of threads. Using 1 makes the tests run consecutively:
$ cargo test -- --test-threads=1 -
Showing Function Output
By default, if a test prints something to
stdoutand it passes, its output will be captured by the test library. Only the output of tests that fail will be printed. Use the following to see the output of tests that pass as well.$ cargo test -- --show-output -
Running a Subset of Tests
The following shows how to run/ignore different tests in different scenarios
# Run a test by its name $ cargo test auth_login_successful # Runs any test that starts with auth $ cargo test auth_ # Runs any test that has the '#[ignore]' attribute cargo test -- --ignored # Runs all tests including ones that are "ignored" cargo test -- --include-ignored
Test Organization
The Rust community thinks about tests in terms of two main categories: unit tests and integration tests. Unit tests are small and more focused, testing one module in isolation at a time, and can test private interfaces. Integration tests are entirely external to your library and use your code in the same way any other external code would, using only the public interface and potentially exercising multiple modules per test. Writing both kinds of tests is important to ensure that the pieces of your library are doing what you expect them to, separately and together.
Unit Tests
The purpose of unit tests is to test each unit of code in isolation from the rest of the code to quickly pinpoint where code is and isn’t working as expected. You’ll put unit tests in the src directory in each file with the code that they’re testing. The convention is to create a module named tests in each file to contain the test functions and to annotate the module with cfg(test) like so:
my_project/
├── Cargo.toml
└── src/
├── main.rs
│
├── math.rs
│ ├── add()
│ ├── subtract()
│ └── #[cfg(test)]
│ mod tests
│
├── string_utils.rs
│ ├── capitalize()
│ ├── truncate()
│ └── #[cfg(test)]
│ mod tests
│
└── models.rs
├── struct User
├── impl User
└── #[cfg(test)]
mod tests
The #[cfg(test)] annotation on the tests module tells Rust to compile and run the test code only when we run cargo test, not when we run cargo build. This saves compile time when we only want to build the library and saves space in the resultant compiled artifact because the tests are not included. Since unit tests go in the same files as the code, we’ll have to use #[cfg(test)] to specify that they shouldn’t be included in the compiled result.
Integration Tests
In Rust, integration tests are entirely external to your library. They use your library in the same way any other code would, which means they can only call functions that are part of your library’s public API. Their purpose is to test whether many parts of your library work together correctly. Units of code that work correctly on their own could have problems when integrated, so test coverage of the integrated code is important as well. To create integration tests, you first need a tests directory.
We create a tests directory at the top level of our project directory, next to src. Cargo knows to look for integration test files in this directory. We can then make as many test files as we want, and Cargo will compile each of the files as an individual crate.
my_project/
├── Cargo.toml
├── src/
│ ├── lib.rs
│ ├── math.rs
│ └── string_utils.rs
│
└── tests/
├── math_tests.rs
├── string_utils_tests.rs
└── api_tests.rs
#![allow(unused)]
fn main() {
// src/math.rs
pub fn add(a: i32, b: i32) -> i32 {
a + b
}
}
The line use my_project::math::add; imports the public API of our source file and we can test how they behave from the “outside”.
#![allow(unused)]
fn main() {
// tests/math_tests.rs
use my_project::math::add;
#[test]
fn adds_numbers() {
assert_eq!(add(2, 3), 5);
}
}
Note that we don’t need to annotate our tests in the tests/ directory with #[cfg(test)]; Cargo treats it specially and compiles files in this directory only when we run cargo test.
Also, note that integration tests can’t be used for binary crates; only library crates.
Closures & Iterators
Overview
A Rust closure is similar to a JavaScript arrow function with lexical capture, but the captured variables participate in Rust’s ownership and borrowing system. The compiler decides whether each captured value is borrowed immutably, borrowed mutably, or moved into the closure, and the resulting closure type implements one or more of Fn, FnMut, and FnOnce.
#![allow(unused)]
fn main() {
fn add_one_v1 (x: u32) -> u32 { x + 1 } // A function for reference
// Different ways of writing a closure
let add_one_v2 = |x: u32| -> u32 { x + 1 };
let add_one_v3 = |x| { x + 1 };
let add_one_v4 = |x| x + 1 ;
}
Annotating Closures
Unlike functions, closures’ parameter and return types are usually inferred by the compiler; but rarely, when they can’t be inferred, we have to annotate them explicitly:
#![allow(unused)]
fn main() {
// We didn't specify any type here
let example_closure = |x| x;
// The compiler here infers that the closure takes and returns a String here
let s = example_closure(String::from("hello"));
// We break the compiler's inference here;
// so, this will cause a compiler error
let n = example_closure(5);
// We can fix it like so
let n = example_closure(5.to_string());
}
The Ownership Semantics of Closures
Closures can capture values from their surrounding environment. Rust analyzes how the closure uses each captured value and automatically chooses the least restrictive of the following capture modes.
-
Immutable Borrow
In the following.
listis borrowed immutably because the closure only reads it.#![allow(unused)] fn main() { let list = vec![1, 2, 3]; let print_list = || println!("{list:?}"); print_list(); print_list(); println!("{list:?}"); // list is still accessible } -
Mutable Borrow
If a closure modifies a captured value, it captures a mutable reference (
&mut T):#![allow(unused)] fn main() { // Because the closure mutates count, it requires mutable access to the captured variable. let mut count = 0; let mut increment = || { count += 1; }; increment(); increment(); println!("{count}"); } -
Taking Ownership
If a closure needs ownership of a captured value, Rust moves the value into the closure:
#![allow(unused)] fn main() { // The move keyword forces ownership of captured values to be transferred into the closure. let text = String::from("hello"); // The `move` keyword explicitly moves a ownership of the value let consume = move || { println!("{text}"); }; consume(); }
Using Closures with Threads
A common use of move closures is spawning threads.
When a new thread is created, it may outlive the current scope. Borrowing local variables would therefore be unsafe. By moving ownership into the closure, Rust guarantees the captured data remains valid for the lifetime of the thread.
#![allow(unused)]
fn main() {
use std::thread;
let numbers = vec![1, 2, 3];
let handle = thread::spawn(move || {
println!("{numbers:?}");
});
handle.join().unwrap();
}
Without move, the closure would attempt to borrow numbers, which is not allowed because the spawned thread may continue executing after the current scope ends.
Moving Captured Values Out of Closures
Every closure implements at least one of Rust’s three closure traits. The trait implemented depends on how the closure uses its captured values.
-
FnA closure that only reads captured values implements
Fn.Fnclosures can be called repeatedly because they do not modify or consume their captured values.#![allow(unused)] fn main() { let x = 10; let add = |y| x + y; println!("{}", add(5)); println!("{}", add(10)); } -
FnMutA closure that mutates captured values implementsFnMut. Because the closure modifies its environment, it requires mutable access and therefore implementsFnMut.#![allow(unused)] fn main() { let mut count = 0; let mut increment = || { count += 1; }; increment(); increment(); } -
FnOnceA closure that consumes a captured value implementsFnOnce. In the following, after the first call,texthas been moved and dropped, so the closure cannot be called again.#![allow(unused)] fn main() { let text = String::from("hello"); let consume = move || { drop(text); }; consume(); }
- A closure implementing
FnOncedoes not necessarily consume its captures.FnOncesimply means it might consume them and therefore can only be guaranteed to be callable once. Rust conservatively uses the least restrictive trait that the closure satisfies.
Trait Hierarchy
The closure traits form the following hierarchy:
Fn => call(&self)
FnMut => call_mut(&mut self)
FnOnce => call_once(self)
This means:
| Trait | May Read | May Mutate | May Consume |
|---|---|---|---|
Fn | true | false | false |
FnMut | true | true | false |
FnOnce | true | true | true |
Closures in Signatures
We can define functions/methods that take or return closures with the following syntax:
#![allow(unused)]
fn main() {
impl<T> Option<T> {
pub fn unwrap_or_else<F>(self, f: F) -> T
where
F: FnOnce() -> T
{
match self {
Some(x) => x,
None => f(),
}
}
}
}
Iterators
Iterators in Rust provide a uniform way to process a sequence of values one item at a time. Rather than exposing how data is stored internally, an iterator exposes a stream of items that can be consumed, transformed, filtered, combined, or collected into new data structures. Any type that implements the Iterator trait can become an iterator, regardless of whether the values come from a collection, a range, a file, or are generated on demand.
fn main() {
// Iterator from a collection
let numbers = vec![1, 2, 3];
// Iterator from a range
let range = 4..=6;
// Iterator generated on demand
let generated = std::iter::repeat("hello").take(3);
for n in numbers.into_iter() {
println!("{n}");
}
for n in range {
println!("{n}");
}
for word in generated {
println!("{word}");
}
}
One of the key advantages of iterators is that they are lazy. Like LINQ in C#, most iterator operations do not perform any work immediately; instead, they build a pipeline that only executes when values are actually requested. This allows Rust to optimize iterator chains efficiently while keeping the code expressive and concise.
fn main() {
let numbers = vec![1, 2, 3, 4, 5];
let doubled = numbers
.iter()
.map(|n| {
println!("Doubling {n}");
n * 2
});
// No output has been printed yet!
// Methods such as collect() are called consuming adapters.
// Calling them uses up the iterator and performs our operations.
let result: Vec<_> = doubled.collect();
println!("{result:?}");
}
When iterating over collections, Rust commonly provides three ways to access elements: by taking ownership of each element, by borrowing elements immutably, or by borrowing them mutably. Which approach is used determines whether the iterator yields owned values, shared references, or mutable references.
fn main() {
let numbers = vec![1, 2, 3];
// Takes ownership of each element
for n in numbers.clone().into_iter() {
println!("Owned: {n}");
}
// Immutable references (&i32)
for n in numbers.iter() {
println!("Borrowed: {n}");
}
let mut numbers = vec![1, 2, 3];
// Mutable references (&mut i32)
for n in numbers.iter_mut() {
*n *= 10;
}
println!("{numbers:?}");
}
Conceptually, an iterator is any object capable of producing a sequence of values on demand until no more values remain. The Iterator trait defines this behavior with the next() method.
fn main() {
let mut iter = vec![10, 20, 30].into_iter();
println!("{:?}", iter.next()); // Some(10)
println!("{:?}", iter.next()); // Some(20)
println!("{:?}", iter.next()); // Some(30)
println!("{:?}", iter.next()); // None
}
Iterators are one of Rust’s “Zero-Cost Abstractions” and supposedly have no performance overhead. They are preferred over normal loops because of how cleanly you can express the flow of an operation with the help of its method APIs and Rust’s closures.
fn main() {
let numbers = vec![1, 2, 3, 4, 5, 6];
// A chain of iterator adapters can express complex
// logic cleanly without sacrificing performance
let sum: i32 = numbers
.iter() // Borrows each number
.filter(|n| *n % 2 == 0) // Only keeps even numbers
.map(|n| n * n) // Squares them
.sum(); // Sums the results
println!("{sum}");
}
The equivalent loop would be:
#![allow(unused)]
fn main() {
let mut sum = 0;
for n in &numbers {
if n % 2 == 0 {
sum += n * n;
}
}
}
- Notice that closures play an important role in how cleanly we can work with iterators.
A (semi) Comprehensive Guide to Rust Pointers
Overview
This one got a bit unwieldy. Initially, it was supposed to be a recap of The Book’s chapter on smart pointers; but since it had the potential, I turned it into a semi-comprehensive guide on Rust pointers.
Pointers
A pointer is a value that contains the memory address of another value. Rather than storing the data itself, a pointer provides a way to access data stored elsewhere in memory (or simply put, pointers point to where some data is in memory).
The most common pointer type in Rust is a reference. References are indicated by the & symbol and allow code to borrow access to a value without taking ownership of it. References are lightweight, have no runtime overhead, and are checked by Rust’s borrow checker to ensure memory safety.
Raw Pointers
Raw pointers are low-level pointer types that bypass Rust’s usual safety guarantees. They are represented by *const T (immutable) and *mut T (mutable) and are similar to C/C++ pointers.
Unlike references, raw pointers are not checked by the borrow checker, may be null, may be dangling, and can violate Rust’s aliasing and borrowing rules. Dereferencing a raw pointer is therefore an unsafe operation and must be performed within an unsafe block.
Raw pointers are primarily used when interacting with foreign code (FFI), implementing low-level abstractions, or working with memory that Rust’s ownership system cannot reason about directly.
Smart Pointers
Smart pointers are types that behave like pointers while also providing additional functionality, metadata, or ownership semantics. Unlike references, smart pointers often own the data they point to and manage that data according to specific rules.
Rust’s standard library provides several smart pointers, each designed for a particular ownership model. Examples include Box<T> for heap allocation and single ownership, Rc<T> for shared ownership, and RefCell<T> for interior mutability.
Smart pointers are typically implemented as structs and usually implement the Deref trait, allowing them to behave like references, and the Drop trait, allowing them to customize cleanup behavior when they go out of scope. These traits enable smart pointers to extend Rust’s ownership system while remaining ergonomic to use.
Thin vs Fat Pointers
In Rust, not all pointers have the same size. Some pointers contain only a memory address, while others store additional metadata alongside the address.
A thin pointer consists solely of a memory address and therefore occupies one machine word. References and pointers to types whose size is known at compile time are typically thin pointers.
A fat pointer contains both a memory address and additional metadata required to work with dynamically sized types. As a result, fat pointers occupy more memory than thin pointers.
Common examples of fat pointers include:
- Slices/String slices (
&[T]/&str), which store a pointer and a length. - Trait objects (
&dyn Trait), which store a pointer to the data and a pointer to a virtual method table (vtable).
The additional metadata allows Rust to work safely with values whose size or behavior cannot be fully determined at compile time.
Deref
Implementing the Deref trait allows you to customize the behavior of the dereference operator (*). By implementing Deref in such a way that a smart pointer can be treated like a regular reference, you can write code that operates on references and use that code with smart pointers too.
struct MyBox<T>(T);
impl<T> MyBox<T> {
fn new(x: T) -> MyBox<T> {
MyBox(x)
}
}
fn main() {
let x = 5;
let y = MyBox::new(x);
assert_eq!(5, x);
assert_eq!(5, *y); // This will cause a compiler error
// because an instance of MyBox<T> cannot be dereferenced
}
This is the corrected version of the above snippet:
struct MyBox<T>(T);
impl<T> MyBox<T> {
fn new(x: T) -> MyBox<T> {
MyBox(x)
}
}
// We implement the Deref trait for our type
impl<T> Deref for MyBox<T> {
type Target = T;
// We return a reference to the first data field of self
fn deref(&self) -> &Self::Target {
&self.0
}
}
fn main() {
let x = 5;
let y = MyBox::new(x);
assert_eq!(5, x);
// Behind the scenes, Rust turns *y to
// *(y.deref()) .aka *(&self.0)
assert_eq!(5, *y);
}
Deref Coercion
Deref Coercion is Rust’s mechanism for automatically converting one reference type into another by following Deref implementations. When a context requires &U, Rust can repeatedly apply dereferencing to convert &T into a compatible reference type required by the current context.
Consider the following:
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) -> &Self::Target {
&self.0
}
}
fn hello(name: &str) {
println!("Hello, {name}!");
}
fn main() {
let m = MyBox::new(String::from("Rust"));
hello(&m);
}
In the above snippet Rust starts with &MyBox<String> (&m). Since MyBox<T> implements Deref<Target = T>, it can dereference &MyBox<String> to &String. Because String implements Deref<Target = str>, Rust can dereference &String to &str. The resulting &str matches the parameter type of hello(), so the call is accepted.
Essentially: &MyBox<String> ==deref==> &String ==deref==> &str
Without Deref Coercion, we would have to write it like this:
fn main() {
let m = MyBox::new(String::from("Rust"));
hello(&(*m)[..]);
// 1. *m uses Deref to access the inner String
// 2. [..] produces a str slice
// 3. & creates an &str
}
DerefMut
Similar to how you use the Deref trait to implement/override the * operator on immutable references, you can use the DerefMut trait to override the * operator on mutable references.
Drop
The Drop trait allows you to customize the code that’s run when an instance of the value goes out of scope. You can provide an implementation for the Drop trait on any type, and that code can be used to release resources like files or network connections.
struct CustomSmartPointer {
data: String,
}
impl Drop for CustomSmartPointer {
fn drop(&mut self) {
println!("Dropping CustomSmartPointer with data `{}`!", self.data);
}
}
fn main() {
let c = CustomSmartPointer {
data: String::from("my stuff"),
};
let d = CustomSmartPointer {
data: String::from("other stuff"),
};
println!("CustomSmartPointers created");
} // c and d are dropped here
When a scope ends, local variables are dropped in reverse order of their declaration.
We can prematurely drop a value by calling std::mem::drop. Calling the drop method directly is not allowed.
#![allow(unused)]
fn main() {
c.drop(); // Error
std::mem::drop(c); // OK
}
The Box
The most straightforward smart pointer is a box Box<T>. Boxes allow you to store data on the heap rather than the stack. What remains on the stack is the pointer to the heap data.
Boxes are mostly useful for one of the following situations:
-
Unknown size: Put the value in a Box so Rust only has to store a pointer of known size.
- Recursive Types like Cons List
-
Large data: Put the data in a Box so moving it only moves its pointer, not the entire value.
- Avoiding expensive moves/copies of large values.
-
Trait objects: Put the value in a Box so different types can be treated uniformly through a trait.
- Dynamic dispatch with trait objects (
Box<dyn Trait>). - More on trait objects later
- Dynamic dispatch with trait objects (
enum List {
Cons(i32, Box<List>),
Nil,
}
use List::{Cons, Nil};
fn main() {
let list = Cons(
1,
Box::new(Cons(
2,
Box::new(Cons(
3,
Box::new(Nil),
)),
)),
);
}
Boxes only provide indirection and heap allocation; they don’t have any other special capabilities thus, incur no performance overhead. They can be useful in cases like the cons list where the indirection is the only feature we need.
When a Box<T> value goes out of scope, the heap data that the box is pointing to is cleaned up as well because of the Drop trait implementation.
The Reference-Counted
The Reference-Counted Smart Pointer (Rc<T>) is Rust’s mechanism for shared ownership. Cloning an Rc<T> creates another owner of the same value rather than copying the value itself. Internally, Rc<T> keeps a reference count of how many owners point to the value and guarantees that the value remains alive as long as at least one strong reference exists. Because multiple owners may exist simultaneously, Rc<T> only provides immutable access to the value it contains. To mutate shared data, Rc<T> is commonly combined with interior mutability (more on this later) types such as RefCell<T>.
pub enum List {
Cons(i32, Rc<List>),
Nil,
}
fn main() {
let a: Rc<List> = Rc::new(Cons(5, Rc::new(Cons(10, Rc::new(Nil)))));
println!(
"reference count after creating a = {}",
Rc::strong_count(&a) // -> 1
);
// `_b` owns its own List node, but shares `a` as its tail
let _b: List = Cons(3, Rc::clone(&a));
println!(
"reference count after creating b = {}",
Rc::strong_count(&a) // -> 2
);
{
// same as `_b`
let _c: List = Cons(4, Rc::clone(&a));
println!(
"reference count after creating c = {}",
Rc::strong_count(&a) // -> 3
);
} // _c is dropped here
// Both `_b` and `_c` want to own a as their tail.
// Box<T> would require transferring ownership,
// but Rc<T> allows shared ownership.
println!(
"reference count after c goes out of scope = {}",
Rc::strong_count(&a) // -> 2
);
} // Reference count becomes 0 and the value is freed
Rc<T> is only intended for single-threaded scenarios. For shared ownership across threads, Rust provides Arc<T> (Atomic Reference Counted Pointer), which performs thread-safe reference counting but comes with some overhead.
Strong & Weak References
When using Rc<T>, Rust distinguishes between strong references and weak references.
A strong reference (Rc<T>) represents ownership of a value. Every time an Rc<T> is cloned, the strong reference count increases. The value remains allocated as long as at least one strong reference exists.
A weak reference (Weak<T>) provides a non-owning reference to a value. Weak references do not contribute to the strong reference count and therefore do not keep the value alive. They are typically used when a value needs to be referenced without expressing ownership.
Because Weak<T> does not participate in ownership, it does not guarantee that the referenced value is still alive. To access the value, a weak reference must first be upgraded into an Rc<T>. This upgrade succeeds only while at least one strong reference exists and fails once the value has been deallocated.
Weak references are particularly important when building graphs, trees, and other data structures with bidirectional relationships. By replacing some ownership relationships with weak references, reference cycles can be avoided.
A reference cycle occurs when two or more values own each other through Rc<T>. Since each value keeps the other’s reference count above zero, none of them can be deallocated, resulting in a memory leak. Using Weak<T> for non-owning relationships breaks these cycles and allows memory to be freed correctly (More on this later).
Interior Mutability
Interior mutability is a Rust design pattern that allows a value to modify part of its internal state even when it is accessed through an immutable reference (&T). Under Rust’s normal borrowing rules, an immutable reference guarantees read-only access to the referenced value. Interior mutability provides a controlled exception to this rule by wrapping specific fields in special types that permit mutation while still maintaining memory safety.
This pattern is useful when a type’s public interface represents an operation as logically read-only, but the implementation needs to update internal details that are not part of the value’s observable state. E.g. caching computed results, recording usage statistics, maintaining shared ownership metadata, or implementing data structures whose internal bookkeeping must change during otherwise read-only operations.
Essentially, Interior mutability is Rust’s controlled exception to the usual rule that data behind an immutable reference cannot be modified.
Rust implements interior mutability using UnsafeCell<T>, a low-level primitive that allows mutation through shared references. Higher-level types such as Cell<T> and RefCell<T> build safe abstractions on top of UnsafeCell<T> by enforcing rules that prevent invalid access and preserve Rust’s guarantees about memory safety.
The Cell Duo
As previously mentioned, two of the most common smart pointers which provide interior mutability are Cell<T> and RefCell<T>.
They allow mutation through an immutable reference (&T), but they do so in very different ways and are designed for different use cases.
Cell<T> is the simplest interior mutability type. It stores a value and allows that value to be replaced, retrieved, or swapped even when the Cell itself is accessed through an immutable reference. Cell<T>’s main strength is that it never gives out references to its contents; so, no references to the inner value can exist, thus, Rust never has to worry about aliasing or borrow violations. Because of this, Cell<T> requires no runtime borrow checking and cannot panic due to borrowing errors.
Instead of borrowing the inner value:
get()copies the value out (forCopytypes)set()replaces the valuereplace()swaps the value and returns the old onetake()removes the value and replaces it withDefault::default()
#![allow(unused)]
fn main() {
use std::cell::Cell;
// Isn't declared with `mut`
let count = Cell::new(0);
count.set(1);
count.set(2);
println!("{}", count.get());
}
RefCell<T> provides interior mutability using a completely different strategy. Instead of forbidding references, it allows them; however, because references are allowed, borrow rules must still be enforced somehow; therefore, borrow checking takes place during runtime instead of compile time.
.borrow()creates an immutable borrow.borrow_mut()creates a mutable borrow
The same borrowing rules (either one mutable reference or any number of immutable references) that Rust enforces at compile time are also enforced with these borrows but at runtime (if we break them, the program panics).
Sometimes Rust’s compile-time analysis is too conservative. So, a RefCell<T> is a good solution.
Another common use case for RefCell<T> is combining it with Rc<T> to create Rc<RefCell<T>>. This pattern provides multiple ownership with interior mutability, allowing several owners to share and modify the same value.
#![allow(unused)]
fn main() {
use std::cell::RefCell;
use std::rc::Rc;
let data = Rc::new(RefCell::new(5));
let a = Rc::clone(&data);
let b = Rc::clone(&data);
// Even though a and b both point to the same value, RefCell<T>
// still enforces Rust's borrowing rules at runtime. Attempting
// to create multiple active mutable borrows will cause a panic.
*a.borrow_mut() += 1; // the mutable reference here is dropped at the end of the statement
*b.borrow_mut() += 1;
}
Rc<RefCell<T>> is a powerful tool, but it should be used carefully. Because ownership is shared and borrowing rules are enforced at runtime, complex structures can become harder to reason about than ordinary ownership and borrowing.
One common pitfall is the creation of reference cycles. Suppose two nodes, A and B, each store an Rc pointing to the other. Even after all external owners are dropped, A keeps B alive and B keeps A alive. As a result, neither reference count ever reaches zero, so neither value is deallocated. The memory remains allocated for the lifetime of the program, resulting in a memory leak.
use std::{cell::RefCell, rc::Rc};
struct Node {
value: i32,
next: RefCell<Option<Rc<Node>>>,
}
fn main() {
let a = Rc::new(Node {
value: 1,
next: RefCell::new(None),
});
let b = Rc::new(Node {
value: 2,
next: RefCell::new(None),
});
// A -> B
*a.next.borrow_mut() = Some(Rc::clone(&b));
// B -> A
*b.next.borrow_mut() = Some(Rc::clone(&a));
println!("a strong count = {}", Rc::strong_count(&a)); // 2
println!("b strong count = {}", Rc::strong_count(&b)); // 2
}
Rust provides Weak<T> to break such cycles. Unlike Rc<T>, a Weak<T> does not contribute to the reference count and therefore does not keep a value alive.
use std::{
cell::RefCell,
rc::{Rc, Weak},
};
#[derive(Debug)]
struct Node {
value: i32,
next: RefCell<Option<Rc<Node>>>,
prev: RefCell<Option<Weak<Node>>>,
}
fn main() {
let a = Rc::new(Node {
value: 1,
next: RefCell::new(None),
prev: RefCell::new(None),
});
let b = Rc::new(Node {
value: 2,
next: RefCell::new(None),
prev: RefCell::new(None),
});
// A strongly owns B
*a.next.borrow_mut() = Some(Rc::clone(&b));
// B weakly references A
*b.prev.borrow_mut() = Some(Rc::downgrade(&a));
println!("a strong count = {}", Rc::strong_count(&a)); // 1
println!("b strong count = {}", Rc::strong_count(&b)); // 2
println!("a weak count = {}", Rc::weak_count(&a)); // 1
}
Smart Pointers Quick Reference
Up to this point, we’ve covered the smart pointers most commonly used in single-threaded Rust. The remaining types target more specialized use cases, particularly concurrency and advanced ownership patterns. Concurrent primitives will be discussed only at a high level here, since they will receive their own dedicated .md later on.
This section is intended as a rough quick reference for most of the useful smart pointers available in std.
Box<T>
Box<T> provides single ownership of heap-allocated data.
Normally, Rust stores values on the stack when their size is known at compile time. Box<T> moves a value onto the heap while the box itself remains on the stack.
Characteristics:
- Large values you don’t want copied around.
- Recursive types.
- Trait objects (
Box<dyn Trait>). - Explicit heap allocation.
- Mutable access follows normal borrowing rules.
- Value is dropped when the box is dropped.
Example:
#![allow(unused)]
fn main() {
let x = Box::new(42);
println!("{}", *x);
}
Common recursive type:
#![allow(unused)]
fn main() {
enum List {
Cons(i32, Box<List>),
Nil,
}
}
Rc<T>
Rc<T> (Reference Counted) is Rust’s mechanism for shared ownership in single-threaded programs. Cloning an Rc<T> creates another owner of the same value rather than copying the value itself.
Internally, Rc<T> maintains a strong reference count. The value remains alive as long as at least one strong reference exists.
Characteristics:
- Multiple owners
- Immutable access only
- Single-threaded
- Automatically deallocates when the strong count reaches zero
#![allow(unused)]
fn main() {
use std::rc::Rc;
let value = Rc::new(String::from("hello"));
let a = Rc::clone(&value);
let b = Rc::clone(&value);
}
Arc<T>
Arc<T> (Atomic Reference Counted) is the thread-safe counterpart to Rc<T>.
Like Rc<T>, it provides shared ownership. Unlike Rc<T>, its reference count is maintained using atomic operations, making it safe to share across threads, though it comes with some overhead.
Characteristics:
- Multiple owners.
- Immutable access only.
- Thread-safe.
- Slightly more expensive than
Rc<T>.
#![allow(unused)]
fn main() {
use std::sync::Arc;
let data = Arc::new(String::from("hello"));
}
Arc<T> only makes ownership thread-safe. It does not make mutation thread-safe.
Weak<T>
Weak<T> is a non-owning reference into data managed by Rc<T> or Arc<T>.
Unlike strong references, weak references do not contribute to the strong reference count and therefore do not keep a value alive.
Characteristics:
- Non-owning.
- Prevent reference cycles.
- Must be upgraded before use.
#![allow(unused)]
fn main() {
use std::rc::Rc;
// Strong reference = owner.
// Weak reference = observer.
let rc = Rc::new(5);
let weak = Rc::downgrade(&rc);
assert!(weak.upgrade().is_some());
drop(rc);
assert!(weak.upgrade().is_none());
}
Cell<T>
Cell<T> provides interior mutability for small Copy types.
Rather than handing out references, Cell<T> copies values in and out of the container. Because references are never exposed, no runtime borrow checking is required.
Characteristics:
- Interior mutability.
- No runtime borrow checking.
- Best suited for
Copytypes. - Single-threaded.
#![allow(unused)]
fn main() {
use std::cell::Cell;
let count = Cell::new(0);
count.set(5);
println!("{}", count.get());
}
RefCell<T>
RefCell<T> provides interior mutability through runtime borrow checking.
Instead of enforcing borrowing rules at compile time, RefCell<T> enforces them while the program runs.
Characteristics:
- Interior mutability.
- Runtime borrow checking.
- Single-threaded.
- Violations cause a panic.
#![allow(unused)]
fn main() {
use std::cell::RefCell;
let value = RefCell::new(5);
*value.borrow_mut() += 1;
}
Rc<RefCell<T>>
Rc<RefCell<T>> combines shared ownership with interior mutability.
Rc<T> provides multiple owners, while RefCell<T> allows those owners to mutate shared data safely through runtime borrow checking.
Characteristics:
- Multiple owners.
- Shared mutable data.
- Single-threaded.
#![allow(unused)]
fn main() {
use std::cell::RefCell;
use std::rc::Rc;
let data = Rc::new(RefCell::new(0));
let a = Rc::clone(&data);
let b = Rc::clone(&data);
*a.borrow_mut() += 1;
*b.borrow_mut() += 1;
}
Mutex<T>
Mutex<T> (Mutual Exclusion) protects data by allowing only one thread at a time to access it.
Access to the underlying value requires acquiring a lock.
Characteristics:
- Interior mutability.
- Thread-safe.
- One active accessor at a time.
#![allow(unused)]
fn main() {
use std::sync::Mutex;
let value = Mutex::new(0);
{
let mut guard = value.lock().unwrap();
*guard += 1;
}
}
Arc<Mutex<T>>
Arc<Mutex<T>> is the standard pattern for shared mutable state across threads.
Arc<T> provides shared ownership while Mutex<T> provides synchronized mutation.
#![allow(unused)]
fn main() {
use std::sync::{Arc, Mutex};
let counter = Arc::new(Mutex::new(0));
}
RwLock<T>
RwLock<T> (Reader-Writer Lock) allows multiple concurrent readers or one exclusive writer.
This can improve performance when reads are far more common than writes.
Characteristics:
- Many readers.
- One writer.
- Thread-safe.
#![allow(unused)]
fn main() {
use std::sync::RwLock;
let value = RwLock::new(5);
let read = value.read().unwrap();
}
Arc<RwLock<T>>
Arc<RwLock<T>> combines shared ownership with a reader-writer lock.
This pattern is commonly used for caches, configuration, and shared lookup structures.
#![allow(unused)]
fn main() {
use std::sync::{Arc, RwLock};
let data = Arc::new(RwLock::new(vec![1, 2, 3]));
}
Cow<'a, T>
Cow stands for Clone-On-Write.
It allows a value to be borrowed when possible and cloned only when mutation becomes necessary.
Internally, a Cow<T> is either:
#![allow(unused)]
fn main() {
Borrowed(&'a T)
Owned(T)
}
#![allow(unused)]
fn main() {
use std::borrow::Cow;
fn uppercase(s: &str) -> Cow<str> {
if s.chars().all(char::is_uppercase) {
Cow::Borrowed(s)
} else {
Cow::Owned(s.to_uppercase())
}
}
}
The primary benefit of Cow<T> is avoiding unnecessary allocations.
OnceCell<T>
OnceCell<T> stores a value that can be initialized exactly once.
After initialization, the value can be read many times but never replaced.
Characteristics:
- Write once.
- Read many.
- Single-threaded.
#![allow(unused)]
fn main() {
use std::cell::OnceCell;
let cell = OnceCell::new();
cell.set("hello").unwrap();
}
OnceLock<T>
OnceLock<T> is the thread-safe version of OnceCell<T>.
It is commonly used for global configuration and lazily initialized shared state.
#![allow(unused)]
fn main() {
use std::sync::OnceLock;
static CONFIG: OnceLock<String> = OnceLock::new();
CONFIG.get_or_init(|| "production".to_string());
}
LazyLock<T>
LazyLock<T> automatically initializes itself on first access.
It is built on top of OnceLock<T> and removes the need to call get_or_init() manually.
#![allow(unused)]
fn main() {
use std::sync::LazyLock;
static CONFIG: LazyLock<String> =
LazyLock::new(|| "production".to_string());
}
Pin<T>
Pin<T> guarantees that a value will not move in memory after being pinned.
This guarantee is required by self-referential types, async futures, generators, and other structures that depend on stable memory addresses.
Characteristics:
- Prevents movement.
- Does not prevent mutation.
- Common in async Rust.
#![allow(unused)]
fn main() {
use std::pin::Pin;
let value = Box::pin(String::from("hello"));
}
The key guarantee of Pin<T> is address stability.
UnsafeCell<T>
UnsafeCell<T> is the fundamental primitive that powers Rust’s interior mutability system.
Normally, Rust assumes that data behind an immutable reference cannot be mutated. UnsafeCell<T> is the one legal mechanism that allows this assumption to be bypassed.
Most programmers never use UnsafeCell<T> directly. Instead, higher-level abstractions are built on top of it:
Cell<T>RefCell<T>Mutex<T>RwLock<T>OnceCell<T>
#![allow(unused)]
fn main() {
use std::cell::UnsafeCell;
let value = UnsafeCell::new(5);
}
Direct access to the inner value requires unsafe code.
Concurrency in Rust
Concurrent Programming
Concurrent programming is the practice of structuring a program so that multiple tasks can make progress during the same period of time. The important idea is not that tasks are executing simultaneously, but that they are in progress together. Concurrency primarily improves responsiveness and throughput.
Imagine a web server. One client requests a file, another submits a form, and a third opens a websocket connection. If the server handled each request from start to finish before touching the next one, most of the CPU would spend its time waiting on disk reads and network operations. Concurrency allows the server to begin processing one request, pause when it must wait, work on another request, then return later to the original task.
Concurrency exists because real-world programs rarely perform one uninterrupted stream of CPU work. They spend time waiting for input/output operations, user interactions, network responses, database queries, timers, and many other external events. A concurrent design prevents the program from hanging whenever one task is blocked.
Parallel Programming
Parallel programming is the practice of executing multiple computations at the same instant. The key distinction is that parallelism requires actual simultaneous execution. This usually means multiple CPU cores.
Suppose you need to process a billion records; with a single CPU core, the work must happen one piece at a time. Even if the code is concurrent, only one instruction stream executes at any given moment.
With four CPU cores, you might split the records into four chunks and process all four chunks simultaneously. This is parallelism.
Concurrency vs Parallelism
Essentially, concurrency is managing many things at once while parallelism is doing many things at once. A program can be concurrent without being parallel.
Imagine a single-core CPU running a web server. The server handles thousands of connections by switching between tasks whenever one is waiting for network activity. Only one task executes at a time, yet many tasks are in progress. This is concurrency without parallelism.
Most modern systems combine both. A browser, for example, is concurrent because it manages many independent tasks, and parallel because those tasks may run on different CPU cores.
Threads
A thread is the smallest unit of execution that an operating system can schedule. When we say a program is “running”, what is actually running is at least one thread.
Every Rust program begins with a single thread called the main thread. As the program grows, it may create additional threads to perform work concurrently. Each thread has its own execution flow, meaning it can execute different instructions independently of other threads.
Process vs Thread
A process is an independent running program. When you launch an application the OS creates a process for it; each process receives its own virtual memory space and OS resources.
A thread exists inside a process. Multiple threads belonging to the same process share most of the process’s resources, including its memory. Because they share memory, threads can communicate much more easily than separate processes. However, this shared access also creates the possibility of race conditions, deadlocks, and data corruption.
Rust’s 1:1 Thread Implementation
Rust’s standard library uses a 1:1 threading model. This means one Rust thread corresponds directly to one OS thread.
When you call std::thread::spawn(), Rust asks the operating system to create a real OS thread. There is no language-level scheduler between Rust threads and OS threads; scheduling is handled entirely by the operating system.
This is an important design decision because languages such as Go, Erlang, and Java’s virtual-thread implementations introduce an additional runtime layer that schedules many user-level threads onto a smaller number of OS threads. Rust deliberately avoids this in the standard library, so no language-specific runtime is required for threading.
The downside is that OS threads are relatively expensive. Each thread requires its own stack and operating-system resources, and creating, destroying, and context-switching between large numbers of threads incurs overhead. As a result, creating thousands of OS threads is often impractical.
Other Thread Implementations
This section goes through alternative thread implementations. Feel free to skip it if you’re not interested.
1:1 Threading
In a 1:1 model, every user thread corresponds to a real OS thread. This is the model Rust uses, but there are crates available with other thread implementations.
The major advantage is simplicity. If the operating system knows about every thread, the OS scheduler can freely distribute them across CPU cores. The major disadvantage is cost. Creating and managing large numbers of OS threads consumes memory and scheduler resources.
N:1 Threading
In an N:1 model, many user threads are mapped onto a single OS thread. Here, the language runtime performs all the scheduling itself; the OS only sees one thread.
This allows user threads to be extremely lightweight because creating a new user thread does not require creating a new OS thread; however, this comes with major limitations:
If one user thread performs a blocking operation, the entire OS thread becomes blocked. Since all user threads depend on that single OS thread, every thread stops making progress. Another limitation is that true parallelism is impossible because only one OS thread exists. Historically, many early green-thread (more on this later) implementations used this approach.
M:N Threading
In an M:N model, many user threads are scheduled across multiple OS threads and the runtime scheduler decides which user threads run on which OS threads. This combines many advantages of both previous models:
- User threads remain lightweight.
- Multiple CPU cores can be utilized.
- Blocking one user thread does not necessarily block all others.
The downside is complexity; the runtime must implement an advanced scheduler, handle synchronization with the operating system, manage thread migration, and coordinate work distribution. Go’s goroutine scheduler is a variant of this model.
Green Threads
A green thread is a thread managed entirely by a language runtime rather than the operating system. The term originally referred to threads implemented in user space.
Because the runtime controls scheduling, green threads are usually very lightweight, fast to create, and cheap to switch between. Go goroutines are an example of this.
Green threads are not tied to a specific mapping model. Most modern green-thread systems use some variation of M:N scheduling. The important characteristic is simply that the runtime, not the operating system, decides when execution switches between tasks.
Why Rust Chose 1:1
Rust’s philosophy strongly favors giving programmers direct control over system behavior. A 1:1 threading model aligns with that goal; by relying on OS threads, Rust avoids requiring a large language runtime. This keeps binaries smaller and startup faster. It also allows Rust to integrate naturally with existing OS facilities such as schedulers, debuggers, profilers, and thread-management tools.
Another reason is that Rust’s ownership and type systems already solve many concurrency problems at compile time. Rust does not need a specialized runtime scheduler to enforce safety.
The tradeoff is that spawning OS threads remains relatively expensive. As a result, Rust developers often use, thread pools, async runtimes, work-stealing schedulers, etc when they need to manage extremely large numbers of concurrent tasks.
Spawning Threads and JoinHandles
In Rust, new threads are created with std::thread::spawn(). The function accepts a closure that contains the work that the new thread should perform and returns a JoinHandle<T>.
A spawned thread begins executing independently of the thread that created it. Because execution continues immediately after spawn() returns, both threads can run concurrently.
The join() method can be called on a JoinHandle to block the current thread until the spawned thread finishes executing. If the main thread exits before a spawned thread completes, the entire process terminates and any remaining threads are stopped.
use std::thread;
use std::time::Duration;
fn main() {
let handle = thread::spawn(|| {
for i in 1..10 {
println!("hi number {i} from the spawned thread!");
thread::sleep(Duration::from_millis(1));
}
});
handle.join().unwrap(); // Block until the spawned thread finishes
for i in 1..5 {
println!("hi number {i} from the main thread!");
thread::sleep(Duration::from_millis(1));
}
// We could also place `handle.join().unwrap()` here.
// In that case, both loops would run concurrently, and
// the main thread would wait for the spawned thread only
// after finishing its own work.
}
Ownership Across Threads
In a single-threaded program, ownership is primarily about preventing use-after-free, double frees, and invalid references. Once multiple threads enter the picture, ownership becomes a concurrency safety mechanism as well. Rust must guarantee that data remains valid even when multiple execution flows are running independently.
The move keyword forces a closure to take ownership of the values it captures from its environment. Instead of borrowing variables from the surrounding scope, ownership is transferred into the closure itself. Once ownership has moved, the closure becomes responsible for those values. You can use move with any closure, even in completely single-threaded code. Thread spawning simply happens to be one of the most common places where ownership transfer is required.
use std::thread;
fn main() {
let v = vec![1, 2, 3];
let handle = thread::spawn(move || {
println!("Here's a vector: {v:?}");
});
handle.join().unwrap();
println!("Here's a vector: {v:?}"); // compiler error; v was moved
}
If multiple threads genuinely need access to the same value, moving ownership into one thread is not sufficient. In that situation, ownership itself must become shareable; we solve this with shared ownership through reference counting (a pattern I’ll explain after Message Passing).
Message Passing
One of Rust’s preferred approaches to concurrency is message passing. Instead of allowing multiple threads to directly manipulate the same piece of memory, Rust encourages threads to communicate by sending values to one another. This philosophy comes from a famous idea popularized by languages such as Go and Erlang:
“Do not communicate by sharing memory; instead, share memory by communicating.”
Traditionally, concurrent programming often begins with shared memory. Multiple threads are given access to the same data structure, and synchronization primitives such as mutexes are used to prevent corruption. This works, but it also introduces many opportunities for mistakes. Threads can acquire locks in the wrong order, forget to synchronize access, create race conditions, or hold locks for too long.
Message passing approaches the problem from the opposite direction. Instead of multiple threads owning the same data, ownership is transferred between threads as messages. At any given moment, a piece of data typically has one owner. If another thread needs that data, ownership is sent to it. This fits remarkably well with Rust’s ownership system because ownership transfer is already a core language concept.
Channels in Rust
Rust implements message passing through channels. A channel can be thought of as a pipe connecting threads; one end is used to send values, the other is used to receive values. Whenever a value is sent through a channel, ownership moves from the sender to the receiver. This is an extremely important property because it means Rust can maintain its ownership guarantees even when data travels between threads.
Rust’s implementation of Channels is MPSC (Multi Producer, Single Consumer); meaning, you can make many clones of your senders and move them to different threads, but only one receiver may exist at a time. This means multiple sending threads may feed values into the same channel, while a single receiving thread consumes them.
The most common channel constructors in std::sync::mpsc are channel() and sync_channel().
channel()
use std::{sync::mpsc, thread, time::Duration};
fn main() {
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
let vals = vec![
String::from("hi"),
String::from("from"),
String::from("the"),
String::from("thread"),
];
for val in vals {
tx.send(val).unwrap();
thread::sleep(Duration::from_secs(1));
}
});
for received in rx {
println!("Got: {received}");
}
}
Creates an unbounded channel. Messages are stored in an internal buffer that can grow as needed, so send() does not block unless the receiver has been dropped. This is convenient when producers should be able to continue sending messages regardless of how quickly the receiver processes them, however, memory usage can grow if messages are produced faster than they are consumed.
sync_channel()
use std::{sync::mpsc, thread, time::Duration};
fn main() {
let (tx, rx) = mpsc::sync_channel(1024);
thread::spawn(move || {
let vals = vec![
String::from("hi"),
String::from("from"),
String::from("the"),
String::from("thread"),
];
for val in vals {
tx.send(val).unwrap();
thread::sleep(Duration::from_secs(1));
}
});
for received in rx {
println!("Got: {received}");
}
}
Creates a bounded channel with a fixed capacity. Once the buffer is full, further calls to send() block until the receiver removes a message. This introduces backpressure, meaning fast producers are forced to slow down when consumers cannot keep up. Backpressure helps prevent unbounded memory growth and keeps producers and consumers operating at a sustainable rate.
A special case is a capacity of 0:
#![allow(unused)]
fn main() {
let (tx, rx) = std::sync::mpsc::sync_channel(0);
}
This creates a rendezvous channel, where no buffering occurs. Each send operation waits until a receiver is ready to accept the message.
Channels for Synchronization
As well as being great data-transport mechanisms, Channels are also a great mechanism for synchronization between threads; suppose one thread sends a value and another thread waits to receive it, the receiving thread cannot continue until a message arrives. Therefore, the act of sending and receiving creates a coordination point between threads. This means channels often eliminate the need for explicit locks on data by simply communicating ownership. Many concurrent designs become much simpler this way.
Shared-State Concurrency
Up to this point, we’ve looked at concurrency through the lens of ownership transfer. Channels work because ownership moves from one thread to another, ensuring there is always a single owner of a piece of data. However, not every problem can be solved by transferring ownership.
Sometimes multiple threads genuinely need access to the same value at the same time (shared cache, global config object, connection pool, etc.) and it would be neither efficient nor practical to keep move ownership of resources between threads; this is where shared-state concurrency enters the picture.
Shared-state concurrency is the traditional concurrency model most programmers are familiar with. Multiple threads share access to the same memory. But this comes with the challenge of ensuring data-integrity. If multiple threads have access to the same value how can we be sure they won’t corrupt it?
Rust’s solution to this comes in the form of specialized smart pointers; mainly Arc<T> for thread-safe shared ownership. and Mutex<T> for thread-safe synchronized access. Similar to Rc<RefCell<T>> but thread-safe.
Mutex<T>
A mutex (short for mutual exclusion) is a synchronization primitive that ensures only one thread may access protected data at a time.
The fundamental idea is simple; before a thread accesses the protected data, it must acquire the lock. While the lock is held, every other thread attempting to acquire the lock must wait; when the thread finishes, it releases the lock and another waiting thread may proceed.
The mutex therefore serializes access to shared state. Even though multiple threads may exist, only one thread may access the protected value at any given moment. This prevents race conditions and ensures that reads and writes occur in a well-defined order.
MutexGuard
When a Rust mutex is locked, you do not directly receive access to the protected value; instead, you’re given a MutexGuard.
A MutexGuard is a smart pointer that represents ownership of the lock. As long as a thread owns the guard, the mutex remains locked and access to the protected value is permitted. Because only one guard may exist at a time, access is mutually exclusive.
Once the guard is dropped (goes out of scope), the lock is automatically released and can be given to other threads.
Because MutexGuard implements Drop, the lock is automatically released when the guard leaves scope, even during panic unwinding.
This eliminates an enormous category of bugs that plague languages requiring explicit unlock operations.
use std::sync::Mutex;
fn main() {
let counter = Mutex::new(0);
{
let mut guard = counter.lock().unwrap();
*guard += 1;
println!("counter = {}", *guard);
} // guard is dropped here, releasing the lock
println!("counter = {}", *counter.lock().unwrap());
}
Mutex Poisoning
Rust introduces an additional safety mechanism called poisoning.
Suppose a thread acquires a mutex and begins modifying shared data; halfway through the update, the thread panics.
At this point, the data may be in an inconsistent state. Perhaps only part of a structure was updated before execution stopped.
Instead of silently allowing other threads to continue, the mutex becomes poisoned and future lock attempts return a PoisonError, signaling that a previous owner panicked while holding the lock. this error result however, does not make the mutex unusable. You may still recover the data if you intentionally choose to do so.
Conceptually, Rust is saying:
“The previous owner of this lock panicked while modifying the data. I cannot guarantee the protected value is still consistent.”
The important point is that Rust forces you to acknowledge the possibility of corruption rather than ignoring it.
use std::sync::{Arc, Mutex};
use std::thread;
fn main() { // More on Arc incoming
let data = Arc::new(Mutex::new(vec![1, 2, 3]));
let data_clone = Arc::clone(&data);
let handle = thread::spawn(move || {
let mut numbers = data_clone.lock().unwrap();
numbers.push(4);
panic!("Something went wrong!"); // This poisons the lock
});
let _ = handle.join();
match data.lock() {
Ok(guard) => {
println!("Data: {:?}", *guard);
}
Err(poisoned) => {
println!("Mutex was poisoned!");
// Explicitly recover the data
let guard = poisoned.into_inner();
println!("Recovered data: {:?}", *guard);
}
}
}
Arc<T>
At this point we have a mechanism for synchronized mutable access; however, another problem remains. Who owns the mutex itself?
Suppose several threads need access to the same mutex; ownership cannot simply be moved into one thread because the other threads would lose access.
We need shared ownership. But we can’t use Rc<T> here because it’s not thread-safe.
An Rc<T> stores a reference count alongside the value.
Whenever a clone occurs the count is incremented, whenever an Rc<T> is dropped it’s decremented, and
when the count finally reaches zero the value is dropped.The problem is that these operations are not synchronized.
Imagine two threads incrementing the counter simultaneously; both threads might read the same count value; both compute the same new value and both write it back; one update is effectively lost and the reference count becomes incorrect.
Incorrect reference counts eventually lead to memory-safety problems, which Rust absolutely cannot allow.
For this reason, Rc<T> does not implement Send or Sync (More on these later); the compiler simply refuses to let it cross thread boundaries.
Ok I yapped too much; all of this is to say we need to use Arc<T> instead. Arc stands for Atomic Reference Counted and it’s the thread-safe alternative to Rc<T>.
Arc<T> achieves this thread-safety by reference counting atomically.
Atomic operations guarantee that updates remain correct even when multiple CPU cores modify the counter simultaneously. Since the counter is synchronized, the reference count cannot become corrupted.
It’s a drop in replacement for Rc<T> for thread-safe use cases but comes with some overhead.
Arc<Mutex<T>>
By combining Arc and Mutex, we can have thread-safe, multi-owner (reference-counted), mutable shared-state.
Arc<T> gives us thread-safe shared ownership and Mutex<T> gives us thread-safe mutable access.
- Sharing a Mutex Between Threads
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let counter = Arc::new(Mutex::new(0));
let mut handles = Vec::new();
for _ in 0..10 {
let counter = Arc::clone(&counter);
let handle = thread::spawn(move || {
let mut num = counter.lock().unwrap();
*num += 1;
});
handles.push(handle);
}
for handle in handles {
handle.join().unwrap();
}
println!("Final count: {}", *counter.lock().unwrap());
}
Message Passing vs Shared-State Concurrency
Message Passing and Shared-State Concurrency solve similar problems but use very different philosophies; In shared-state concurrency multiple threads can access the same data but access must be synchronized; however, message passing avoids sharing ownership whenever possible and transfers it instead. Neither approach is universally superior.
Message passing often leads to simpler designs because fewer objects are shared.
Shared-state concurrency can be more efficient when many threads genuinely need access to the same data.
In practice, real Rust applications often use both. A program might use channels to distribute work between threads while using mutexes to protect a shared cache or configuration object. Rust provides tools for both approaches because each has situations where it is the better fit.
All that said, Rust’s ownership system naturally favors channels since they’re essentially ownership-transfer pipelines. with channels ownership remains unambiguous, thus, entire categories of concurrency bugs become impossible and the compiler can verify that no thread continues using a value after it has been sent elsewhere.
Send & Sync
You might wonder how Rust knows whether a type can be used safely across threads. The answer is Auto Traits. Auto traits are traits that the compiler automatically implements for a type when all of its fields satisfy the same trait.
The most important auto traits are Send and Sync:
-
SendA type is
Sendif ownership of its value can be safely transferred from one thread to another. Most Rust types areSendincluding: Integers, Strings, Vectors, HashMaps any struct that is entirely composed ofSendtypes, etc.Rc<T>is the classic example of a type that is notSend. Its reference count is not synchronized. If ownership could freely move between threads, the count could become corrupted. Rust therefore refuses to allow it. -
SyncA type is Sync if references to the type can be safely shared between threads. For example, immutable data is often
Syncbecause multiple threads can safely read the same value simultaneously. AMutex<T>is also Sync because the mutex guarantees that mutable access remains synchronized.RefCell<T>is notSync. Because its borrow tracking is not thread-safe. Two threads attempting to manipulate the borrow state simultaneously would create problems.
A struct containing only Send and Sync fields will automatically implement Send and Sync itself.
Neither Send nor Sync provides any methods or behavior. Instead, they exist solely to describe thread-safety properties to the compiler. Traits that exist only to convey information about a type and do not define behavior are called Marker Traits.
Therefore, Send and Sync are both Auto Traits and Marker Traits.
Deadlocks
One of Safe Rust’s greatest strengths is its ability to prevent data races at compile time. However, not all concurrency bugs can be eliminated through the type system. Some concurrency failures arise from flawed program logic rather than unsafe memory access. One of the most common examples is a deadlock, where two or more threads become permanently blocked while waiting for resources held by one another; waiting for each other indefinitely, and none of them can make progress. The program is still running. Nothing has crashed. No panic occurred. Yet the affected threads are permanently stuck.
The most common deadlocks involve mutexes. Imagine two shared resources protected by two mutexes: Lock A and Lock B. Thread 1 acquires Lock A and then tries to acquire Lock B; at the same time, Thread 2 acquires Lock B and then tries to acquire Lock A; now both threads are waiting and neither thread can continue because each needs a lock currently owned by the other.
The program has reached a Deadlock.
MutexGuard helps reduce some locking mistakes because lock lifetime is tied directly to scope. By keeping scopes small, locks are released sooner, which can reduce opportunities for deadlocks.
There are other practical ways of avoiding deadlocks but they’re beyond the scope of this file.
Asynchronous Programming In Rust
A Word
This file focuses on the concepts behind asynchronous programming and how Rust implements them. It intentionally takes a high-level, runtime-agnostic approach, emphasizing the underlying ideas and design rather than the APIs of any particular async runtime.
As a result, you’ll find relatively few concrete examples or runtime-specific code snippets here. I plan to write separate notes covering popular runtimes (such as Tokio), where I’ll focus on their APIs, practical usage, and implementation details. I’ll link those notes here once they’re available.
Overview
Asynchronous programming is a programming model that allows a task to suspend execution while waiting for an operation to complete, allowing other tasks to make progress in the meantime. It is a language or library abstraction for expressing concurrency without requiring one operating system thread per task.
Operating systems already provide another mechanism for concurrent execution: threads. Using multiple OS threads to execute tasks concurrently is known as multithreaded programming.
Both asynchronous programming and multithreading enable concurrent execution, but they do so in different ways. Threads rely on the operating system scheduler to switch between threads, whereas asynchronous programming relies on tasks that voluntarily yield control at suspension points (such as await). Async code is often easier to scale to large numbers of waiting tasks, while threads are generally a better fit for CPU-bound work.
Prerequisites
Here, I’ll give a brief explanation of a few important topics that are necessary for the rest of this file.
CPU Bound Operations
A CPU-bound operation spends most of its time performing computations. Its execution speed is primarily limited by the processing power of the CPU rather than waiting for external resources. Because these tasks actively use the CPU, running multiple CPU-bound operations simultaneously often benefits from multiple CPU cores.
Examples include compressing a file, rendering an image, encrypting data, performing mathematical calculations, etc.
IO Bound Operations
An I/O-bound operation spends most of its time waiting for input or output rather than performing computations. During this waiting period, the CPU has very little work to do. Since the CPU is mostly idle while waiting, I/O-bound workloads benefit greatly from concurrent execution.
examples include reading or writing files, sending or receiving network requests, querying a database, waiting for user input, etc.
Blocking Calls
A blocking call prevents the current thread from doing anything else until the requested operation completes.
For example, if a thread calls a blocking function to read a file, the operating system suspends that thread until the data becomes available. While blocked, the thread cannot execute other work.
Blocking is simple to reason about, but can lead to poor resource utilization when many operations spend most of their time waiting.
Non-Blocking Calls
A non-blocking call returns immediately instead of waiting for an operation to finish. If the requested work cannot be completed immediately, the call returns without waiting. The caller can continue executing other work and obtain the result once the operation completes.
Asynchronous programming is largely built around non-blocking operations.
OS Threads
An operating system thread (or kernel thread) is the unit of execution managed and scheduled by the operating system. Each thread has its own call stack, registers, and execution state. The operating system’s scheduler decides when to pause one thread and allow another to run, a process known as preemption.
Threads make it possible for multiple tasks to execute concurrently, and on multi-core processors, some threads may execute in parallel.
Because OS threads are relatively expensive to create and context-switch, many languages and runtimes provide their own lightweight execution units that are scheduled onto a smaller number of OS threads.
User-Level Threads
A user-level thread is a lightweight unit of execution managed entirely by a language runtime or library instead of the operating system.
Unlike OS threads, the operating system is unaware of user-level threads. Instead, the runtime schedules many user-level threads onto a smaller number of OS threads. This scheduling strategy is commonly called the M:N threading model, because M user-level threads are multiplexed onto N operating system threads.
Because user-level threads are much cheaper to create and switch between than OS threads, applications can efficiently manage thousands or even millions of concurrent tasks. Examples include Go goroutines and Java virtual threads.
Modeling Concurrency Over OS Threads
Operating system threads are a powerful abstraction for concurrent execution, but they are not without cost. Each thread requires its own call stack, is created and managed by the operating system, and must be scheduled by the kernel. While this overhead is perfectly acceptable for dozens or even hundreds of threads, it becomes increasingly expensive when an application needs to manage tens or hundreds of thousands of concurrent operations.
This is particularly common in I/O-bound applications such as web servers, where most tasks spend the majority of their lifetime waiting for network or disk operations rather than actively using the CPU. Dedicating an entire operating system thread to every waiting task would waste both memory and scheduling resources.
To address this, many languages and runtimes introduce their own lightweight units of execution that are managed in user space instead of by the operating system.
Why Abstract Over OS Threads
Operating system threads remain the foundation of concurrent execution. Regardless of the programming language or runtime being used, code ultimately executes on one or more OS threads.
The goal of higher-level concurrency abstractions is therefore not to replace OS threads, but to use them more efficiently. Instead of creating one OS thread for every concurrent task, a runtime can schedule many lightweight tasks onto a much smaller number of OS threads. This greatly reduces memory usage, lowers scheduling overhead, and allows applications to efficiently manage enormous numbers of concurrent operations.
Different languages achieve this in different ways, but nearly all modern concurrency abstractions fall into one of two categories: stackful coroutines or stackless coroutines.
Coroutines
In its simplest form, a coroutine is just a task that can stop and resume by yielding control to either its caller, another coroutine, or a scheduler.
Stackful Coroutines
A stackful coroutine is a lightweight unit of execution that owns its own call stack, much like an operating system thread. Because each coroutine maintains an independent stack, it can suspend execution at almost any point in the call chain and later resume exactly where it left off.
This makes stackful coroutines feel very similar to ordinary threads while remaining significantly cheaper to create and manage. Since they are scheduled by a language runtime rather than the operating system, thousands or even millions of them can exist simultaneously.
Fibers
Fibers are one of the simplest forms of stackful coroutines. They are cooperatively scheduled, meaning a running fiber voluntarily yields execution rather than being preempted by the operating system. Because every fiber owns its own stack, switching between fibers preserves the complete execution state, including nested function calls and local variables.
Green Threads
Green threads extend the same idea by providing a thread-like abstraction that is managed entirely by a language runtime instead of the operating system. Rather than manually switching between execution contexts, the runtime automatically schedules green threads, allowing programmers to write code that closely resembles traditional multithreaded programs while avoiding the cost of creating large numbers of OS threads.
M:N Scheduling
Stackful coroutines are commonly scheduled using an M:N scheduling model, where M user-level threads are multiplexed onto N operating system threads.
The runtime determines which coroutine should execute next, while the operating system schedules only the smaller pool of OS threads. This allows applications to support vast numbers of concurrent tasks without requiring an equally large number of operating system threads.
Stackless Coroutines
Unlike stackful coroutines, stackless coroutines do not own an independent call stack. Instead, they suspend execution only at explicitly defined suspension points, such as an await or yield expression.
Because they do not require a separate stack for every coroutine, they are substantially smaller and cheaper to create than stackful coroutines. As a result, applications can efficiently manage millions of stackless coroutines while using only a small number of operating system threads.
Modern asynchronous programming languages commonly adopt this approach. Although the terminology varies between ecosystems, the underlying concept is largely the same. For example, Rust exposes stackless coroutines as Futures, JavaScript uses Promises together with async/await, and C# represents asynchronous operations with Tasks and so on.
State Machines
A state machine in its simplest form is a data structure that has a predetermined set of states it can be in. In the case of stackless coroutines, each state represents a possible pause/resume point. We don’t store the state needed to pause/resume the task in a separate stack (like stackful coroutines do); We save it in a data structure instead.
This has some advantages, the most prominent ones being they’re very efficient and flexible. The downside is that you’d never want to write these state machines by hand, so you need some kind of support from the compiler or some other mechanism.
Compiler Generated State Machines
Rather than preserving an entire call stack, a stackless coroutine is transformed by the compiler into a state machine. This state machine stores only the information necessary to resume execution later, such as local variables and the point at which execution previously suspended.
Each time the coroutine resumes, the state machine continues execution from the appropriate state until it either suspends again or completes. This transformation allows stackless coroutines to achieve their small memory footprint while still preserving the illusion that execution simply paused and later continued from the same location.
Event Queueing
Since non-blocking operations complete at unpredictable times, the operating system needs a way to inform applications when they are ready to continue.
Rather than forcing applications to repeatedly check whether every operation has completed (a technique known as busy polling) modern operating systems record completed or ready events in a queue. Applications simply wait for new events to appear, process them, and then continue executing.
This event-driven model allows a single thread to efficiently manage thousands of simultaneous I/O operations without continuously wasting CPU time checking whether each one has finished.
This naturally raises another question: how does the operating system notify applications that an asynchronous operation has become ready? The answer is event queues.
OS Event Queues
Every major operating system provides a mechanism for monitoring large numbers of I/O operations simultaneously. Although their implementations differ, they all serve the same purpose; efficiently notifying applications when an operation can make progress.
These mechanisms fall into two categories; readiness-based event queues and completion-based event queues.
Readiness-Based Event Queues
A readiness-based event queue notifies an application that an operation can now proceed without blocking. The operating system does not perform the operation on the application’s behalf; instead, it simply reports that the underlying resource has become ready.
For example, a network socket may become ready for reading because new data has arrived, or ready for writing because sufficient buffer space is available. Once notified, the application performs the actual read or write operation itself.
This model is widely used on Unix-like operating systems.
kqueue (macOS)
kqueue is the readiness notification interface provided by macOS and other BSD-based operating systems. Applications register the resources they wish to monitor, and the kernel reports whenever one of those resources becomes ready for an operation.
epoll (Linux)
epoll is Linux’s readiness notification mechanism for efficiently monitoring large numbers of file descriptors. Rather than repeatedly checking every descriptor, applications wait for epoll to report which resources are ready, greatly improving scalability for network servers and other I/O-intensive applications.
Completion-Based Event Queues
A completion-based event queue takes a different approach. Instead of notifying an application that an operation is ready to begin, it reports that the requested operation has already finished.
In this model, the application submits an I/O request to the operating system, which performs the operation asynchronously. Once the work is complete, the operating system places a completion event into the queue, allowing the application to retrieve the result without performing the operation itself.
This model eliminates the need for the application to retry or resume the I/O operation after receiving a notification, since the work has already been completed.
IOCP (Windows)
I/O Completion Ports (IOCP) are Windows’ high-performance completion-based I/O mechanism. Applications submit asynchronous I/O requests to the operating system, which performs the operations in the background. As each operation completes, the kernel places a completion event into the completion port, allowing worker threads to efficiently process completed requests. This design enables Windows applications to scale to very large numbers of concurrent I/O operations while using only a relatively small pool of threads.
Checkpoint
Hi there! From this point onward, the discussion becomes much more Rust-specific, so this is probably a good place to take a break.
I’ve tried to introduce each concept in a natural order, but a few ideas won’t fully click until you’ve seen the whole picture—especially the role of a runtime and its executor.
For now, just remember one important fact: Rust provides the language primitives for asynchronous programming, but it does not provide anything that actually runs asynchronous code.
To execute async programs, you need an asynchronous runtime. One of the runtime’s primary components is the executor, whose job is to drive futures to completion by polling them and scheduling tasks.
We’ll cover runtimes, executors, reactors, and wakers in detail near the end of this file. For now, it’s enough to think of the executor as the component responsible for making futures actually progress.
Async In Rust
Rust implements asynchronous programming using stackless coroutines. Every async code block and async fn is transformed by the compiler into a state machine that implements the Future trait (more on this later).
Unlike operating system threads, these state machines do not execute on their own. Instead, they represent asynchronous computations that can be suspended and later resumed by an executor. Each time a future is resumed, it makes progress until it either reaches another suspension point or completes.
async Blocks
An async block creates a future from an arbitrary block of code.
#![allow(unused)]
fn main() {
let future = async {
println!("Hello");
42
};
}
Like every future in Rust, async blocks are lazy. Creating it does not execute any of its code.
Nothing inside the block runs until the future is polled, typically by awaiting it or by submitting it to an executor as part of a task.
#![allow(unused)]
fn main() {
let future = async {
println!("Running...");
42
};
println!("Created the future.");
let value = future.await;
println!("{value}");
}
Produces:
Created the future.
Running...
42
The body of an async block can contain .await expressions, local variables, loops, conditionals, and nearly any other Rust construct.
Similar to futures returned by async fn, futures produced by async blocks can also be stored, passed to functions, composed with other futures, or spawned onto an executor.
async move
By default, an async block captures variables from its surrounding scope in much the same way that closures do. The compiler decides whether each variable should be borrowed or moved based on how it is used.
Sometimes, however, the future must own everything it uses. This is achieved with an async move block.
#![allow(unused)]
fn main() {
let s = String::from("Hello");
let future = async move {
println!("{s}");
};
}
Much like how it behaves with closures, the move keyword transfers ownership of captured values into the future itself. After move, the original scope can no longer use those values.
Because the future now owns its captured data, it also becomes responsible for cleaning it up. If the future completes normally, or is cancelled by being dropped, all values moved into the future are dropped as part of the future’s own destruction.
This ownership transfer is especially important when spawning tasks. Since a spawned task may continue executing long after the current scope has ended, it generally cannot borrow local variables. Instead, it must own any data it needs, making async move the common choice when spawning new tasks.
What the Compiler Sees
Although async and .await look like language constructs, the compiler ultimately lowers them into ordinary Rust code.
Consider the following function:
#![allow(unused)]
fn main() {
async fn hello() -> String {
String::from("Hello")
}
}
Conceptually, the compiler transforms it into something similar to:
#![allow(unused)]
fn main() {
fn hello() -> impl Future<Output = String> {
async {
String::from("Hello")
}
}
}
The exact generated type is an anonymous compiler-generated state machine, but from the programmer’s perspective it simply implements Future<Output = String>.
This explains an important property of async functions:
Calling an async fn does not execute its body.
Instead, calling the function constructs and returns a future.
#![allow(unused)]
fn main() {
let future = hello(); // Nothing has run yet.
}
Execution begins only when that future is first polled, usually by awaiting it. The same principle applies to ordinary async blocks.
#![allow(unused)]
fn main() {
let future = async {
println!("Hello");
};
}
Creating the block merely constructs another future. The code inside the block remains dormant until that future is polled.
Since futures are lazy, they must eventually be driven by something else. In practice, this usually happens in one of two ways:
- Awaiting the future, causing it to become part of the current task.
- Spawning the future onto an executor, creating a new task that the runtime polls independently.
Regardless of how the future is driven, the underlying mechanism is always the same: the executor repeatedly calls poll() until the future eventually returns Poll::Ready.
Futures
A Future, as the name implies, represents an operation whose result will become available in the future. Unlike a function call, creating a future does not immediately begin executing it. A future is lazy, meaning it performs no work until it is repeatedly polled by an executor.
Futures do not drive themselves. An executor polls them, and if a future returns Pending, it arranges to be woken when it can make further progress.
Every future eventually reaches one of two states:
Poll::Pending: the future cannot make further progress right now.Poll::Ready(output): the future has completed and produced its output.
This behavior is captured by the Future trait:
#![allow(unused)]
fn main() {
pub trait Future {
type Output;
fn poll(
self: Pin<&mut Self>,
cx: &mut Context<'_>,
) -> Poll<Self::Output>;
}
}
The poll() method takes two arguments:
self: Pin<&mut Self>gives the future mutable access to its own state. The reason futures are wrapped inPinis an important topic that we’ll cover later.cx: &mut Context<'_>provides the future with information about the current task. Most importantly, it gives the future access to aWaker, which it can use to notify the executor when it is ready to make more progress after returningPoll::Pending.
Each call to poll() asks the future to make as much progress as possible without blocking the current thread. If it cannot continue, it returns Poll::Pending. Once the asynchronous operation has completed, it returns Poll::Ready(output).
Leaf Futures & Non-Leaf Futures
Not all futures perform asynchronous work themselves. A leaf future is one that directly represents an asynchronous operation, such as waiting for a timer to expire or reading data from a socket. It interacts with the operating system or another asynchronous source and is ultimately responsible for determining when progress can be made.
A non-leaf future is built by composing one or more other futures. Rather than performing asynchronous work directly, it polls its child futures and combines their results. Most futures produced by async functions are non-leaf futures, as they simply coordinate and await other asynchronous operations. When polled, a non-leaf future propagates the poll down the chain until it eventually reaches one or more leaf futures.
A leaf future performs actual asynchronous work. It communicates directly with the operating system or an asynchronous runtime to initiate I/O operations.
So essentially, Executors poll the root future, which in turn polls its child futures, and so on, until the chain reaches one or more leaf futures. The leaf futures are responsible for interacting with the underlying asynchronous resources, while each non-leaf future simply coordinates and delegates progress to the futures it contains.
Because async functions return futures, awaiting a future requires the caller to become asynchronous as well. As a result, asynchrony propagates up the call chain. A leaf future performs the actual asynchronous work, while every caller that awaits it becomes a non-leaf future that composes and coordinates the operation.
Self-Referential Futures
Rust compiles every async fn and async block into a state machine. Whenever execution reaches an .await, the state machine must preserve enough information to resume execution later. This includes local variables that remain in scope after the suspension point.
Consider the following example:
#![allow(unused)]
fn main() {
async fn example() {
let s = String::from("Hello");
let r = &s;
some_async_operation().await;
println!("{r}");
}
}
When the compiler transforms this function into a state machine, both s and r become fields of the generated future. Since r references s, the future now contains a pointer to one of its own fields.
A future that contains references to its own data is called self-referential.
Because async functions can become self-referential, the compiler and the Future API must support futures that cannot safely be moved after polling begins. As a result, futures cannot generally be treated as values that may be freely moved in memory after execution begins.
Why Moving Them is Dangerous
Moving an ordinary Rust value is perfectly safe because ownership is transferred along with its contents. However, moving a self-referential future is different. Although the future’s fields move to a new memory location, any references stored inside the future still point to the old location. Those references immediately become invalid.
To prevent this, once a future begins being polled, it must remain at a fixed memory address for the rest of its lifetime.
This requirement explains why the Future trait defines poll() like this:
#![allow(unused)]
fn main() {
pub trait Future {
type Output;
fn poll(
self: Pin<&mut Self>, // Pin<T>:
cx: &mut Context<'_>,
) -> Poll<Self::Output>;
}
}
Instead of receiving a mutable reference (&mut Self), poll() receives a pinned mutable reference (Pin<&mut Self>), guaranteeing that the future will not be moved while it is being polled.
Pin<T>
Pin<T> is a wrapper type that guarantees the value it contains will not be moved after it has been pinned. This allows self-referential futures to safely store references to their own fields across suspension points.
Pinning is a language-level guarantee rather than a runtime feature. The executor does not keep track of object addresses or prevent futures from moving; instead, Rust’s type system ensures that once a future is pinned, safe Rust code cannot move it.
For asynchronous programming, the most important consequence is that executors always poll futures through Pin<&mut Self>, ensuring that any internal self-references remain valid throughout the future’s execution.
Unpin
Fortunately, not every type is self-referential. Most Rust types can be safely moved even after they have been pinned. Such types implement the Unpin auto trait; similar to Send and Sync.
For Unpin types, Pin<T> imposes no additional restrictions—they can be treated much like ordinary mutable references. Pinning only has a meaningful effect for types that do not implement Unpin, such as many compiler-generated futures.
In other words, Pin<T> is designed to support the small number of types that require a stable memory address, while Unpin allows the vast majority of Rust types to remain freely movable.
Not every compiler-generated future is actually self-referential, but the Future trait has to accommodate those that are. That’s why poll always takes Pin<&mut Self>, even though many futures end up being Unpin.
Tasks
One of the most common sources of confusion in async Rust is the distinction between a future and a task.
A future is simply a value that represents an asynchronous computation. By itself, it performs no work and remains idle until something polls it.
A task is a future that has been submitted to an executor for execution. The executor owns the task, polls its future, stores its state while it is suspended, and resumes it whenever it is able to make further progress.
In other words, a future describes what work should be performed, while a task represents that work being actively managed by an asynchronous runtime. This distinction is important because executors schedule tasks, not bare futures.
Every spawned task owns exactly one root future, although that future may itself contain many child futures created through await.
await vs spawn
await is Rust’s mechanism whereas spawn is a helper function that’s usually exposed by async runtime crates like tokio.
Both await and spawn are mechanisms for running asynchronous computations, but they do so in fundamentally different ways.
Awaiting a future does not create a new task. Instead, the awaited future becomes part of the current task’s state machine. When execution reaches an .await that cannot make immediate progress, the current task is suspended until the awaited future can continue.
Spawning is different. Rather than executing the future within the current task, the future is submitted to the executor as an entirely new task. The executor schedules it independently, allowing both the original task and the spawned task to make progress concurrently.
In short, await composes asynchronous operations into a single task, whereas spawning creates an additional task that is scheduled independently.
JoinHandle
When a future is spawned as a new task, the runtime typically returns a JoinHandle. Similar to spawning a new thread.
A JoinHandle represents the ability to observe the result of a spawned task. Awaiting the handle waits for the task to complete and retrieves its output.
The handle does not execute the task or cause it to begin running. The task starts executing as soon as it is submitted to the executor; the handle only provides a way to synchronize with it later.
Detached Tasks
A spawned task does not necessarily need to be awaited.
If the JoinHandle is dropped without being awaited, the spawned task may continue executing independently. Such a task is commonly called a detached task.
Detaching a task means giving up the ability to retrieve its result while allowing the runtime to continue scheduling it. The exact behavior depends on the runtime, but the concept remains the same: the task’s lifetime is no longer tied to the code that spawned it.
use std::time::Duration;
fn main() {
// trpl is a helper crate that re-exports
// functionality from the `futures` and `tokio` crates
trpl::block_on(async {
// This is a detached task because we ignore the `JoinHandle`
// returned by `spawn_task()`, giving up our ability to await
// or retrieve its result.
//
// In this example, the task does not finish because the runtime
// shuts down once the root task completes, dropping any remaining
// detached tasks.
trpl::spawn_task(async {
for i in 1..10 {
println!("hi number {i} from the first task!");
trpl::sleep(Duration::from_millis(500)).await
}
});
for i in 1..5 {
println!("hi number {i} from the second task!");
trpl::sleep(Duration::from_millis(500)).await;
}
});
}
Task Cancellation
A task is cancelled by dropping the future it owns.
Recall that a future is just a value representing an asynchronous computation. As long as the future exists, the executor may continue polling it. Once the future is dropped, however, it can never be polled again, so the asynchronous operation permanently stops making progress.
Unlike many asynchronous systems, Rust does not require a dedicated cancellation mechanism. Cancellation is simply a consequence of ownership and RAII. Dropping a future runs the destructors of all values it owns, immediately releasing resources such as file handles, network sockets, mutex guards, and heap allocations, just as they would be in synchronous code.
Cancellation is also cooperative, not preemptive. An executor cannot interrupt a future while it is actively executing. Instead, a future can only be cancelled after it has yielded control back to the executor by returning Poll::Pending. Once polling begins, the future continues executing until it either reaches another suspension point or completes.
use tokio::time::{sleep, Duration};
async fn work() {
println!("Started");
sleep(Duration::from_secs(5)).await;
println!("Finished");
}
#[tokio::main]
async fn main() {
let task = tokio::spawn(work());
sleep(Duration::from_secs(1)).await;
task.abort();
}
In this example, the task is aborted while the future is suspended at .await. Tokio cancels the task by dropping the future. As a result, "Finished" is never printed, the future is never polled again, and all resources owned by the future are cleaned up automatically.
Task Scheduling
Unlike operating system threads, which are preemptively scheduled by the operating system, asynchronous tasks are cooperatively scheduled. A task continues executing until it voluntarily yields control, usually by reaching an .await whose underlying future cannot make immediate progress. At that point, the future returns Poll::Pending, allowing the executor to schedule other runnable tasks.
This differs fundamentally from preemptive scheduling. An operating system can interrupt a running thread at almost any point and switch execution to another thread, even if the thread is performing a long-running computation. An executor, however, cannot interrupt a running task. Once a task begins executing, it continues running until it either completes or yields by returning from poll(). Ordinary synchronous code inside an async function therefore runs uninterrupted.
Consider the following task:
#![allow(unused)]
fn main() {
async fn work() {
heavy_computation(); // Runs uninterrupted.
read_socket().await; // May suspend here.
process_data(); // Runs uninterrupted.
}
}
The executor cannot schedule another task while heavy_computation() or process_data() are executing. Only when read_socket().await suspends does the executor regain control and become free to poll other tasks.
Because of this, asynchronous tasks should avoid performing long-running CPU-bound work without yielding. Such computations can monopolize the executor, delaying other tasks and reducing the responsiveness of the application. Async programming is therefore best suited to workloads that spend much of their time waiting for external events, while CPU-intensive work is typically delegated to dedicated threads or thread pools.
Many asynchronous runtimes also take advantage of parallelism by executing tasks on multiple operating system threads. However, asynchronous programming is primarily about improving concurrency rather than accelerating CPU-bound work. Its greatest benefits are seen in I/O-bound applications that spend much of their time waiting for external events.
Features the Standard Library Provides
Rust’s standard library, together with compiler support, defines the core abstractions required for asynchronous programming.
These include:
- The
Futuretrait - The
Pollenum - The
Contexttype - The
Wakertype - The
PinAPI, which allows futures to be safely polled without being moved - Compiler support for
async/await
Features the Standard Library Doesn’t Provide
While the standard library defines what a future is, it does not provide anything that actually executes asynchronous code.
In particular, the standard library does not include:
- an Executor
- an Event Loop
- Timers
- Asynchronous File I/O
- Asynchronous Networking
- Task Scheduling
- Spawning APIs
For example, this program compiles:
async fn hello() {
println!("Hello!");
}
fn main() {
let future = hello();
println!("Future created!");
}
Its output is:
Future created!.
Hello! is never printed. The future is merely constructed; it is never polled.
To actually execute it, an executor, like the one provided by the tokio crate, is required.
#[tokio::main]
async fn main() {
// The executor polls the future until it completes.
hello().await;
}
This separation is intentional. The Rust standard library provides the language primitives for asynchronous programming, while asynchronous runtimes such as Tokio, async-std, and smol provide the machinery that drives futures, schedules tasks, and interfaces with the operating system. This design gives the programmer freedom and allows multiple runtimes to coexist without requiring the language itself to choose a single asynchronous implementation.
Runtime
A runtime is the infrastructure responsible for driving asynchronous programs. It provides the machinery required to execute futures, schedule and execute tasks, interact with the operating system’s event queue, and wake suspended tasks when they are able to make progress.
The Rust standard library intentionally does not include a runtime. Instead, runtimes are provided by external libraries such as Tokio, async-std, and smol. This allows different runtimes to optimize for different workloads while sharing the same language-level async primitives.
Although implementations differ, most asynchronous runtimes contain two major components; an Executor, which schedules and polls futures, and a Reactor, which interfaces with the operating system’s event notification mechanisms. Together, these components allow asynchronous tasks to execute efficiently while using only a small number of operating system threads
Executor
The executor is the part of the runtime responsible for executing asynchronous tasks.
Recall that creating a future does not execute it. A future only makes progress when its poll() method is called. The executor repeatedly polls runnable tasks until their futures eventually return Poll::Ready.
If polling a future returns Poll::Pending, the executor stops polling it and moves on to other runnable tasks. The suspended task will remain idle until something notifies the executor that it may be able to make progress again.
Notice what the executor doesn’t do. It does not wait for network packets, timers, or file I/O to complete. Its only job is to execute Rust code by polling futures.
Reactor
While the executor runs Rust code, the reactor waits for external events.
Suppose a future tries to read from a socket:
#![allow(unused)]
fn main() {
async fn download() -> String {
socket.read().await
}
}
If no data is currently available, the future cannot continue executing. Instead of blocking the thread, it asks the reactor to monitor the socket.
The reactor registers interest in the event with the operating system using facilities such as epoll, kqueue, or IOCP. It then waits for the operating system to report that the socket has become readable.
The reactor never executes Rust code itself. Its job is simply to detect when external events occur.
Wakers
Once the reactor learns that an event has occurred, it must somehow notify the executor that the corresponding task should be polled again.
This is the purpose of a Waker.
Whenever a future returns Poll::Pending, it receives a Waker through its Context.
#![allow(unused)]
fn main() {
fn poll(
self: Pin<&mut Self>,
cx: &mut Context<'_>,
) -> Poll<Self::Output>
}
Before returning Poll::Pending, the future stores or registers this Waker with whatever asynchronous resource it is waiting on.
Later, when that resource becomes ready; for example, when the reactor learns that a socket has become readable, it calls waker.wake().
Calling wake() does not execute the future immediately. It simply tells the executor that the associated task should be placed back into its runnable queue. The executor will eventually poll it again, allowing execution to resume where it previously suspended.
Patterns & Matching Revisited
Overview
Patterns and pattern matching are fundamental features of idiomatic Rust. While I briefly introduced them in my earlier notes, this chapter explores them in greater depth. We’ll examine what patterns are, where they can be used throughout the language, how Rust matches values against them, and the powerful techniques they enable for writing expressive, concise code.
What Are Patterns?
A pattern is Rust’s way of describing the shape of a value. Rather than referring to a specific value directly, a pattern specifies what a value should look like in order to match. A pattern can match something as simple as a single literal or variable, or something much more complex, such as a struct, tuple, enum variant, or a deeply nested combination of these.
Patterns are also capable of binding parts of a matched value to new variables, ignoring values that are not needed, and expressing multiple possible matches. This makes them a concise and expressive way to inspect and decompose data.
For example, in the pattern (x, y), Rust expects a two-element tuple. If the value is (10, 20), the pattern matches successfully and binds 10 to x and 20 to y.
What is Pattern Matching?
Pattern matching is the process of comparing a value against one or more patterns to determine whether its structure satisfies a particular pattern. If a match succeeds, Rust can extract values from the matched data, bind them to variables, and execute code associated with that pattern.
Pattern matching is deeply integrated into Rust. Although it is most commonly associated with the match expression, it also appears in many other parts of the language, including let statements, if let, while let, function parameters, for loops, and closure parameter lists. Together, patterns and pattern matching provide a uniform mechanism for inspecting, destructuring, and working with data throughout Rust.
For example, when matching an Option<i32>, the pattern Some(value) matches only if the value is the Some variant, binding the contained integer to value. If the value is None, the pattern does not match.
#![allow(unused)]
fn main() {
let x = Some(42);
match x {
Some(value) => println!("{value}"),
None => println!("No value"),
}
}
All the Places We Can Use Patterns
This section explores all the places where patterns are valid and can be used throughout Rust.
Don’t worry if the matching aspect of some examples doesn’t make sense yet; we’ll cover that shortly when we discuss refutability and pattern matching.
For each construct below, I’ve also noted whether it requires an irrefutable pattern. This will become useful after reading the section on refutability.
match Arms
Requires Irrefutable Patterns: False
The most common place to use patterns is in the arms of a match expression. Each arm contains a pattern that is compared against the matched value. The first pattern that matches is selected, and its corresponding code is executed.
#![allow(unused)]
fn main() {
let number = 2;
match number {
1 => println!("One"),
2 => println!("Two"),
_ => println!("Something else"),
}
}
let Statements
Requires Irrefutable Patterns: True
Patterns can also appear in let statements to destructure values as they are bound to variables. This allows complex values to be split into their individual components in a single statement.
#![allow(unused)]
fn main() {
let (x, y) = (10, 20);
println!("{x}, {y}");
}
Conditional if let Statements
Requires Irrefutable Patterns: False
The if let construct is a convenient way to match a single pattern without writing a full match expression. The body executes only if the value matches the specified pattern.
#![allow(unused)]
fn main() {
let value = Some("Rust");
if let Some(language) = value {
println!("{language}");
}
}
let...else Expressions
Requires Irrefutable Patterns: False
A let...else expression requires a value to match a pattern. If the match succeeds, execution continues normally; otherwise, the else block must diverge (for example, by returning or panicking).
#![allow(unused)]
fn main() {
let Some(name) = Some("Alice") else {
return;
};
println!("{name}");
}
while let Conditional Loops
Requires Irrefutable Patterns: False
A while let loop repeatedly matches a pattern and continues looping as long as the match succeeds. It is commonly used when processing values until a particular condition is reached (aka. pattern doesn’t match anymore).
#![allow(unused)]
fn main() {
let mut stack = vec![1, 2, 3];
while let Some(value) = stack.pop() {
println!("{value}");
} // Stops once .pop() returns None
}
for Loops
Requires Irrefutable Patterns: True
Patterns can be used in for loops to destructure each item produced by an iterator as it is iterated over.
#![allow(unused)]
fn main() {
let points = [(1, 2), (3, 4), (5, 6)];
for (x, y) in points {
println!("({x}, {y})");
}
}
Function Parameters
Requires Irrefutable Patterns: True
Function parameters are themselves patterns. This allows values passed to a function to be destructured immediately instead of inside the function body.
#![allow(unused)]
fn main() {
fn print_point((x, y): (i32, i32)) {
println!("({x}, {y})");
}
}
Closure Parameter Lists
Requires Irrefutable Patterns: True
Like function parameters, closure parameters are patterns. This makes it possible to destructure arguments directly as they are received by the closure.
#![allow(unused)]
fn main() {
let points = [(1, 2), (3, 4), (5, 6)];
points.iter().for_each(|&(x, y)| {
println!("({x}, {y})");
});
}
Irrefutability vs Refutability
Every pattern in Rust falls into one of two categories: irrefutable or refutable. The distinction is based on a single question:
Can this pattern ever fail to match?
An irrefutable pattern is one that is guaranteed to match every possible value of the expected type. Since it can never fail, Rust knows execution can safely continue after the pattern is applied.
For example, the pattern x is irrefutable because it matches any value by simply binding that value to the variable x. Likewise, the tuple pattern (x, y) is irrefutable when matching a two-element tuple, because every value of type (T1, T2) necessarily has exactly two elements.
#![allow(unused)]
fn main() {
let x = 42; // Never fails
let (x, y) = (10, 20); // Never fails
}
A refutable pattern, on the other hand, only matches values with a particular shape. If the value has a different shape, the match fails.
For example, the pattern Some(x) only matches values of the form Some(...). If the value is None, the pattern does not match.
#![allow(unused)]
fn main() {
let value = Some(42);
// This succeeds
if let Some(x) = value {
println!("{x}");
}
let value = None;
// This fails
if let Some(x) = value {
println!("{x}");
}
}
The distinction between refutable and irrefutable patterns determines where a pattern is allowed to appear. Constructs such as let statements, function parameters, and for loops require irrefutable patterns, because there is no meaningful way for execution to continue if the pattern were allowed to fail. Since these constructs don’t provide a mechanism for handling failure, Rust requires patterns that are guaranteed to succeed.
By contrast, constructs such as match, if let, while let, and let...else are specifically designed to handle the possibility of a failed match. They therefore accept refutable patterns, allowing your program to take different actions depending on whether the pattern matches.
Attempting to use a refutable pattern where an irrefutable one is required results in a compile-time error.
#![allow(unused)]
fn main() {
let value: Option<i32> = Some(42);
let Some(x) = value; // Error!
}
The compiler rejects this because value could have been None, meaning the pattern Some(x) is not guaranteed to match. In situations like this, you should instead use a construct that is capable of handling failure, such as if let or let...else.
Pattern Matching 101
Now that we’ve covered where patterns can be used and the distinction between refutable and irrefutable patterns, it’s time to learn the pattern syntax itself. In this section, we’ll gradually build up from the simplest patterns to more powerful ones, eventually covering destructuring, match guards, and bindings.
Matching Literals
The simplest kind of pattern is a literal pattern, which matches a single, exact value. A literal pattern succeeds only if the matched value is equal to that literal.
#![allow(unused)]
fn main() {
let x = 2;
match x {
1 => println!("One"),
2 => println!("Two"),
3 => println!("Three"),
_ => println!("Something else"),
}
}
Here, the pattern 2 matches because x is exactly 2. Had x been any other value, Rust would have continued checking the remaining patterns until one matched.
Matching Named Variables
A variable in a pattern doesn’t compare against an existing variable; it creates a new binding. Consequently, a variable pattern matches any value and binds that value to the variable.
#![allow(unused)]
fn main() {
let x = Some(5);
match x {
Some(value) => println!("The value is {value}"),
None => println!("No value"),
}
}
In this example, the pattern Some(value) matches any Some(...) variant and binds its contents to the newly created variable value.
Be careful when using variable patterns inside a match: if a variable with the same name already exists, the pattern creates a new variable that shadows the existing one rather than comparing against it.
#![allow(unused)]
fn main() {
let x = Some(5);
let y = 10;
match x {
Some(y) => println!("Matched, y = {y}"),
None => {}
}
println!("Outer y = {y}");
}
This prints:
Matched, y = 5
Outer y = 10
The y inside the match is a new binding that temporarily shadows the outer y.
Matching Multiple Patterns
Sometimes several patterns should produce the same result. Instead of duplicating code, Rust allows multiple patterns to be combined using the | operator (pronounced or). The match succeeds if any of the listed patterns match.
#![allow(unused)]
fn main() {
let x = 2;
match x {
1 | 2 => println!("One or two"),
3 => println!("Three"),
_ => println!("Something else"),
}
}
This is equivalent to writing separate arms for 1 and 2, but is shorter and avoids repetition.
Matching Ranges
Rust also allows patterns to match an inclusive range of values using the ..= operator. Range patterns are supported for numeric types and chars because those values have a well-defined ordering known at compile time.
#![allow(unused)]
fn main() {
let x = 4;
match x {
1..=5 => println!("Between one and five"),
_ => println!("Something else"),
}
}
The pattern 1..=5 matches any integer from 1 through 5, inclusive.
Range patterns work equally well with characters.
#![allow(unused)]
fn main() {
let c = 'm';
match c {
'a'..='z' => println!("Lowercase letter"),
'A'..='Z' => println!("Uppercase letter"),
_ => println!("Something else"),
}
}
Since 'm' falls within the range 'a'..='z', the first arm is selected.
Multiple patterns and ranges can also be combined naturally.
#![allow(unused)]
fn main() {
let x = 10;
match x {
1 | 3 | 5 => println!("An odd number"),
10..=20 => println!("Between ten and twenty"),
_ => println!("Something else"),
}
}
The pattern 10..=20 matches because 10 lies within the specified range.
Destructuring with Patterns
One of the greatest strengths of patterns is their ability to destructure values; that is, to break complex data types into their individual components. Rather than accessing fields or elements one at a time, a pattern can extract everything you need in a single operation.
Destructuring works with many of Rust’s compound types, including structs, enums, tuples, and even combinations of these. As you’ll see, patterns can be nested arbitrarily, allowing you to work with deeply structured data in a concise and expressive way.
Destructuring Structs
Structs are destructured by writing a pattern that mirrors the struct’s fields. Each matched field can either be bound to a new variable or ignored if it isn’t needed.
#![allow(unused)]
fn main() {
struct Point {
x: i32,
y: i32,
}
let point = Point { x: 10, y: 20 };
match point {
Point { x, y } => {
println!("x = {x}, y = {y}");
}
}
}
When the variable name is the same as the field name, Rust allows the shorthand x instead of x: x.
If you’d like to bind a field to a variable with a different name, you can do so explicitly.
#![allow(unused)]
fn main() {
struct Point {
x: i32,
y: i32,
}
let point = Point { x: 10, y: 20 };
match point {
Point { x: x_coord, y: y_coord } => {
println!("({x_coord}, {y_coord})");
}
}
}
You can also match specific field values while binding others.
#![allow(unused)]
fn main() {
struct Point {
x: i32,
y: i32,
}
let point = Point { x: 0, y: 7 };
match point {
Point { x: 0, y } => println!("On the y-axis at {y}"),
Point { x, y: 0 } => println!("On the x-axis at {x}"),
Point { x, y } => println!("({x}, {y})"),
}
}
Destructuring Enums
Enum variants are destructured by writing a pattern for the specific variant you wish to match. If the variant contains associated data, that data can be extracted directly into variables.
#![allow(unused)]
fn main() {
enum Message {
Quit,
Write(String),
Move { x: i32, y: i32 },
}
let message = Message::Move { x: 5, y: 10 };
match message {
Message::Quit => println!("Quit"),
Message::Write(text) => println!("{text}"),
Message::Move { x, y } => println!("Move to ({x}, {y})"),
}
}
Notice that each variant has its own shape. The pattern must mirror that shape in order to match successfully.
Destructuring Nested Structs and Enums
Patterns can be nested, allowing you to destructure values inside other values. Each level of the pattern mirrors the structure of the data being matched.
#![allow(unused)]
fn main() {
enum Color {
Rgb(u8, u8, u8),
}
enum Message {
ChangeColor(Color),
}
let message = Message::ChangeColor(Color::Rgb(255, 0, 0));
match message {
Message::ChangeColor(Color::Rgb(r, g, b)) => {
println!("Red = {r}, Green = {g}, Blue = {b}");
}
}
}
Even though the value consists of multiple nested types, the entire structure is matched and destructured in a single pattern.
Destructuring Structs and Tuples
Patterns are not limited to a single data type. Since patterns describe the shape of a value, they can freely combine structs, tuples, enums, and other compound types whenever those types are nested inside one another.
#![allow(unused)]
fn main() {
struct Point {
x: i32,
y: i32,
}
let ((a, b), Point { x, y }) = (
(1, 2),
Point { x: 3, y: 4 },
);
println!("{a}, {b}, {x}, {y}");
}
The pattern mirrors the structure of the value exactly: first a tuple containing another tuple and a struct, then patterns that destructure each of those values into their individual components.
Ignoring Values in a Pattern
Sometimes you’re only interested in part of a value. In these situations, Rust allows you to ignore the fields, elements, or bindings that you don’t need. This keeps patterns concise while making it clear which parts of a value are actually relevant.
Rust provides several ways to ignore values, each suited to a slightly different situation.
Ignoring an Entire Value with _
The underscore (_) is a special pattern that matches any value without binding it to a variable. It is commonly used when you don’t care about the matched value at all.
#![allow(unused)]
fn main() {
let x = Some(5);
match x {
Some(_) => println!("Got a value!"),
None => println!("No value."),
}
}
Even though the Some variant contains a value, the pattern ignores it completely.
You’ll often see _ used as the final arm of a match expression to handle every remaining case.
#![allow(unused)]
fn main() {
let number = 42;
match number {
1 => println!("One"),
2 => println!("Two"),
_ => println!("Something else"),
}
}
Here, _ acts as a catch-all pattern that matches anything not handled by the previous arms.
Ignoring Parts of a Value with _
The _ pattern can also be used inside larger patterns to ignore only specific fields or elements while extracting the ones you care about.
#![allow(unused)]
fn main() {
let point = (3, 7);
match point {
(x, _) => println!("x = {x}"),
}
}
Only the first element is bound to x; the second is ignored.
The same idea applies when destructuring structs and enums.
#![allow(unused)]
fn main() {
struct Point {
x: i32,
y: i32,
}
let point = Point { x: 5, y: 10 };
match point {
Point { x, y: _ } => println!("x = {x}"),
}
}
Ignoring Unused Variables with the _ Prefix
A variable whose name begins with an underscore is still bound to the matched value, but Rust suppresses the warning that the variable is unused.
#![allow(unused)]
fn main() {
let s = Some(String::from("hello"));
if let Some(_text) = s {
println!("Got a string!");
}
}
This differs from the plain _ pattern. The pattern _text creates a variable, whereas _ does not.
This distinction matters because binding a value can affect ownership.
#![allow(unused)]
fn main() {
let s = Some(String::from("hello"));
if let Some(_) = s {
println!("Matched!");
}
println!("{s:?}");
}
Since _ doesn’t bind the String, s is still available after the match.
By contrast:
#![allow(unused)]
fn main() {
let s = Some(String::from("hello"));
if let Some(_text) = s {
println!("Matched!");
}
// println!("{s:?}"); // Error: `s` was partially moved.
}
Although _text is never used, it still takes ownership of the String, causing s to be partially moved.
Ignoring Remaining Parts of a Value with ..
When a value contains many fields or elements, writing _ for each one can become tedious. The .. pattern allows you to ignore all remaining parts of a value that aren’t explicitly matched.
For example, suppose we only care about the first and last elements of a tuple.
#![allow(unused)]
fn main() {
let numbers = (1, 2, 3, 4, 5);
match numbers {
(first, .., last) => {
println!("first = {first}, last = {last}");
}
}
}
Likewise, when destructuring a struct, .. can ignore every field that isn’t explicitly listed.
#![allow(unused)]
fn main() {
struct Point3D {
x: i32,
y: i32,
z: i32,
}
let point = Point3D {
x: 1,
y: 2,
z: 3,
};
match point {
Point3D { x, .. } => println!("x = {x}"),
}
}
The .. pattern is especially useful when working with large structs or tuples, allowing you to focus only on the fields that matter while ignoring everything else.
Match Guards
A match guard is an additional condition that can be attached to a match arm using the if keyword. The guard is evaluated only after the pattern itself has successfully matched. The arm is selected only if both the pattern and the guard evaluate to true.
Match guards are useful when a pattern alone cannot express the desired matching logic.
#![allow(unused)]
fn main() {
let num = Some(4);
match num {
Some(x) if x % 2 == 0 => println!("{x} is even"),
Some(x) => println!("{x} is odd"),
None => println!("No value"),
}
}
Here, both arms use the pattern Some(x). The first arm is selected only when the additional condition x % 2 == 0 is satisfied. Otherwise, Rust continues checking the remaining arms.
Because guards are arbitrary boolean expressions, they can reference variables introduced by the pattern as well as variables from the surrounding scope.
#![allow(unused)]
fn main() {
let x = Some(5);
let y = 5;
match x {
Some(n) if n == y => println!("Matched!"),
_ => println!("No match."),
}
}
Notice that the pattern introduces the variable n, while y comes from the surrounding scope. The guard compares the two values after the pattern has matched.
One important consequence of using a match guard is that the compiler can no longer exhaustively analyze the guarded arm. As a result, you’ll often need an additional arm to handle the remaining cases.
Bindings with @
The @ operator allows you to simultaneously test a value against a pattern and bind that value to a variable. Without @, you often have to choose between checking a pattern and capturing the matched value.
Consider the following enum:
#![allow(unused)]
fn main() {
enum Message {
Hello { id: i32 },
}
let msg = Message::Hello { id: 5 };
}
Suppose we want to match IDs between 3 and 7, while also keeping the matched ID available. The @ operator makes this straightforward.
#![allow(unused)]
fn main() {
match msg {
Message::Hello {
id: id_variable @ 3..=7,
} => {
println!("Found an ID in range: {id_variable}");
}
Message::Hello { id: 10..=12 } => {
println!("Found an ID between 10 and 12");
}
Message::Hello { id } => {
println!("Some other ID: {id}");
}
}
}
The pattern id_variable @ 3..=7 can be read as:
Match the pattern
3..=7, and if it succeeds, bind the matched value toid_variable.
Without @, you could still determine whether the ID falls within the desired range, but you would have to perform the range check separately after binding the value or duplicate the matching logic. The @ operator combines both operations into a single, expressive pattern.
Miscellaneous Topics
Overview
In this file, I will explore a few topics that I couldn’t fit anywhere else and aren’t complex enough to warrant their own dedicated file.
Trait Objects
Trait objects are Rust’s mechanism for runtime polymorphism. They allow a value to be treated as “some type that implements a particular trait” without knowing the concrete type at compile time. Unlike generics, which use static dispatch and generate specialized code for each concrete type, trait objects use dynamic dispatch, meaning the method to call is determined at runtime.
Consider the following:
#![allow(unused)]
fn main() {
trait Draw {
fn draw(&self);
}
// Multiple types implement this
struct Button;
struct TextBox;
impl Draw for Button {
fn draw(&self) {
// Implementation specific to this type
println!("Drawing a button");
}
}
impl Draw for TextBox {
fn draw(&self) {
// Implementation specific to this type
println!("Drawing a text box");
}
}
}
Instead of working with a specific concrete type, a trait object works with any value implementing the trait.
#![allow(unused)]
fn main() {
// This could be either Button or TextBox
let widget: Box<dyn Draw> = Box::new(Button);
widget.draw();
// Or even this is possible
let widgets: Vec<Box<dyn Draw>> = vec![
Box::new(Button),
Box::new(TextBox),
Box::new(TextBox),
Box::new(Button),
Box::new(Button),
Box::new(Button),
Box::new(TextBox),
];
for widget in widgets {
widget.draw();
}
}
Here, dyn Draw means “some type that implements Draw.” The concrete type is hidden, leaving only the trait’s interface.
If you paid attention, in the aforementioned example our trait objects were boxed. This is because of the fact that trait objects are Dynamically Sized Types (DSTs) and their size is unknown at compile time; So, they have to be behind a pointer such as Box, &, Rc, or Arc.
Essentially, trait objects are fat pointers that contain two machine-word-sized pointers; one is a data pointer that points to the actual object; and the other is a pointer to a vtable, which contains metadata for the concrete type, including pointers to the implementations of the trait’s methods.
Trait objects are most useful when the concrete type is not known until runtime or when multiple different types need to be treated uniformly. Common use cases include collections containing varied types (such as Vec<Box<dyn Draw>>), extensibility systems, event handlers and callbacks, and APIs that operate on any implementation of a trait without requiring the caller to use generics.
Static Dispatch
Static dispatch is a form of polymorphism where the compiler determines the exact function or method to call at compile time. Rust achieves this through generics and monomorphization, generating specialized code for each concrete type. Because the target is known ahead of time, the compiler can inline function calls and perform aggressive optimizations.
Dynamic Dispatch
Dynamic dispatch determines the function or method to call at runtime. Trait objects (dyn Trait) use dynamic dispatch by storing a pointer to a vtable, which contains the implementations of the trait’s methods for the underlying concrete type (data pointer). When a method is called, Rust looks it up in the vtable and invokes the appropriate implementation.
Object Safety
Not every trait can be used as a trait object. A trait must be object-safe, meaning its methods must be callable without knowing the concrete implementing type.
Object safety is the set of rules that determines whether a trait can be used as a trait object (dyn Trait). The underlying principle is that every method in the trait must be callable without knowing the concrete implementing type. Since a trait object “hides” the concrete type and only retains a pointer to the value and its vtable, methods cannot rely on information that has been “hidden”.
For example, our Draw trait from earlier examples is object-safe because its method can be called through the vtable without needing to know the concrete type:
In contrast, the following trait is not object-safe:
#![allow(unused)]
fn main() {
trait Cloneable {
fn clone(&self) -> Self;
}
}
The problem is that the return type is Self. Once a value has been hidden into a trait object (dyn Cloneable), Rust no longer knows what Self refers to, so it cannot determine what type clone() should return.
Similarly, traits with generic methods are not object-safe because a vtable contains a fixed set of function pointers and cannot accommodate every possible instantiation of a generic method. As a rule of thumb, a trait is generally object-safe if its methods do not return Self and are not themselves generic. These restrictions ensure that every method can be invoked through a trait object using only the information stored in its vtable.
In short, a trait is object-safe if it satisfies the following:
- The methods take
self,&selfor&mut self - Don’t return
Self - Are not generic
Trait Object & Performance
Trait objects incur a small runtime cost because every method call requires an indirect lookup through the vtable instead of a direct function call. Additionally, since the compiler cannot know the concrete type, it generally cannot inline trait method calls or perform some optimizations that are possible with static dispatch. This overhead is usually negligible unless the method is called extremely frequently in performance-critical code.
Unsafe Rust
Rust’s ownership model and borrow checker eliminate many classes of bugs at compile time. However, some low-level programming tasks cannot be expressed within Rust’s normal safety guarantees. For these situations, Rust provides Unsafe Rust, a restricted opt-out from some of the compiler’s safety checks.
Unsafe Rust is primarily intended for implementing low-level abstractions, interacting with foreign code, and performing operations that the compiler cannot verify as safe. Most Rust code should remain entirely safe, while unsafe code is typically isolated to small, well-thought-out sections.
Importantly, unsafe does not disable the borrow checker or turn off Rust’s safety guarantees entirely. It only grants permission to perform a handful of operations that are otherwise rejected by the compiler.
The Unsafe Superpowers
An unsafe block grants exactly five additional capabilities:
- Dereference raw pointers.
- Call unsafe functions or methods.
- Access or modify mutable static variables.
- Implement unsafe traits.
- Access fields of
unions.
These capabilities are commonly referred to as Unsafe Rust’s five superpowers.
#![allow(unused)]
fn main() {
unsafe {
// Unsafe operations are allowed here.
}
}
Marking a block as unsafe does not make everything inside it unsafe. Safe operations remain safe, and only the specific unsafe operations require the unsafe block.
Raw Pointers
Raw pointers (*const T and *mut T) are similar to references but are not subject to Rust’s borrowing rules. Unlike references, raw pointers may be null, dangling, alias mutable data, and do not guarantee valid memory.
Creating a raw pointer is safe, but dereferencing one is unsafe because the compiler cannot verify that it points to valid memory.
#![allow(unused)]
fn main() {
let x = 5;
let ptr = &x as *const i32;
unsafe {
println!("{}", *ptr);
}
}
Raw pointers are commonly used when interfacing with C libraries, implementing data structures, or working directly with memory.
Calling Unsafe Functions
Some functions are marked unsafe because the compiler cannot enforce their safety requirements. The caller must guarantee that the function’s documented preconditions are satisfied.
Declaring a function as unsafe shifts the responsibility for maintaining its safety invariants to its callers.
#![allow(unused)]
fn main() {
unsafe fn dangerous() {}
unsafe {
dangerous();
}
}
Creating Safe Abstractions over Unsafe Code
One of Rust’s core design principles is to keep unsafe code as small and localized as possible. Unsafe operations are often wrapped inside safe APIs that enforce the necessary invariants internally.
This allows the rest of a program to remain entirely safe while still benefiting from low-level implementations hidden behind well-designed interfaces.
Interfacing with Foreign Code
Rust can call functions written in other languages, such as C, through the Foreign Function Interface (FFI). Foreign functions are declared inside an extern block.
Because Rust cannot verify the behavior of foreign code, calling these functions is unsafe.
#![allow(unused)]
fn main() {
unsafe extern "C" {
fn abs(input: i32) -> i32;
}
unsafe {
println!("{}", abs(-5));
}
}
Accessing or Modifying Mutable Static Variables
A static variable has a fixed memory location for the duration of the program. Immutable statics are always safe, but mutable statics (static mut) are unsafe because simultaneous access from multiple threads can cause data races.
Whenever possible, synchronization primitives such as Mutex or atomic types should be preferred over static mut.
#![allow(unused)]
fn main() {
static mut COUNTER: u32 = 0;
unsafe {
COUNTER += 1;
}
}
Implementing Unsafe Traits
Some traits are marked unsafe because incorrect implementations can violate Rust’s safety guarantees.
Marking a trait or implementation as unsafe indicates that the implementer must uphold additional invariants beyond what the compiler can verify.
#![allow(unused)]
fn main() {
unsafe trait MyTrait {}
unsafe impl MyTrait for MyType {}
}
Accessing Fields of unions
A union allows multiple fields to occupy the same memory location. Since Rust cannot know which field currently contains a valid value, reading a union field is unsafe.
Unions are primarily used for low-level systems programming and interoperability with C.
#![allow(unused)]
fn main() {
union Value {
i: u32,
f: f32,
}
let value = Value { i: 42 };
unsafe {
println!("{}", value.i);
}
}
Advanced Traits
Rust’s trait system is powerful enough to express much more than shared behavior. In addition to defining methods, traits can specify associated types, default generic parameters, relationships with other traits, and even provide mechanisms for resolving naming conflicts.
These features are considered “advanced” not because they are difficult, but because they are needed less frequently. Nevertheless, they are fundamental building blocks used throughout the standard library and many third-party crates.
Associated Types
An associated type is a placeholder type defined as part of a trait. Instead of making the trait itself generic, the implementer specifies what concrete type the placeholder represents.
#![allow(unused)]
fn main() {
trait Iterator {
type Item;
fn next(&mut self) -> Option<Self::Item>;
}
}
When implementing the trait, the associated type is defined once.
#![allow(unused)]
fn main() {
struct Counter;
impl Iterator for Counter {
type Item = u32;
fn next(&mut self) -> Option<Self::Item> {
// ...
}
}
}
Associated types make traits easier to use because a type can implement the trait only once for a given associated type, eliminating the ambiguity that would arise with a generic trait. They also make the associated type part of the trait’s contract.
Default Generic Type Parameters
Generic parameters can have default types, allowing implementations to omit the generic argument when the default is appropriate.
#![allow(unused)]
fn main() {
trait Add<Rhs = Self> {
type Output;
fn add(self, rhs: Rhs) -> Self::Output;
}
}
The std::ops::Add trait uses this feature so that, by default, the right-hand operand has the same type as the left-hand operand.
Default generic parameters are commonly used to reduce boilerplate, preserve backward compatibility, support operator overloading.
#![allow(unused)]
fn main() {
impl Add for Point {
type Output = Point;
fn add(self, rhs: Point) -> Point {
Point {
x: self.x + rhs.x,
y: self.y + rhs.y,
}
}
}
}
Operator Overloading
Rust allows certain operators (such as +, -, *, and ==) to be overloaded by implementing traits from std::ops.
For example, implementing the Add trait defines the behavior of the + operator for a type.
#![allow(unused)]
fn main() {
use std::ops::Add;
impl Add for Point {
type Output = Point;
fn add(self, rhs: Point) -> Point {
Point {
x: self.x + rhs.x,
y: self.y + rhs.y,
}
}
}
}
Rust intentionally limits operator overloading to predefined operators and traits; you cannot invent entirely new operators.
Fully Qualified Syntax
Sometimes multiple traits (or a trait and the type itself) define methods with the same name. In these situations, Rust needs help determining which implementation you intend to call.
Suppose two traits both define fly().
#![allow(unused)]
fn main() {
Pilot::fly(&person);
Wizard::fly(&person);
person.fly();
}
The first two explicitly call the trait implementations, while the last calls the method defined directly on the type of &person.
For associated functions (those without a self parameter), even more explicit syntax may be required.
#![allow(unused)]
fn main() {
<Dog as Animal>::baby_name()
}
This is known as Fully Qualified Syntax, and its general form is:
#![allow(unused)]
fn main() {
<Type as Trait>::function(arguments...)
}
Most of the time Rust can infer the correct implementation, so fully qualified syntax is only needed to resolve ambiguity.
Supertraits
A supertrait is a trait that another trait depends on. By requiring one trait to inherit from another, the derived trait gains access to all of the supertrait’s associated items.
#![allow(unused)]
fn main() {
use std::fmt::Display;
trait OutlinePrint: Display {
fn outline_print(&self) {
// Uses Display::fmt()
}
}
}
Here, OutlinePrint requires every implementer to also implement Display, allowing outline_print() to use the formatting functionality provided by Display.
Supertraits are Rust’s way of expressing trait dependencies.
Advanced Types
Rust’s type system includes several advanced features that improve code clarity, expressiveness, and flexibility. These features don’t introduce new kinds of data, but rather new ways of working with types.
In this section, we’ll cover the newtype pattern, type aliases, the never type (!), and dynamically sized types (DSTs). While these features are less common than structs, enums, and generics, they appear throughout the standard library and are useful to understand.
The Newtype Pattern
The newtype pattern creates a new type by wrapping an existing type in a tuple struct.
#![allow(unused)]
fn main() {
struct UserId(u32);
}
Although UserId contains a u32, it is a completely distinct type. This allows the compiler to prevent accidentally mixing it with other u32 values.
Beyond working around the orphan rule, the newtype pattern has two additional benefits:
- It provides stronger type safety by preventing logically different values from being mixed.
- It allows a type to expose a custom public API, hiding the implementation details of the wrapped type.
Since the wrapper exists only at the type level, the compiler optimizes it away, so the pattern introduces no runtime overhead.
Type Aliases
A type alias gives an existing type another name using the type keyword.
#![allow(unused)]
fn main() {
type Kilometers = i32;
}
Unlike a newtype, a type alias does not create a new type, it simply introduces another name for the same type. Consequently, Kilometers and i32 are interchangeable.
Type aliases are most commonly used to simplify long or repetitive type signatures.
#![allow(unused)]
fn main() {
type Thunk = Box<dyn Fn() + Send + 'static>;
}
They are also widely used in the standard library. For example, std::io::Result<T> is simply an alias for Result<T, std::io::Error>, saving users from repeatedly writing the full type.
The Never Type (!)
The never type, written !, represents computations that never produce a value.
Examples include functions that panic, terminate the program, or loop forever.
#![allow(unused)]
fn main() {
fn never_returns() -> ! {
panic!("This function never returns!");
}
}
Since a value of type ! can never exist, Rust can automatically coerce it into any other type. This allows expressions like panic!(), continue, and return to appear where a value of another type is expected.
#![allow(unused)]
fn main() {
let x = match value {
Some(v) => v,
None => panic!("No value"),
};
}
Here, one match arm produces an i32, while the other produces !. The expression is still valid because ! can coerce into any type.
Dynamically Sized Types (DSTs)
Most types in Rust have a size that is known at compile time. These are called Sized types.
Some types, however, do not have a compile-time size. These are known as Dynamically Sized Types (DSTs).
The most common DST is str.
#![allow(unused)]
fn main() {
let s: &str = "hello";
}
A bare str cannot exist as a standalone value because its size is unknown. Instead, DSTs must always be used behind a pointer, such as &str, Box<str>, Rc<str>.
The pointer stores not only the address of the data, but also the metadata needed to determine its size at runtime.
The Sized Trait
Rust automatically assumes that every generic type parameter implements the Sized trait.
#![allow(unused)]
fn main() {
fn foo<T>(value: T) {
// T: Sized is assumed
}
}
This is equivalent to writing:
#![allow(unused)]
fn main() {
fn foo<T: Sized>(value: T) {
// ...
}
}
If a generic function should also accept dynamically sized types, the implicit bound can be relaxed using ?Sized.
#![allow(unused)]
fn main() {
fn foo<T: ?Sized>(value: &T) {
// ...
}
}
Notice that the parameter is a reference. Since a DST has no known size, it cannot be passed or stored by value and must instead be accessed through some form of pointer.
Advanced Functions and Closures
Functions and closures are closely related in Rust, and in most situations they can be used interchangeably. This chapter explores two advanced topics: function pointers and returning closures. These features are especially useful when writing generic libraries, inter-operating with other languages, or building APIs that produce behavior dynamically.
Function Pointers
A function pointer is a pointer to a regular function. Its type is written as fn, which should not be confused with the closure traits (Fn, FnMut, and FnOnce).
#![allow(unused)]
fn main() {
fn add_one(x: i32) -> i32 {
x + 1
}
fn do_twice(f: fn(i32) -> i32, arg: i32) -> i32 {
f(arg) + f(arg)
}
}
Because functions have a concrete type, they can be passed around just like any other value.
Function Pointers vs Closures
Function pointers and closures have similar call syntax, but they are not the same.
A function pointer refers to an existing function and cannot capture values from its surrounding environment. A closure, on the other hand, is an anonymous function that can capture variables from the scope in which it is created.
One important relationship between them is that function pointers implement all three closure traits (Fn, FnMut, and FnOnce). As a result, a function can always be passed wherever a closure is expected.
#![allow(unused)]
fn main() {
fn add_one(x: i32) -> i32 {
x + 1
}
fn apply<F>(f: F, x: i32) -> i32
where
F: Fn(i32) -> i32,
{
f(x)
}
apply(add_one, 5);
}
For this reason, it is generally preferable to accept a generic closure (F: Fn...) rather than a function pointer (fn...), since doing so allows callers to provide either a regular function or a closure. The primary exception is when interfacing with foreign languages such as C, which understand function pointers but not closures.
Returning Closures
Each closure in Rust has its own unique, anonymous type. Consequently, you cannot write a function whose return type is simply “a closure.”
Instead, closures are typically returned using impl Trait.
#![allow(unused)]
fn main() {
fn make_adder() -> impl Fn(i32) -> i32 {
|x| x + 1
}
}
The compiler knows the concrete closure type, even though it remains hidden from users of the function.
Returning Multiple Closure Types
The impl Trait approach works only when every execution path returns the same concrete closure type.
If different branches return different closures, the return type must be erased using a trait object.
#![allow(unused)]
fn main() {
fn make_adder() -> Box<dyn Fn(i32) -> i32> {
Box::new(|x| x + 1)
}
}
Boxing the closure behind dyn Fn gives all returned closures a common type, making it possible to store different closures in collections or return them from the same function.
Macros
Macros are Rust’s meta-programming facility. Unlike functions, which operate on values at runtime, macros operate on Rust code itself during compilation. They can generate, transform, or eliminate code before the compiler performs type checking and code generation.
Macros are most commonly used to reduce boilerplate, implement domain-specific languages (DSLs), and provide functionality that would be impossible or impractical to express with ordinary functions. Familiar examples from the standard library include println!, vec!, format!, and dbg!.
Declarative vs Procedural Macros
Rust supports two kinds of macros.
Declarative macros (also called macro_rules! macros) use pattern matching to transform one piece of Rust syntax into another. They are ideal for eliminating repetitive code and creating concise, expressive syntax.
Procedural macros are regular Rust programs that receive Rust code as input and produce Rust code as output. They are significantly more powerful than declarative macros and come in three forms:
- Derive macros (
#[derive(...)]) - Attribute macros (
#[my_attribute]) - Function-like macros (
my_macro!(...))
Procedural macros power many popular libraries, including serialization, web frameworks, and ORMs.
Why Macros Are So Powerful
Macros are one of Rust’s most powerful language features because they can generate code at compile time. This allows them to:
- Eliminate repetitive boilerplate.
- Create expressive APIs and mini domain-specific languages.
- Generate implementations automatically.
- Perform compile-time validation.
- Extend Rust’s syntax in ways that functions cannot.
Many of Rust’s most ergonomic libraries, including crates like Serde, Tokio, Diesel, and Axum, rely heavily on procedural macros to provide concise, declarative APIs.
At the same time, macros are considerably more complex than ordinary Rust code. They operate on Rust’s syntax rather than values, making them harder to write, debug, and understand. Fortunately, most Rust programmers spend far more time using macros than writing them.
Learning Resources
Macros are a substantial topic in their own right and deserve a dedicated study once you’re comfortable with the rest of the language. The following resources are excellent references:
- The Rust Book, Chapter 20.5: Macros.
- The Little Book of Rust Macros; a comprehensive guide focused entirely on Rust macros.
- The Rust Reference; the authoritative specification for macro syntax and behavior.
- The documentation for the
syn,quote, andproc-macro2crates, which form the foundation of most procedural macro development.
Unless you plan to write libraries or frameworks, you don’t need to master macros immediately. Being comfortable reading and using common macros such as println!, vec!, and derive macros is sufficient for most day-to-day Rust programming.