Chapter 1: Welcome to Rust

Rust is a modern systems language created to give programmers precise control of memory without the usual dangers that come with that level of power. It aims to make fast programs possible and also safe programs possible, and it does this through strict rules that the compiler checks long before code ever runs. The result is a language that feels careful rather than academic, and practical rather than experimental. You will see these traits everywhere as you work through this book.

Rust became popular because it solves real problems that older languages struggled with. People use it to write command line tools, servers, embedded firmware, desktop applications and more. It is known for strong community standards, excellent documentation and a growing ecosystem. If you want reliability and performance in the same place, Rust is designed for exactly that.

This chapter introduces the tools you need, the way Rust projects are organised and the small habits that help you read compiler output with confidence. Once you understand the basics here, you will be ready to move on to the deeper parts of the language.

What Rust is and why it exists

Rust was created to address two long standing challenges in systems programming: memory safety and concurrency safety. Traditional low level languages allow powerful operations, but they also make it easy to cause memory corruption or data races. Rust approaches the problem with a strict ownership model that prevents these issues at compile time. This means many errors that would only appear during execution in other languages are caught early.

The origin story of Rust began with Graydon Hoare, who worked at Mozilla and started the language as a personal side project in 2006. According to his own account, he began thinking about a safer systems language after dealing with a broken elevator in his apartment building, a small frustration that sparked large ideas. Hoare named the language Rust after a hardy group of fungi known for durability and survival, which reflected the qualities he wanted the language to have.

For several years he developed Rust quietly in his own time. Around 2009 a few people at Mozilla became interested, and the work gradually shifted from a single person’s experiment into a focused language project backed by a small team. Hoare drew inspiration from a wide range of older research and languages from the 1970s through the 1990s, including CLU, BETA, Mesa, NIL, Erlang, Newsqueak, Napier, Hermes, Sather, Alef and Limbo. He described Rust as technology from the past used to protect the future, and many of its core ideas come directly from those decades of research.

Other early contributors expressed similar views. Manish Goregaokar noted that the design of Rust stands on top of mostly long established theory, but presented in a way that makes it practical for real projects. This mixture of old ideas and new goals shaped Rust into the language it became: careful, modern, and aimed at solving long standing problems without trading away performance.

💡 Think of Rust as giving you a seatbelt that you cannot forget to put on. The compiler keeps you safe without taking performance away.

The language also emphasises clear error messages, strong tooling and a dependable standard library. These qualities make it suitable for both small personal projects and large production systems.

Installing rustup and toolchains

The easiest way to install Rust is with rustup. It manages compiler versions, keeps your tools updated and allows you to switch between stable, beta and nightly releases. This keeps your environment predictable. The official installation page provides current instructions for all platforms and is the best place to begin. You can find it at rust-lang.org/tools/install. The page lists the latest stable release and includes platform specific guidance.

On Linux and macOS the recommended installation method uses a small bootstrap script, shown below. It downloads rustup, installs the stable toolchain and configures your environment. If you prefer to read the script before running it, the installation page also links to its source.

curl https://sh.rustup.rs -sSf | sh

Windows users can install Rust through the official installer available on the same page. It sets up the toolchain and adds the needed paths. After installation you can open a new terminal and run the following command to confirm that everything is ready.

rustup --version
💡 rustup installs its own copy of the compiler, so you do not need to manage separate downloads yourself. The command rustup update keeps every installed toolchain current.

Once rustup is installed you have access to the stable compiler by default. You can add other toolchains with commands such as rustup toolchain install beta. This makes it easy to test new features without affecting your main projects.

After installation, the rustup command lets you manage toolchains and components. For example, you can view installed toolchains with rustup toolchain list. The stable toolchain is recommended when you begin unless you have a specific need for newer features.

Your first cargo project

cargo is Rust's build system and package manager. It handles compilation, dependencies, test running and more. To create a new project, run the command shown below. It creates a directory, a manifest file and a simple program ready to build.

cargo new hello-rust

This command creates a new folder named hello-rust in whatever directory your terminal is currently using. On Windows, if your terminal opens in your user directory, the new project will appear in a path similar to C:\Users\YourName\hello-rust which is also written as ~\hello-rust in shells that support the tilde shortcut. On macOS and Linux the tilde also points to your home directory, so the project usually appears as ~/hello-rust unless you changed directories before running the command.

Inside the project you will find a src folder that contains main.rs. You can open it with any text editor (see the next section). Cargo treats the entire project as a unit, so running cargo run inside the folder compiles and runs your program in one step.

⚠️ Cargo expects a consistent directory layout. Avoid moving files around unless you understand how Cargo discovers modules.

This structure keeps projects tidy and predictable. Every chapter builds on this layout, and you will become comfortable with it quickly.

Recommened Text Editors/IDEs

Rust works well in almost any editor, so you can choose whatever feels comfortable. The main thing to look for is reliable support for syntax highlighting, code completion and inline error messages. These features come from the Rust Language Server or the newer Rust Analyzer component, and most modern editors can use them. It is fine to begin with something simple and move to a larger IDE later once your projects grow.

As well as using your operating system's in-built text and/or programme editor(s), below is a small list of popular and well supported options. Each one integrates cleanly with Rust tooling and has an active community. The URLs are the official pages where you can download the editor or learn more about it.

You can write Rust in any editor that feels comfortable, and you can change later without losing anything. What matters most is that you have syntax highlighting, language server support and a layout that helps you focus on the code in front of you.

How to read compiler messages

The Rust compiler is strict, but also friendly. When something goes wrong it tries to explain what happened and why. Messages often include labels, hints and even small suggestions. Although they may seem long at first, they usually contain exactly the information you need.

error[E0382]: use of moved value: `x`
...

Reading and understanding compiler feedback is an essential part of learning Rust since the ownership rules are enforced at compile time. Error codes such as E0382 can be looked up online at the link below for a detailed explanation:

Using rustfmt and clippy

Rust includes practical tools that encourage consistent style and clear code. The formatter rustfmt rewrites your code to follow standard style rules, and clippy checks for common mistakes and performance issues. These tools help you maintain clean code without extra effort. Install them as follows (if not already):

rustup component add rustfmt clippy

Once installed, you can run cargo fmt to format your entire project and cargo clippy to analyse it. Both are widely used in the Rust community and make collaboration easier.

Project layout and workspace basics

A typical Cargo project consists of a Cargo.toml manifest, a src directory and any supporting files you choose to add. This predictable layout helps tools work smoothly and keeps codebases easy to navigate. Larger projects sometimes use workspaces, which allow multiple related crates to live in a single repository with shared settings.

my-app
├── Cargo.toml
└── src
    └── main.rs
    ...

Workspaces are useful when a project grows into several parts, such as a library and one or more binaries. Cargo handles builds across all crates in the workspace, which lets you organise code without adding complexity.

Chapter 2: Language Basics

This chapter introduces the core pieces of Rust syntax that you will see throughout the rest of the language. These ideas set the foundation for everything that follows. By the end of this chapter you will understand the structure of a Rust file, the rules around variables, how types behave and how Rust organises simple units of code. These basics are small but important, and learning them early keeps later chapters clear.

Keywords, items, and files

Rust programs are built from items. An item is any top level construct such as a function, struct, enum, module or constant. Items live inside files, and Rust treats each file as part of a module tree that matches the directory layout of your project. This approach keeps programs tidy and encourages clear separation of concerns.

Keywords are reserved words such as fn, struct and mod. They tell the compiler how to interpret the parts of a file. You cannot use keywords as variable names because they have fixed meaning. Rust keeps its keyword list modest so most names remain available for everyday code.

💡 If you are unsure whether a word is reserved you can try it in a simple program. The compiler will tell you if the name has a special role.

Each source file ends in .rs and contains one or more items. A project usually starts with main.rs for binary programs or lib.rs for libraries. Cargo treats these as entry points and builds the module tree from there.

Variables, mutability, and shadowing

Variables in Rust are immutable by default. This means that once a value is bound to a name it cannot change unless you explicitly mark it as mutable with mut. This rule prevents accidental modification of data and makes code easier to reason about.

let x = 5;
let mut y = 10;

Rust also supports shadowing, which allows a new variable with the same name to replace an older one in the same scope. This is useful when you want to transform a value while keeping a clean name. Shadowing does not mutate data in place; it creates a new binding.

let name = "Alice";
let name = "Bob";
println!("{}", name);  // prints Bob

The first binding creates name with one value. The second line creates a brand new name in the same scope. The earlier binding is no longer reachable after this point. Or with a small inner block:

let count = 5;
{
  let count = 8;          // shadows outer count only inside this block
  println!("{}", count);  // prints 8
}
println!("{}", count);    // prints 5

Mutability and shadowing serve different purposes. Mutability changes the value behind a name. Shadowing creates a new name with a new value. Both appear often in everyday Rust code.

Scalar and compound types

Rust groups built in types into two families. Scalar types represent simple values such as integers, floating point numbers, booleans and characters. These are basic building blocks for calculations and control flow.

Compound types combine several values into one unit. Rust provides tuples and arrays as built in compound types. Tuples can mix types and have a fixed length. Arrays hold a fixed number of values of one type. Both appear frequently when structuring small pieces of data before moving on to richer types like structs.

⚠️ Arrays in Rust have a length that is part of their type. The notation [i32; 3] means an array of three i32 values. An i32 is a 32 bit signed integer, and the number after the semicolon shows the fixed length of the array. Because the length is included in the type, [i32; 3] and [i32; 4] are treated as completely different types by the compiler.

Most real programs combine these basic types to form shapes that match the problem domain. The goal here is simply to understand the building blocks before struct types and enums arrive in later chapters.

Statements, expressions, and blocks

Rust distinguishes between statements and expressions. A statement performs an action. An expression produces a value. Many parts of Rust code are expressions, including blocks wrapped in braces. This means that a block can return a value and be used wherever an expression is expected.

let result = {
  let a = 2;
  let b = 3;
  a + b
};
⚠️ Rust treats blocks as expressions, which is different from many languages. If the last line in a block has no semicolon, that line becomes the value the block returns. Adding a semicolon turns it into a statement instead. This behaviour can feel unusual at first, but it becomes familiar once you read more Rust code.

Understanding expressions early makes control flow and function writing easier because many Rust constructs rely on this behaviour.

Comments and documentation with rustdoc

Rust supports standard line comments beginning with // and block comments enclosed in /* … */. These are suitable for everyday notes and explanations inside your code. Clear comments make intent visible and help future readers follow your logic.

Rust also includes a documentation system called rustdoc. Documentation comments begin with /// and attach to the item that follows them. When you run cargo doc your project gains a full set of generated documentation pages that include your comments, type signatures and examples.

/// Adds two numbers together.
fn add(a: i32, b: i32) -> i32 {
  a + b
}
⚠️ Rust is strict about types, so this function tells the compiler three things very clearly. First, a must be an i32 which is a 32 bit signed integer. Second, b must also be an i32. Finally, the function returns an i32. Rust does not perform automatic numeric conversions. If you need to convert a value when calling the function you must do it explicitly, for example add(5u8 as i32, 10). You will see how this works in more detail when we explore Rust's numeric types.

These documentation comments support Markdown, example blocks and links. Larger projects often rely on them heavily because they help keep code and explanation in the same place. Over time this becomes one of Rust’s strengths since well documented code remains easier to maintain.

Chapter 3: Ownership

Ownership is Rust’s core model for memory safety. Each value has a single owner; when the owner goes out of scope the value is dropped. The compiler enforces this at compile time, so you get safety without a garbage collector. This chapter explains how values move, when they copy, how borrowing works, why mutability matters to borrows, how slices fit into the picture, and how to read lifetime annotations in plain terms.

Moves and copies

When you assign or pass a value, Rust either moves it or copies it. A move transfers ownership to a new variable or parameter; a copy duplicates the bits so both variables remain valid. Types that implement the Copy trait perform a bitwise copy; most scalar types do. Heap-owning types like String and Vec<T> move by default.

⚠️ A scalar type is a single, simple value such as an i32, u8, f64, char or bool. These occupy a fixed amount of memory and have no heap allocation, which is why they can be duplicated safely with a straightforward bitwise copy.

Copy types

Numbers and other small scalars implement Copy. After assignment both bindings are usable, because the value was duplicated.

let a: i32 = 10;
let b = a;             // i32 implements Copy
println!("{a}, {b}");  // both fine
⚠️ It is often clearer to declare the type for important values, especially when teaching or reading unfamiliar code. Rust is able to infer many types on its own, and simple literals such as 10 default to i32, but explicit annotations help readers see your intent and help the compiler catch mistakes earlier.

Other common Copy types include bool, char, and tuples that contain only Copy members such as (i32, i32). If a tuple contains a non-Copy member then the tuple is not Copy.

Move semantics for heap-owned values

Types that own heap memory move, which prevents double frees and data races. After a move the previous binding becomes invalid.

let s1 = String::from("hello");
let s2 = s1;  // move: s1 is no longer valid
println!("{s2}");
⚠️ Rust moves ownership for types like String because they manage heap memory. A move prevents two variables from thinking they both own the same allocation. Other languages usually hide this detail and rely on a garbage collector, but Rust treats ownership as an explicit rule so memory is released safely and predictably when the owner goes out of scope.

Passing such a value to a function also moves it unless you borrow it. Returning a value moves ownership back to the caller unless you borrow it instead.

💡 If a type implements Clone, you can request an explicit deep copy with .clone(). This may allocate; use it when you really need a duplicate.

Destructuring and partial moves

Pattern matching can move some fields out of a struct while leaving others borrowed or moved differently. After a partial move, only the moved fields are unavailable; the rest can still be used according to the pattern.

struct User { name: String, id: u32 }

let u = User { name: String::from("Ada"), id: 7 };
let User { name, id } = u;  // name moves, id copies
println!("{name} #{id}");
// u is no longer fully usable here; its name field was moved
⚠️ The id field copies because u32 is a scalar that fits entirely on the stack, so duplicating it is trivial. The name field moves because a String owns heap memory and cannot be safely duplicated without an explicit clone. This contrast is a direct result of Rust’s rule that only Copy types duplicate automatically; everything else transfers ownership instead.

Borrowing and references

Borrowing lets you use a value without taking ownership. A reference points to a value owned by something else. You create a reference with &value for an immutable borrow or &mut value for a mutable borrow. The compiler checks that references never outlive their owners and never violate aliasing rules.

⚠️ You do not return a borrow in a manual way. Instead the compiler ends a borrow when the reference goes out of use. At that point ownership is simply back with the original variable because it never left in the first place. The reference was only a temporary permission slip, and the compiler tracks exactly how long that permission is valid. This keeps borrowing lightweight and predictable.

Immutable &T borrows

Any number of immutable borrows can coexist, which supports safe shared reads. The underlying value cannot be modified through an immutable borrow.

let s = String::from("hello");
let r1: &String = &s;
let r2 = &s;
println!("{r1} {r2}");  // shared read access

Immutable borrows are the default choice when you only need to read. They keep code simple and allow more flexible usage.

Mutable &mut T borrows

A mutable borrow grants exclusive write access for a limited region of code. There can be only one active mutable reference to a value at a time, and no immutable references may be active to that same value while the mutable borrow is alive.

let mut s = String::from("hi");
let r = &mut s;  // exclusive mutable borrow
r.push_str(" there");
println!("{r}");
⚠️ A mutable borrow and an immutable borrow to the same value cannot overlap in time; the compiler tracks these lifetimes precisely and rejects code that would allow aliasing with mutation.

Borrow scopes end at last use

Borrow scopes often end earlier than the enclosing block; they end at the last use of the reference. This can help you introduce a mutable borrow after immutable borrows in the same block.

let mut s = String::from("abc");
let r1 = &s;
println!("{r1}");  // r1 last used here
let r2 = &mut s;   // now allowed; r1's scope ended
r2.push('d');

Mutable vs immutable borrows

Choosing between &T and &mut T affects what other borrows can exist at the same time. Prefer immutable borrows for reading; upgrade to mutable borrows only for the smallest section of code that needs to change the value. This leads to clearer intent and fewer conflicts.

Aliasing rules in practice

The rules can be summarized as: many readers or one writer. This avoids data races and is enforced at compile time without runtime overhead.

let mut v = vec![1, 2, 3];

let a = &v;      // shared borrow
let b = &v;      // another shared borrow
println!("{:?} {:?}", a, b);

// a and b are no longer used after this point; now we can borrow mutably
let m = &mut v;  // exclusive mutable borrow
m.push(4);

Interior mutability

Some types allow mutation through an immutable reference by enforcing safety with runtime checks. This pattern is called interior mutability; Cell<T> and RefCell<T> are common tools. They are advanced; you will meet them later when covering smart pointers.

💡 Keep mutable borrows narrow. Create them as late as possible and drop them as soon as possible to reduce contention with other borrows.

Slices and string slices

A slice is a view into a sequence; it borrows a contiguous range of elements without owning them. Slices are written with range syntax on collections like arrays, vectors, and strings. A slice reference has the type &[T] for elements of type T or &str for string slices.

Array and vector slices &[T]

Use ranges to take slices. The resulting slice borrows the original collection, so the collection must outlive the slice.

let nums = vec![10, 20, 30, 40, 50];
let mid: &[i32] = &nums[1..4];  // 20, 30, 40
println!("{:?}", mid);

Ranges support several forms: start..end excludes the end; start..=end includes the end; ..end starts at zero; start.. continues to the end; .. means the whole range.

String slices &str

String owns UTF-8 bytes on the heap; &str is a borrowed slice of UTF-8. Because characters may be multiple bytes, slicing must align on byte boundaries for valid UTF-8. Indexing by position is not allowed; you slice by byte ranges carefully.

let s = String::from("résumé");
let first = &s[..2];  // "ré" in bytes; careful with UTF-8 boundaries
println!("{first}");
⚠️ Slicing in the middle of a multi-byte character panics at runtime. Use iterator methods like chars() when working in character units.

Slices borrow; they do not own

Because slices borrow, their lifetime is tied to the owner. You cannot return a slice to data that will be dropped; you must ensure the owner lives long enough.

fn first_two(v: &[i32]) -> &[i32] {
  &v[..2]
}

This function returns a slice borrowed from its input; the caller must keep the original slice or collection alive for as long as it uses the returned slice.

Lifetimes in plain terms

Lifetimes describe how long references are valid. The compiler uses lifetime analysis to guarantee that no reference outlives the data it points to. In many cases lifetimes are inferred; when they cannot be inferred you add annotations that connect inputs to outputs.

Reading lifetime annotations like <'a>

When you see <'a> on a function or type, read it as: there is a named lifetime called 'a. An annotation like -> &'a str means the returned reference is valid for at most the lifetime 'a. An input such as x: &'a str means x must live at least as long as 'a. Annotations connect these together so the compiler knows which inputs the output depends on.

fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
  if x.len() >= y.len() { x } else { y }
}

Here, the returned &str is guaranteed not to outlive either input; it lives no longer than the shorter of their lifetimes. The annotation does not change lifetimes; it documents and constrains their relationship.

Borrowed returns vs owned returns

If a function creates new data inside itself, it should usually return an owned value so the caller can use it freely. Returning a borrowed reference to data created inside the function would reference data that ends when the function returns; that would be invalid.

fn make_string() -> String {
  String::from("made here")
}

Owned returns transfer ownership to the caller; borrowed returns tie the result to an input parameter’s lifetime. Choose based on whether the result depends on caller data or on newly created data.

Structs with references

A struct that holds a reference must declare a lifetime parameter and annotate reference fields so the compiler can verify that any instance does not outlive the data it borrows.

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

fn highlight<'a>(text: &'a str) -> Excerpt<'a> {
  Excerpt { text }
}

The instance of Excerpt<'a> cannot outlive the string slice it references. The annotations tell the compiler exactly that relationship.

💡 When you are unsure about lifetimes, try to return owned data or restructure code so borrows do not need to escape their immediate scope; this often simplifies the design.

Throughout the rest of the book you will see these ownership rules at work. Whenever you encounter braces with omitted details such as { … }, remember that owners, borrows, and lifetimes govern when values are valid and who may access them at that moment.

Chapter 4: Control Flow and Pattern Matching

Control flow in Rust covers the familiar tools such as if, loops and iteration. Rust also includes structural pattern matching, which lets you describe shapes of data and act on them cleanly. This chapter brings these ideas together so your programs can make choices, repeat work, open up complex values and react to success or failure with clarity.

Using if, loop, while, and for

Rust’s branching and looping tools are small and direct. The if expression chooses between paths; loop repeats forever until you break; while repeats while a condition is true; for walks through an iterator. Because each construct is an expression, you can capture values produced inside them when it suits the design.

Basic if expressions

An if expression evaluates a condition and picks one branch. Each branch must yield compatible types if you use the expression’s value.

let n = 7;
let parity = if n % 2 == 0 { "even" } else { "odd" };
println!("{parity}");

This example returns a string slice from either branch. You can extend the chain with else if when more conditions are needed.

Using loop for open-ended repetition

loop is a simple infinite loop. You end it with break, optionally returning a value from the break expression.

let mut count = 0;
let final = loop {
  count += 1;
  if count == 3 {
    break count * 2;
  }
};
println!("{final}");

The value after break becomes the loop’s result. This makes loop useful when you want a clear exit point that produces something.

while loops

while evaluates its condition first. If it is true the body runs; if not the loop ends. This is helpful when reading from input or slicing through data until a condition no longer holds.

let mut n = 5;
while n > 0 {
  println!("{n}");
  n -= 1;
}

Because the condition is rechecked each time, this loop adapts to changing values naturally.

for loops over iterators

for works with any type that becomes an iterator. It retrieves each item in turn and binds it to a pattern of your choice.

for x in 1..4 {
  println!("{x}");  // prints 1, 2, 3
}

Ranges create simple iterators. Many container types also implement iteration so you can walk through their items directly.

💡 for loops automatically borrow collections, so you do not consume them unless you iterate by value.

match fundamentals

The match expression compares a value against a set of patterns and runs the code for the first matching branch. Patterns can test literal values, ranges, enum variants, tuple shapes and more. Every match must be exhaustive; you must cover all possible shapes or include a final wildcard pattern.

Literal and range matches

You can match single values or ranges inside a branch. This works well for selective branching without nested if constructs.

let score = 8;

let label = match score {
  0 => "none",
  1..=5 => "low",
  6..=10 => "high",
  _ => "unknown"
};

println!("{label}");

Here the wildcard branch _ ensures the match is complete.

Matching enum variants

Enums are a natural fit for match because each variant becomes a distinct pattern. This produces clean control flow when you handle structured data.

enum Direction { North, South, East, West }

let d = Direction::East;

match d {
  Direction::North => println!("up"),
  Direction::South => println!("down"),
  Direction::East => println!("right"),
  Direction::West => println!("left")
}

Each branch handles one variant and nothing else.

Destructuring patterns

Patterns can open complex structures so you can work with the inner pieces directly. This technique is called destructuring and it works with tuples, structs, arrays and enum variants that hold data.

Destructuring tuples

Tuples break apart naturally inside patterns. You can bind each element to a name or ignore parts you do not need.

let point = (3, 4);

let (x, y) = point;
println!("{x}, {y}");

You can also use _ to mark unused fields without introducing a binding:

let triple = (10, 20, 30);

// We only care about the first value.
// The middle value is named, and the last value is ignored.
let (first, middle, _) = triple;

println!("{first} and {middle}");

Destructuring struct fields

Struct patterns allow you to list the fields you care about. Any field you omit is ignored.

struct User { name: String, id: u32 }

let u = User { name: "Ada".into(), id: 7 };

let User { name, id } = u;
println!("{name} #{id}");

You may also rename fields inside the pattern if needed, using the form field: new_name.

⚠️ Destructuring follows ownership rules. Fields that are Copy duplicate automatically; other fields move unless you borrow them.

Destructuring enum variants

Enum variants that hold data unpack cleanly inside a match. This lets you bind the contained values directly.

enum Event { Click(x: i32, y: i32), Quit }

let e = Event::Click(10, 20);

match e {
  Event::Click(x, y) => println!("clicked at {x}, {y}"),
  Event::Quit => println!("goodbye")
}

This approach keeps branching logic tidy even when values become more complex.

Using if let and while let

if let and while let are shortcuts for matching a single pattern and ignoring everything else. They shine when you want to unwrap one shape of data and skip the rest without writing a full match expression.

Using if let

if let runs its body only when the pattern matches. This is compact when you expect one case most of the time.

let v = Some(10);

if let Some(n) = v {
  println!("{n}");
}

Here the inner n is bound only if the option contains a value.

Using while let

while let repeats as long as the pattern keeps matching. It is often used to pull values from iterators or stateful types.

let mut v = vec![1, 2, 3];

while let Some(x) = v.pop() {
  println!("{x}");
}

This continues until pop() returns None.

💡 if let and while let reduce nesting. Use them when a single pattern matters and everything else can be ignored.

Option and Result in practice

Option and Result encode presence or absence of values, and success or failure of operations. They make control flow explicit by including every possibility in the type system. Pattern matching is the natural way to handle them, and many methods on these types help with common tasks.

Working with Option<T>

Option has two variants: Some(T) for a present value and None for no value. Match on them to act on each case.

let maybe = Some("Rust");

match maybe {
  Some(name) => println!("{name}"),
  None => println!("nothing here")
}

Methods like unwrap_or, map and and_then help you chain operations without branching by hand.

Working with Result<T, E>

Result expresses either success Ok(T) or failure Err(E). This pattern avoids hidden exceptions and encourages explicit handling.

fn parse(s: &str) -> Result {
  s.parse().map_err(|_| "not a number")
}

match parse("42") {
  Ok(n) => println!("got {n}"),
  Err(e) => println!("{e}")
}

The ? operator propagates errors automatically by returning early when an Err appears.

⚠️ Option expresses absence; Result expresses failure. Choosing the right one keeps intent clear for readers and tools.

Chapter 5: Strings and Collections

Rust offers several tools for storing and organizing data. Some focus on text, some on ordered sequences, and others on fast lookup through hashing or sorted keys. Understanding how these pieces fit together helps you choose the right collection for each task and use them with confidence.

String vs str

Rust has two closely related text types. String owns a growable buffer of UTF-8 bytes on the heap; it can change size, push content and take ownership of text. str describes a UTF-8 slice that someone else owns. You usually see it as &str, which is a borrowed view into existing text. This split keeps memory handling clear and avoids hidden allocations.

Where String fits

String is your go-to type when you need to build or modify text. It can expand, shrink and hold any valid UTF-8 sequence.

let mut s = String::from("Rust");
s.push_str("ace");
println!("{s}");

This example adds more text to the owned buffer. Because String owns its memory, it can be moved, borrowed or cloned when needed.

Where &str fits

&str is a borrowed slice of text. It does not own memory and cannot grow. Most string literals behave as &str, and many functions prefer &str when they only need to read text.

let msg: &str = "hello";  // a borrowed string slice
println!("{msg}");

Because &str borrows its data, the owner must live long enough to keep the slice valid.

⚠️ Choose String when you need ownership or mutation; choose &str when you only need to read.

Vec, arrays, and slices

Rust’s fundamental sequence types come in three forms. Arrays have fixed size known at compile time; vectors grow at runtime and own their memory; slices are borrowed views into arrays or vectors. These types cover most tasks that involve ordered data.

Arrays

An array has a fixed length and a single element type. Its size is part of its type, which makes arrays predictable and fast.

let a: [i32; 3] = [10, 20, 30];
println!("{:?}", a);

Arrays live on the stack when small and are ideal for fixed structures such as coordinates or short static tables.

Vectors Vec<T>

Vec<T> is a growable list. It owns heap memory and can push, pop and resize itself freely.

let mut v = Vec::new();
v.push(1);
v.push(2);
println!("{:?}", v);

Vectors are the most common sequence type in Rust because they adapt easily to changing workloads.

Slices &[T]

A slice borrows a section of an array or vector. It does not own memory and cannot change size, but it gives safe read access to ranges of elements.

let nums = vec![5, 6, 7, 8];
let part: &[i32] = &nums[1..3];
println!("{:?}", part);

Slices are lightweight views; their lifetime is tied to the underlying collection.

💡 You can convert arrays and vectors to slices easily. Borrowing with &v yields a slice of the entire vector.

HashMap and BTreeMap

Rust provides two main dictionary types: HashMap for fast average lookups, and BTreeMap for sorted keys and predictable ordering. Both store key-value pairs and let you query, insert and update entries efficiently.

Using HashMap

HashMap stores keys based on hashes and gives near constant-time access for inserts and lookups.

use std::collections::HashMap;

let mut map = HashMap::new();
map.insert("a", 1);
map.insert("b", 2);

println!("{:?}", map.get("a"));

Hash-based maps are ideal when ordering does not matter and speed does.

Using BTreeMap

BTreeMap keeps keys in sorted order, which supports ordered iteration and range queries.

use std::collections::BTreeMap;

let mut map = BTreeMap::new();
map.insert(30, "thirty");
map.insert(10, "ten");

for (k, v) in &map {
  println!("{k}: {v}");
}

Sorted maps are helpful when you need predictable iteration or want to work with key ranges.

⚠️ HashMap is usually faster; BTreeMap stays ordered. Pick based on whether ordering or raw speed matters more.

Sets and other standard types

Sets store unique values without duplicates. Rust includes HashSet and BTreeSet, which mirror the behaviour of HashMap and BTreeMap. The standard library also offers queues, binary heaps and other specialised collections.

Using HashSet

HashSet provides quick membership tests and enforces uniqueness.

use std::collections::HashSet;

let mut set = HashSet::new();
set.insert("apple");
set.insert("banana");
set.insert("apple");  // ignored, already present

println!("{:?}", set);

This is useful for filtering duplicates or checking if something is included.

Binary heaps

BinaryHeap stores items in priority order. You always get the largest element when popping.

use std::collections::BinaryHeap;

let mut heap = BinaryHeap::new();
heap.push(10);
heap.push(4);
heap.push(7);

println!("{:?}", heap.pop());

Binary heaps are great for scheduling, priority tasks or algorithms that need a top element repeatedly.

Iteration patterns over collections

Rust’s collections expose iterators that let you traverse items by value, by reference or by mutable reference. Iterators are lazy; they produce values as needed and chain together smoothly.

Iterating by reference

This is the common pattern when you only need to read items.

let nums = vec![1, 2, 3];

for n in &nums {
  println!("{n}");
}

The vector is borrowed, so it remains usable after the loop.

Iterating mutably

Use &mut when you need to adjust each element in place.

let mut nums = vec![1, 2, 3];

for n in &mut nums {
  *n *= 2;
}

println!("{:?}", nums);

This pattern preserves ownership and modifies items safely.

Consuming iteration

Iterating by value takes ownership of items and consumes the collection.

let nums = vec![1, 2, 3];

for n in nums {
  println!("{n}");
}

The vector cannot be used afterward because all its elements have been moved.

💡 Many iterator adaptors such as map, filter and collect work across all these patterns and help you build clear pipelines of data transformations.

Chapter 6: Functions, Modules, and Crates

Functions organise behaviour, modules organise code, and crates organise entire libraries or programs. Rust encourages clean boundaries between these layers so projects stay readable and easy to maintain. This chapter walks through each layer in turn so you can understand how Rust structures real software.

Functions and return values

Functions group work into reusable pieces. They take inputs, perform actions and return values. Rust functions are explicit about what they use and what they produce, which makes their behaviour predictable.

Defining basic functions

A function declaration lists the name, parameters and return type. The last expression becomes the return value unless you include an explicit return.

fn add(a: i32, b: i32) -> i32 {
  a + b
}

println!("{}", add(3, 4));

Type annotations for parameters and return types are required so the compiler knows exactly how the function is meant to behave.

Returning multiple values with tuples

Rust functions can return a single value, but that value may be a tuple if you want to group several results.

fn split(n: i32) -> (i32, i32) {
  (n / 2, n % 2)
}

let (q, r) = split(9);
println!("{q}, {r}");

This pattern avoids creating temporary structs when you only need a simple bundle of data.

Early returns

You can end a function early with return. This may help with error handling or special cases.

fn safe_div(a: i32, b: i32) -> Option {
  if b == 0 {
    return None;
  }
  Some(a / b)
}

The function either returns a division result or indicates absence with None.

💡 The last expression without a semicolon becomes the return value. Use explicit return only when it makes the flow clearer.

Visibility and modules

Modules group related items and control what is visible from the outside. By default everything in a module is private to that module unless you mark it pub. This helps you hide internal details while exposing a clean public surface.

Declaring modules

You declare a module with mod. Its contents may sit inline or in another file that shares the module’s name.

mod util {
  pub fn shout(msg: &str) {
    println!("{msg}!");
  }
}

util::shout("hi");

Inside a module you control which items become part of its public API by adding pub.

Nested modules

Modules may contain submodules. This allows deep organisation when a project grows.

mod net {
  pub mod http {
    pub fn get() {
      println!("fetching");
    }
  }
}

net::http::get();

The module tree mirrors your directory structure when modules are split across files.

⚠️ Privacy follows the module tree. A pub item is visible to the outside world only if every module above it is also public in the path being used.

Crates and packages

A crate is the unit of compilation in Rust. It may be a library or a binary program. A package is a folder that contains one or more crates along with a Cargo.toml file. Most packages contain a single crate, but the system allows more when needed.

Library and binary crates

A library crate exposes an API and has no main function. A binary crate defines fn main() and produces an executable.

// src/lib.rs
pub fn greet() {
  println!("hi");
}

// src/main.rs
use mycrate::greet;

fn main() {
  greet();
}

This separation helps you share functionality between projects or publish reusable components.

The role of Cargo.toml

Cargo.toml describes the package: its name, version, dependencies and crate type. Cargo uses this file to build, test and distribute your project.

[package]
name = "demo"
version = "0.1.0"

[dependencies]
rand = "0.8"

Changes in Cargo.toml guide the build system and affect how your crate is compiled.

Use paths and re-exporting

The use keyword shortens long paths to keep code readable. Re-exporting with pub use lets your crate present a simpler external interface by exposing internal items under new paths.

Using paths

You bring names into scope with use. This avoids repeating full module paths.

use std::collections::HashMap;

let mut map = HashMap::new();
map.insert("x", 1);

This keeps code concise and keeps the focus on logic instead of paths.

Re-exporting with pub use

pub use re-exports an internal item as part of your public API. This helps you reorganise internal modules while presenting a clean surface.

mod math {
  pub fn square(n: i32) -> i32 { n * n }
}

pub use math::square;

println!("{}", square(5));

External users of your crate can now call square directly without knowing the module layout.

💡 Re-export only the items that represent your intended public design. Hide helpers and leave them private for clarity.

Cargo features and profiles

Cargo offers tools to customise how your crate is built. Features switch optional parts of your code on or off. Profiles control optimisation, debug info and general build behaviour.

Defining features

Features are declared in Cargo.toml. They let users opt in to additional capabilities without forcing them on everyone.

[features]
serde = ["serde_crate"]
fast = []

Code that depends on a feature usually sits behind #[cfg(feature = "name")] attributes.

Build profiles

Cargo includes profiles such as dev, release and test. Each profile changes optimisation levels, debug data and build speed.

[profile.release]
opt-level = 3
debug = false

Release builds focus on runtime performance; dev builds focus on quick iteration with helpful debug information.

⚠️ Profiles guide compilation behaviour only; they do not change the Rust code itself. Use them to tune for development or production needs.

Chapter 7: Error Handling

Rust treats errors as a core part of program design. Some failures should immediately stop execution because continuing would be unsafe or meaningless. Others can be anticipated and handled in code so that the program can continue gracefully. In this chapter you will learn the difference between panicking and recoverable errors, how to use Result and the ? operator, how crates such as thiserror and anyhow improve ergonomics, how to design good error types, and how to record context using tracing.

Panic vs recoverable errors

A panic stops the current thread immediately and unwinds the stack. It is for conditions that should never happen in production or that make it impossible to proceed safely. Recoverable errors are expected possibilities such as missing files or network timeouts; you represent these cases with Result<T, E> and decide what to do.

panic! for unrecoverable conditions

Use panic! if the program has entered an invalid state and continuing would be incorrect. Typical examples include violated invariants or corrupted data structures.

fn get_slice_prefix(data: &[u8], n: usize) -> &[u8] {
  if n > data.len() {
    panic!("prefix length {} exceeds slice length {}", n, data.len());
  }
  &data[..n]
}

In release builds you should reserve panic for truly exceptional conditions. Prefer assertions during development, and remove or gate them behind configuration when appropriate.

💡 By default Rust unwinds the stack on panic and runs destructors; you can switch to aborting at compile time for smaller binaries with [profile.release] panic = "abort".

Recoverable errors with Result

Recoverable errors are encoded in the type system. A function that can fail returns Result<T, E> where E implements std::error::Error or another error type you define.

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

fn read_file(path: &str) -> Result<String, io::Error> {
  let mut s = String::new();
  let mut f = File::open(path)?;
  f.read_to_string(&mut s)?;
  Ok(s)
}

A caller can then decide how to react. For example it might retry, log and continue, ask the user for a different path, or escalate the error to a higher level.

When to choose panic vs Result

Here is a quick comparison to guide your choice. If in doubt, return Result so that callers can decide policy.

Situation Use Reason
Invalid internal state that violates invariants panic! Program logic is wrong; recovery would hide a bug
User input, file system, network, environment Result External conditions are expected to fail sometimes
Prototype or short CLI where simplicity is fine expect/unwrap Ergonomic shorthand; include a clear message with expect
Library APIs used by others Result Do not crash callers; surface errors as values

Result and the ? operator

Result<T, E> carries either a success value T or an error E. The ? operator propagates an error to the caller if the value is Err; otherwise it unwraps the Ok value. This keeps code concise while preserving explicit error flow.

Basic propagation with ?

Any function that uses ? must return a compatible Result or implement From<E> for its error type so conversions can happen automatically.

use std::fs;
use std::io;

fn size_in_bytes(path: &str) -> Result<u64, io::Error> {
  let meta = fs::metadata(path)?;
  Ok(meta.len())
}

If fs::metadata fails, the io::Error is returned to the caller immediately. Otherwise execution continues and the function returns Ok with the size.

Mapping and adding context

Sometimes you want to transform or annotate the error. Use map_err to convert types, and use crates such as anyhow or eyre for convenient context.

#[derive(Debug)]
struct ConfigError(String);

fn load_config(path: &str) -> Result<String, ConfigError> {
  std::fs::read_to_string(path).map_err(|e| ConfigError(format!("read failed: {}", e)))
}

You can also attach context strings using anyhow::Context which you will see shortly.

⚠️ Avoid unwrap in libraries. Prefer expect with a helpful message in small binaries if you choose to crash rather than attempting recovery.

thiserror and anyhow for ergonomics

Manually implementing error traits can be verbose. The thiserror crate derives boilerplate for your error enums. The anyhow crate provides a flexible anyhow::Error type for application code, plus easy context. Use thiserror in libraries to define precise error types; use anyhow in binaries where one catch-all type simplifies plumbing.

Defining library errors with thiserror

With thiserror you describe variants and their display messages declaratively. Each variant can wrap a source error which participates in error chains.

use thiserror::Error;
use std::{io, num::ParseIntError};

#[derive(Debug, Error)]
pub enum AppError {
  #[error("i/o failed: {0}")]
  Io(#[from] io::Error),

  #[error("invalid port: {0}")]
  ParsePort(#[from] ParseIntError),

  #[error("config missing key: {key}")]
  MissingKey { key: String },
}

The #[from] attribute adds automatic conversions so that ? works across APIs that return those error types.

Application errors with anyhow

anyhow is ideal for top-level binaries. It erases the concrete type and captures a backtrace when enabled, while preserving sources and context strings.

use anyhow::{Context, Result};

fn run(path: &str) -> Result<()> {
  let data = std::fs::read_to_string(path)
    .with_context(|| format!("while reading {}", path))?;
  let port: u16 = std::env::var("PORT")
    .context("PORT not set")?
    .parse()
    .context("PORT must be a number")?;
  println!("config loaded, port {}", port);
  Ok(())
}

At main you can return anyhow::Result<()> to let anyhow render a friendly message.

fn main() -> anyhow::Result<()> {
  run("config.toml")
}
💡 Enable backtraces with RUST_BACKTRACE=1. anyhow will include them when the underlying error supports it.

Designing error types

Good error design makes your APIs pleasant to use and your diagnostics useful. Prefer enums for discrete failure modes, provide conversions from lower-level errors, and include structured data so callers can act programmatically.

Use enums with meaningful variants

Enumerate the ways an operation can fail. Each variant can carry data that helps a caller decide the next step.

#[derive(Debug, thiserror::Error)]
pub enum DbError {
  #[error("connection failed: {0}")]
  Connect(#[from] std::io::Error),

  #[error("not found: {entity} {id}")]
  NotFound { entity: &'static str, id: String },

  #[error("constraint violated: {name}")]
  Constraint { name: String },
}

This communicates intent more clearly than a single string message and avoids fragile string matching.

Preserve sources and context

Errors often originate deep in the stack. Preserve the original error with a source so that debuggers and logs can display the full chain.

use thiserror::Error;

#[derive(Debug, Error)]
#[error("load user failed")]
pub struct LoadUserError {
  #[from]
  source: reqwest::Error,
}

If you are not using thiserror, implement std::error::Error::source manually and add a Display implementation that is concise and actionable.

Choose error boundaries

Let libraries expose specific error types; convert to application-level types at the boundary. Do not leak transient details such as SQL strings or file paths unless they help the user fix the problem.

⚠️ Avoid stringly typed errors that only carry text. Without structured data you will end up parsing messages which is brittle and locale dependent.

Logging with tracing

Logging and structured diagnostics complement error handling. The tracing ecosystem provides spans and events that attach context to logs. You can record fields, track execution flow, and correlate errors with spans for rich reports.

Set up a subscriber

A subscriber collects and formats events. For quick starts use tracing-subscriber with an environment filter so you can adjust verbosity without recompiling.

use tracing::{error, info, instrument};
use tracing_subscriber::{fmt, EnvFilter};

fn init_tracing() {
  tracing_subscriber::registry()
    .with(fmt::layer())
    .with(EnvFilter::from_default_env())
    .init();
}

Set RUST_LOG=info or similar to control levels at runtime. Levels include error, warn, info, debug, and trace.

Spans, events, and errors

Use #[instrument] to create spans automatically and record function arguments. Emit events at decision points, and include the error chain when failures occur.

#[instrument(skip(path))]
fn process(path: &str) -> anyhow::Result<()> {
  let data = std::fs::read_to_string(path)
    .with_context(|| format!("reading {}", path))?;
  info!(bytes = data.len(), "loaded file");
  if data.is_empty() {
    anyhow::bail!("file is empty");
  }
  Ok(())
}

fn main() -> anyhow::Result<()> {
  init_tracing();
  if let Err(e) = process("data.txt") {
    error!(error = %e, "processing failed");
  }
  Ok(())
}

Many libraries emit tracing events so your application gains visibility across dependencies. Pair this with error chains to get actionable reports.

Chapter 8: Traits, Generics, and Lifetimes

Traits define shared behavior. Generics let code work for many types while staying checked at compile time. Lifetimes describe how long references remain valid. Together these features enable reusable, safe abstractions.

Defining and implementing traits

A trait is a collection of required methods and optional default methods. Implement a trait for a type to make it satisfy that behavior. Traits can also be used as bounds for generics.

Declaring a trait

Start with a trait block. Provide method signatures. You can include default bodies that implementors may override.

pub trait Describe {
  fn name(&self) -> &str;
  fn describe(&self) -> String {
    format!("This is {}", self.name())
  }
}

Implementing a trait

Use impl Trait for Type. Implement all required methods, optionally override defaults.

struct User { id: u64, handle: String }

impl Describe for User {
  fn name(&self) -> &str { &self.handle }
  fn describe(&self) -> String {
    format!("user#{} ({})", self.id, self.name())
  }
}
⚠️ You can implement a trait for a type if either the trait or the type is local to your crate. This is the orphan rule which prevents conflicting implementations across crates.

Generic functions and types

Generics abstract over types while monomorphization generates efficient code for each concrete instantiation. Use type parameters inside angle brackets and refer to them in signatures and bodies.

Generic functions

Introduce type parameters with <T, U, …>. Add bounds to require behavior which will appear later in this chapter.

fn take_two<T>(a: T, b: T) -> (T, T) {
  (a, b)
}

Generic structs and enums

Define type parameters on the type and optionally on impl blocks.

struct Boxed<T>(T);

impl<T> Boxed<T> {
  fn new(value: T) -> Self { Boxed(value) }
  fn into_inner(self) -> T { self.0 }
}
💡 Use turbofish syntax ::<T> when inference cannot determine type parameters. Example: "42".parse::<i32>().

Trait bounds and where clauses

Bounds specify the required behavior for type parameters. Put simple bounds inline or move complex ones to a where clause for readability.

Inline bounds

Attach bounds directly to type parameters. The compiler then ensures only types implementing those traits can be used.

fn min<T: Ord>(a: T, b: T) -> T {
  if a <= b { a } else { b }
}

Readable where clauses

Move longer bounds into a dedicated clause. This helps when there are several parameters or multiple trait requirements.

use std::fmt::Display;

fn show_pair<A, B>(a: A, b: B)
where
  A: Display + Clone,
  B: Display,
{
  println!("a={}, b={}", a, b);
}
⚠️ Trait objects such as &dyn Display use dynamic dispatch. Generic functions with T: Display use static dispatch via monomorphization. Choose based on performance and object-safety needs.

Associated types vs generics

Associated types tie a placeholder type to a trait implementation. Generics parameterize the trait itself. Choose associated types when there is a single natural type per implementor. Choose generics when callers should select the type.

Using associated types

Define a placeholder within the trait, then specify it in each impl. Callers do not specify it at use sites.

trait IntoIter {
  type Item;

  fn into_iter(self) -> Box<dyn Iterator<Item = Self::Item>>;
}

impl IntoIter for String {
  type Item = char;

  fn into_iter(self) -> Box<dyn Iterator<Item = char>> {
    Box::new(self.chars())
  }
}

Using generics on traits

Parameterize the trait so callers pick the type argument. This is flexible when one implementor supports many output types.

trait Converter<T> {
  fn convert(&self, input: &str) -> Option<T>;
}

struct Parsers;

impl Converter<i32> for Parsers {
  fn convert(&self, input: &str) -> Option<i32> {
    input.parse().ok()
  }
}
💡 If the output type is intrinsic to the implementor, prefer an associated type. If users should choose, prefer a generic parameter.

Lifetime parameters and elision rules

Lifetimes describe the scope in which references remain valid. The compiler usually infers them. You only name lifetimes when inference needs help or when exposing relationships in public APIs.

Naming lifetimes

Use a tick prefix like 'a. Bind parameters to show that one reference cannot outlive another.

fn first<'a>(s: &'a str) -> &'a str {
  &s[..1]
}

Elision rules and common patterns

Rust applies rules that remove the need to write lifetimes in many cases. For example, a method with &self often elides the output lifetime to match self. For functions with multiple input references and one output reference, you may need to state relationships explicitly.

// Elision: &self implies output borrows from self
impl User {
  fn handle(&self) -> &str { &self.handle }
}

// Explicit: choose which input the output depends on
fn longest<'a>(a: &'a str, b: &'a str) -> &'a str {
  if a.len() >= b.len() { a } else { b }
}
⚠️ Lifetimes never change runtime behavior. They are a static contract checked by the compiler which prevents dangling references while generating zero-cost code.

Chapter 9: Closures and Iterators

Closures and iterators work together to help you express data processing as clear pipelines. Closures are anonymous functions that can capture values from their environment. Iterators produce items one by one and only do work when you ask for the next value. Combined, they let you write compact code that reads like a description of the task while staying efficient.

Closure capture modes

Rust classifies a closure by how it captures variables from the environment. The compiler chooses the least intrusive mode that still works for the closure body. The three traits are Fn (shared reference), FnMut (unique mutable reference), and FnOnce (by value). You can also force capture with the move keyword when you need ownership to transfer into the closure.

Shared capture with Fn

When a closure only reads from captured variables, the compiler uses Fn. This means the closure takes shared references to its environment and can be called many times in parallel contexts that accept Fn.

fn main() {
  let threshold = 10;
  let is_big = |x: &i32| *x > threshold;  // captures by shared reference (Fn)
  assert!(is_big(&12));
  assert!(!is_big(&5));
}

Mutable capture with FnMut

If a closure needs to mutate captured state, it becomes FnMut. You must call it with mut and only one caller may hold it at a time while it mutates its environment.

fn main() {
  let mut count = 0;
  let mut bump = || { count += 1; count };  // captures by &mut (FnMut)
  assert_eq!(bump(), 1);
  assert_eq!(bump(), 2);
  // count is now 2
}
💡 If a closure both reads and writes captured data, it still implements only FnMut, which is sufficient for most iterator adapters that change state such as scan or map when they mutate an external counter.

By-value capture with FnOnce and move

When a closure takes ownership of a captured value, it implements at least FnOnce. A FnOnce closure can be called only one time because captured ownership may be consumed. Use move to require by-value capture.

fn make_printer() -> impl FnOnce() {
  let s = String::from("owned");
  move || println!("{s}") // takes ownership of s (FnOnce)
}

fn main() {
  let print_once = make_printer();
  print_once();
}
⚠️ A closure can implement multiple traits. If it only reads, it implements Fn (and also FnMut and FnOnce). If it mutates, it implements FnMut (and FnOnce). If it moves out of captured variables, it implements only FnOnce.

Iterator adapters

The Iterator trait models a sequence that yields items on demand. Adapters create new iterators from existing ones without consuming all the data up front. You compose adapters to express a pipeline, then finish with a consumer such as collect, for, or fold.

Mapping and filtering

Use map to transform each item and filter to keep only items that satisfy a predicate. Both are lazy, so nothing happens until a consumer pulls values.

fn main() {
  let nums = vec![1, 2, 3, 4, 5];
  let evens_squared = nums
    .iter()
    .copied()
    .filter(|n| n % 2 == 0)
    .map(|n| n * n)
    .collect::<Vec<i32>>();
  assert_eq!(evens_squared, vec![4, 16]);
}

Taking, skipping, and enumerating

take limits the number of items, skip ignores a prefix, and enumerate pairs items with their index. These are useful for paging and positional logic.

fn main() {
  let slice = (100..).skip(3).take(4).collect::<Vec<i32>>();
  assert_eq!(slice, vec![103, 104, 105, 106]);

  let labels = ["a", "b", "c"];
  for (i, s) in labels.iter().enumerate() {
    println!("{i}: {s}");
  }
}

Folding and reducing

fold accumulates a result by visiting each item with a closure that updates state. It is the general form of many aggregations.

fn main() {
  let product = [2, 3, 4].iter().copied().fold(1, |acc, n| acc * n);
  assert_eq!(product, 24);
}
💡 Many adapters have fallible counterparts, such as try_fold and try_for_each, which short-circuit on Err. These integrate cleanly with Result based workflows.

IntoIterator and FromIterator

IntoIterator describes types that can produce an iterator. The for loop uses this trait behind the scenes. FromIterator builds a collection from an iterator. Together they connect collections and pipelines.

IntoIterator for references and ownership

Collections often implement IntoIterator in three ways: by value (consumes and yields owned items), by shared reference (yields references), and by mutable reference (yields mutable references). Choose the form that suits your needs.

fn main() {
  let v = vec![String::from("a"), String::from("b")];

  for s in &v {              // &Vec<String> - yields &String
    println!("{s}");
  }

  for s in &mut v.clone() {  // &mut Vec<String> - yields &mut String
    s.push_str("!");
  }

  for s in v.clone() {       // Vec<String> - yields String, consumes v
    println!("{s}");
  }
}

Collecting with FromIterator

collect uses FromIterator to build a target type. Use turbofish or an annotation to select the destination. Many standard collections implement FromIterator, including Vec, String, HashSet, and HashMap.

use std::collections::HashMap;

fn main() {
  let pairs = vec![("a", 1), ("b", 2)];
  let map = pairs.into_iter().collect::<HashMap<_, _>>();
  assert_eq!(map.get("b"), Some(&2));
}
⚠️ When type inference cannot guess the target of collect, guide it with a concrete type. For example, add : Vec<i32> to a binding or use turbofish on the method call.

Building custom iterators

You can define your own iterator by creating a stateful struct and implementing Iterator for it. The only required method is next, which returns Option<Item>. Each call to next advances the state.

Implementing Iterator for a counter

This example yields square numbers up to a limit. The struct holds the current value and the limit, and next updates the state before returning the next item.

struct Squares {
  cur: u32,
  max: u32,
}

impl Squares {
  fn up_to(max: u32) -> Self {
    Self { cur: 0, max }
  }
}

impl Iterator for Squares {
  type Item = u32;

  fn next(&mut self) -> Option<Self::Item> {
    if self.cur > self.max {
      return None;
    }
    let n = self.cur;
    self.cur += 1;
    Some(n * n)
  }
}

fn main() {
  let out = Squares::up_to(5).skip(2).take(3).collect::<Vec<u32>>();
  assert_eq!(out, vec![4, 9, 16]);
}

Providing IntoIterator for your type

To support for loops over your collection type, implement IntoIterator. Decide whether iteration should consume the collection or yield references.

struct Bag<T> {
  items: Vec<T>,
  // … other fields
}

impl<T> IntoIterator for Bag<T> {
  type Item = T;
  type IntoIter = std::vec::IntoIter<T>;

  fn into_iter(self) -> Self::IntoIter {
    self.items.into_iter()
  }
}
💡 You can also implement IntoIterator for &Bag<T> and &mut Bag<T> to yield references without consuming the bag. Mirror how Vec<T> does it for a familiar user experience.

Lazy pipelines in real programs

Iterator chains are lazy. Nothing executes until a consumer pulls results. This helps with large inputs, streaming I/O, and early exit when you only need a few items. The following example scans a file for the first matching record and stops as soon as it finds one.

Processing text incrementally

The std::io::BufRead API exposes lines, which returns an iterator of Result<String, io::Error>. Combine adapters with find_map to stop at the first interesting item.

use std::fs::File;
use std::io::{self, BufRead, BufReader};

fn first_id(path: &str) -> io::Result<u64> {
  let file = File::open(path)?;
  let reader = BufReader::new(file);

  reader
    .lines()
    .filter_map(|res| res.ok())
    .find_map(|line| {
      line.strip_prefix("id=")
        .and_then(|s| s.parse::<u64>().ok())
    })
    .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "no id found"))
}

fn main() -> io::Result<()> {
  println!("{}", first_id("data.txt")?);
  Ok(())
}

Composing fallible steps with Iterator

When each step can fail, use filter_map, map_while, or try_fold. This keeps the pipeline linear and avoids nested loops and conditionals.

use std::num::ParseIntError;

fn sum_first_k(nums: &[&str], k: usize) -> Result<i64, ParseIntError> {
  nums.iter()
    .map(|s| s.parse::<i64>())
    .take(k)
    .try_fold(0_i64, |acc, n| n.map(|x| acc + x))
}
⚠️ Avoid collecting into intermediate Vec values unless you truly need random access. Staying in iterator form lets Rust fuse adapters and minimize allocations.

When to choose a loop instead

Prefer an explicit for loop when the logic needs complex early returns, multiple mutation points, or cross-item state that does not map cleanly to adapters. Both styles compile to efficient code, so choose the one that reads best.

Chapter 10: Smart Pointers and Memory

Rust provides smart pointer types that encode ownership and borrowing rules in their APIs. These types help you place data on the heap, share ownership across parts of your program, mutate through shared references when you must, manage destruction of resources, and handle special layouts that pin addresses. This chapter covers Box, Rc and Arc, Cell and RefCell, the Drop trait, and Pin.

Box for heap allocation

Box<T> stores a value on the heap and gives ownership of a pointer to that value. Moving the Box moves the pointer value; the allocation remains in place. Use a box when you need a known size for a recursive type, when you want to transfer ownership cheaply, or when you need to allocate a large object off the stack.

Recursive enums with Box

Directly embedding a variant that contains itself would make the type size infinite; a box breaks the cycle by storing the child on the heap.

enum List {
  Nil,
  Cons(i32, Box<List>),
}

fn len(mut l: &List) -> usize {
  let mut n = 0;
  loop {
    match l {
      List::Nil => break,
      List::Cons(_, next) => { n += 1; l = next; }
    }
  }
  n
}
💡 Box gives unique ownership. If you need shared ownership, pick Rc or Arc instead.

Rc and Arc for shared ownership

Rc<T> is a single thread reference counting pointer; Arc<T> is atomic for multi thread sharing. Cloning either increases the strong count; when the last strong owner is dropped, the inner value is dropped. These types provide shared ownership; interior mutability is a separate choice.

Building shared graphs

Graphs and trees with parent links often require shared owners. Pair Rc with non owning Weak pointers to break cycles.

use std::rc::{Rc, Weak};
use std::cell::RefCell;

struct Node {
  value: i32,
  parent: RefCell<Weak<Node>>,
  children: RefCell<Vec<Rc<Node>>>,
}

fn main() {
  let root = Rc::new(Node { value: 1, parent: RefCell::new(Weak::new()), children: RefCell::new(vec![]) });
  let leaf = Rc::new(Node { value: 2, parent: RefCell::new(Weak::new()), children: RefCell::new(vec![]) });
  *leaf.parent.borrow_mut() = Rc::downgrade(&root);
  root.children.borrow_mut().push(leaf);
}
⚠️ Reference cycles with only strong owners leak memory because counts never reach zero. Use Weak for back pointers to avoid cycles.

Sharing across threads with Arc

Arc is safe to clone and move between threads. Combine it with Mutex or RwLock when shared mutation is required.

use std::sync::{Arc, Mutex};
use std::thread;

fn main() {
  let counter = Arc::new(Mutex::new(0_u64));
  let handles: Vec<_> = (0..4).map(|_| {
    let c = Arc::clone(&counter);
    thread::spawn(move || {
      for _ in 0..1_000 { *c.lock().unwrap() += 1; }
    })
  }).collect();

  for h in handles { h.join().unwrap(); }
  assert_eq!(*counter.lock().unwrap(), 4_000);
}

Cell and RefCell interior mutability

Interior mutability lets you mutate through a shared reference, with safety enforced at runtime. Cell<T> is for copy types and swap like operations; RefCell<T> performs dynamic borrow checking and works with any type. This pattern is useful when an API exposes shared references but needs to adjust hidden state.

Cell for small copies

Cell gives get and set for Copy types. It avoids borrow tracking by moving values in and out.

use std::cell::Cell;

struct Flag {
  hits: Cell<u32>,
}

impl Flag {
  fn hit(&self) { self.hits.set(self.hits.get() + 1); }
}

RefCell for dynamic borrows

RefCell enforces the borrowing rules at runtime. You borrow immutably with borrow and mutably with borrow_mut; violations panic, which means you should keep access patterns simple and well documented.

use std::cell::RefCell;

struct Log {
  lines: RefCell<Vec<String>>,
}

impl Log {
  fn push(&self, s: String) { self.lines.borrow_mut().push(s); }
  fn len(&self) -> usize { self.lines.borrow().len() }
}
💡 Pairing Rc<T> with RefCell<U> is a common pattern when multiple owners need mutation; for threads, use Arc with Mutex or RwLock.

Drop and resource management

Implement Drop to run code when a value goes out of scope. This is Rust’s approach to deterministic resource cleanup; it works for files, sockets, and any other external resource. Keep drop lightweight and avoid panicking inside it.

Custom cleanup with Drop

This wrapper writes a log line when the guard is destroyed. The pattern composes cleanly with early returns and errors.

struct Guard(&'static str);

impl Drop for Guard {
  fn drop(&mut self) {
    eprintln!("dropping {}", self.0);
  }
}

fn main() {
  let _g = Guard("temporary");
  // when main ends, drop runs automatically
}

Early release with std::mem::drop

Call std::mem::drop to release a value before the end of scope. This is useful when you need to free a lock or close a file so that later code can proceed.

use std::sync::{Mutex, Arc};
fn main() {
  let m = Arc::new(Mutex::new(0));
  let guard = m.lock().unwrap();
  // do work
  drop(guard); // releases the lock here
  // more work that does not hold the lock
}

Pin and self-referential data

Pin<P> guarantees that the pointee will not be moved again, which is necessary for types that store self references or for stackless futures that assume stable addresses. Most safe Rust code does not need Pin; use it when an API requires it, or when you build types that must never move once constructed.

Pinning indirection

Pinned values are usually behind a pointer, such as Pin<Box<T>> or Pin<Arc<T>>. You obtain a pinned pointer, then use projection methods to access fields safely.

use std::pin::Pin;

struct NeverMove {
  id: usize,
  // self-referential fields would go here …
}

fn main() {
  let x = Box::new(NeverMove { id: 7 });
  let pinned: Pin<Box<NeverMove>> = Box::pin(x);
  // pinned can be used safely; moving the box value would violate pinning
  assert_eq!(pinned.id, 7);
}

Pin in async code

Futures generated by async may hold pointers into their own stack frames. Executors pin futures before polling to keep addresses stable while state machines advance. When you write manual futures or project pinned fields, rely on pin-project or similar helpers to keep code correct.

⚠️ Self referential structs are hard to implement safely. Prefer designs that avoid internal self references; use indices or handles into containers instead.

Chapter 11: Concurrency and Parallelism

Rust gives you predictable concurrency by enforcing ownership and borrowing rules at compile time. Threads communicate through channels, shared state is guarded with locks or atomics, and marker traits such as Send and Sync tell the compiler what may move across threads. When tasks scale beyond manual threading, libraries such as rayon offer data parallelism with minimal boilerplate.

Threads and channels

The standard library provides std::thread for spawning threads and std::sync::mpsc for message passing. A thread runs a closure on a new call stack; channels send values from one thread to another without sharing mutable memory.

Spawning work in threads

thread::spawn takes ownership of a closure and returns a JoinHandle. Calling join waits for the thread to finish and yields its result or an error.

use std::thread;

fn main() {
  let handle = thread::spawn(|| {
    let mut n = 0;
    for _ in 0..1_000 { n += 1; }
    n
  });

  let result = handle.join().unwrap();
  assert_eq!(result, 1_000);
}

Message passing with channels

Channels use a sender and receiver. The sender clones freely, while the receiver is unique. Sending moves values into the channel; receiving yields them one at a time.

use std::sync::mpsc;
use std::thread;

fn main() {
  let (tx, rx) = mpsc::channel();

  thread::spawn(move || {
    for i in 0..3 { tx.send(format!("msg {i}")).unwrap(); }
  });

  for msg in rx {
    println!("{}", msg);
  }
}
💡 Channels avoid shared mutable state. If two threads only need to exchange data, a channel is simpler and often faster to reason about.

Send and Sync explained

Send means a type may be moved across threads. Sync means a type may be referenced from multiple threads at the same time. These traits are auto implemented for most types; the compiler infers them from their fields.

Send for ownership transfer

If a type implements Send, you may move it into a thread. Basic primitives and most smart pointers implement Send.

fn spawn_with_value(v: String) {
  std::thread::spawn(move || {
    println!("got {}", v);
  });
}

Sync for shared references

A type is Sync when &T may be shared across threads. Types that contain Cell or RefCell are not Sync because they allow mutation without locks.

// &usize is Sync, so Arc<usize> is shareable across threads
use std::sync::Arc;
use std::thread;

fn main() {
  let n = Arc::new(42_usize);
  let n2 = n.clone();
  thread::spawn(move || println!("{}", n2));
}
⚠️ You almost never implement Send or Sync by hand. Manual implementations require unsafe code; rely on the compiler to infer the traits unless you have a very specific low level use case.

Shared state with Mutex and RwLock

When threads must mutate the same data, wrap the value in a lock. Mutex permits one writer at a time; RwLock permits multiple readers or one writer. Both provide guards that release automatically through Drop.

Coordinating writes with Mutex

A Mutex<T> stores a value inside a lock. Accessing the value requires locking it; the guard holds a mutable reference until it is dropped.

use std::sync::{Arc, Mutex};
use std::thread;

fn main() {
  let shared = Arc::new(Mutex::new(0_i32));

  let workers: Vec<_> = (0..4).map(|_| {
    let s = shared.clone();
    thread::spawn(move || {
      for _ in 0..500 { *s.lock().unwrap() += 1; }
    })
  }).collect();

  for w in workers { w.join().unwrap(); }
  assert_eq!(*shared.lock().unwrap(), 2000);
}

Read heavy workloads with RwLock

RwLock supports multiple readers or one writer. It is useful when many threads read and updates are rare.

use std::sync::{Arc, RwLock};
use std::thread;

fn main() {
  let data = Arc::new(RwLock::new(vec![1, 2, 3]));

  {
    let read = data.read().unwrap();
    assert_eq!(read.len(), 3);
  }

  {
    let mut write = data.write().unwrap();
    write.push(4);
  }
}
💡 Locks add overhead. If many tasks only read immutable data, consider cloning shared values or using channels to avoid locking.

Atomics and Arc

Atomic types provide lock free operations on primitive values. They guarantee safe concurrent updates by using hardware atomic instructions. Use them when you need simple counters or flags without allocating a lock.

Atomic counters

Atomic integers support operations such as fetch_add, swap, and compare_exchange. Choose a memory ordering that matches your needs; SeqCst is the simplest and most consistent.

use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use std::thread;

fn main() {
  let count = Arc::new(AtomicUsize::new(0));
  let mut handles = vec![];

  for _ in 0..4 {
    let c = count.clone();
    handles.push(thread::spawn(move || {
      for _ in 0..1_000 { c.fetch_add(1, Ordering::SeqCst); }
    }));
  }

  for h in handles { h.join().unwrap(); }
  assert_eq!(count.load(Ordering::SeqCst), 4_000);
}

Atomic flags and coordination

You can build simple signals with atomic booleans. These flags coordinate work between threads without storing additional state.

use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::thread;
use std::time::Duration;

fn main() {
  let flag = Arc::new(AtomicBool::new(false));

  let f2 = flag.clone();
  thread::spawn(move || {
    thread::sleep(Duration::from_millis(200));
    f2.store(true, Ordering::SeqCst);
  });

  while !flag.load(Ordering::SeqCst) {
    std::thread::yield_now();
  }
}
⚠️ Atomics are powerful and easy to misuse. When synchronisation involves more than simple flags or counters, use Mutex or RwLock to avoid subtle ordering bugs.

Data parallelism with rayon

Rayon parallelises iterator based computations. It splits data into chunks, processes them in parallel, then recombines the results. You write the same iterator chains you would in a single thread, except you call par_iter instead of iter.

Parallel iterators

Parallel iterators behave like ordinary iterators but schedule work across a thread pool. Rayon handles load balancing internally.

use rayon::prelude::*;

fn main() {
  let data: Vec<u64> = (1..=1_000_000).collect();
  let sum: u64 = data.par_iter().copied().sum();
  println!("sum = {}", sum);
}

Parallel transformations

You can combine map, filter, reduce, and other adapters exactly as you would with sequential iterators.

use rayon::prelude::*;

fn main() {
  let squares: Vec<u64> = (1..=20_000)
    .into_par_iter()
    .map(|n| n * n)
    .filter(|n| n % 3 == 0)
    .collect();

  assert!(squares.len() > 0);
}
💡 Rayon excels at pure computations. When work involves heavy I/O or fine grained locking, ordinary threads or async designs often scale more predictably.

Chapter 12: Async Rust

Rust supports asynchronous programming through zero-cost abstractions that compile to efficient state machines. You write familiar code using async and await; the compiler lowers this into a Future that an executor drives to completion. This chapter explains how Future works, how executors schedule tasks, why pinning matters, how to work with Stream and Sink, how to use Tokio for tasks and timers, and how to approach async traits while avoiding common pitfalls.

Futures and executors

A Future is a value that represents a computation that may complete later. It has a single required method, poll, which asks whether the future can make progress. An executor repeatedly polls pending futures until they are ready. Most day-to-day code does not call poll directly; you use async and await, and the runtime handles polling for you.

What a Future really is

When you write an async fn, the compiler produces a type that implements Future. The function body becomes a state machine that stores any local variables across suspension points. This keeps allocations to a minimum and avoids hidden threads.

use core::future::Future;
use core::task::{Context, Poll};
use pin_project_lite::pin_project;
use std::pin::Pin;

pin_project! {
  struct ReadyOnce {
    done: bool,
  }
}

impl Future for ReadyOnce {
  type Output = 'static str;
  fn poll(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<Self::Output> {
    let this = self.project();
    if *this.done { Poll::Ready("done") } else { *this.done = true; Poll::Pending }
  }
}

fn main() {
  // A toy executor that polls until ready (illustrative only).
  let mut fut = Box::pin(ReadyOnce { done: false });
  let waker = futures::task::noop_waker();
  let mut cx = Context::from_waker(&waker);
  match fut.as_mut().poll(&mut cx) {
    Poll::Ready(v) => println!("{v}"),
    Poll::Pending => println!("pending"),
  }
}

This example shows the poll contract. Real programs rely on an executor to drive futures instead of calling poll directly.

Executors and runtimes

An executor schedules and runs tasks (futures that have been spawned). A runtime bundles an executor with I/O, timers, and other services. Tokio is a popular choice; async-std is another. The right choice depends on your needs.

RuntimeHighlightsTypical use
TokioMature ecosystem; multi-scheduler; powerful I/OServers; clients; production services
async-stdStd-like surface; broad utilitiesUtilities; simpler async tasks
Minimal executorsTiny footprint; no bundled I/OEmbedded; custom schedulers
💡 Think of an executor as a cooperative scheduler. Futures yield when they hit .await, which lets other tasks run without preemptive context switches.

async, await, and Pinning

async turns a function or block into a future. await pauses that future until the awaited one is ready. Pinning guarantees that a future will not move in memory after it starts running, which is important because the state machine may contain self-referential data.

Declaring and awaiting futures

An async fn returns an opaque future (impl Future<Output = T>). You can run it by awaiting inside an async context or by spawning it on a runtime.

async fn fetch_value() -> u32 {
  42
}

async fn use_it() -> u32 {
  let v = fetch_value().await;
  v + 1
}

To call use_it from main you need a runtime, since await requires an async context.

Why Pin matters

Most futures are !Unpin. Once they start running they must not move. Pin<T> enforces that a value stays at a stable address. Boxing with Box::pin is a common way to pin on the heap.

use std::pin::Pin;

async fn step() { /* work … */ }

async fn chained() {
  step().await;
  step().await;
}

fn pin_example() {
  let fut = chained();         // not pinned yet
  let _pinned = Box::pin(fut); // now pinned on the heap
}
⚠️ Avoid moving a !Unpin future after it has been polled. Moving can invalidate internal references. Use Pin to keep locations stable.

Streams and Sinks

Stream models a sequence of asynchronous values. Sink models an asynchronous consumer that you can send values into. Libraries such as futures provide traits and adapters for both.

Consuming a Stream

You can iterate a stream with while let plus next, or use combinators such as map, filter, and take. This supports backpressure because polling advances only when there is demand.

use futures::{StreamExt, stream};

async fn sum_first_three() -> i32 {
  let s = stream::iter([1, 2, 3, 4, 5]);
  s.take(3).fold(0, |acc, x| async move { acc + x }).await
}

Sending into a Sink

A Sink accepts values asynchronously. Many channel types implement Sink, which lets you pipe a Stream into a Sink with helpers such as forward.

use futures::{SinkExt, StreamExt};
use futures::channel::mpsc;

async fn forward_example() {
  let (tx, mut rx) = mpsc::channel(8);
  let sender = async move {
    let mut tx = tx;
    for i in 0..5 { tx.send(i).await.unwrap(); }
  };
  let receiver = async move {
    while let Some(v) = rx.next().await {
      println!("got {v}");
    }
  };
  futures::join!(sender, receiver);
}

Tokio tasks and timers

Tokio provides an efficient multi-threaded runtime, a scheduler for lightweight tasks, asynchronous I/O, and utilities like timers. You write #[tokio::main] to bootstrap the runtime, then tokio::spawn to schedule work.

Spawning tasks with tokio::spawn

Spawning detaches a future to run concurrently. The return value is a JoinHandle<T>. Await the handle to observe completion and to receive the task result.

use tokio::task;

#[tokio::main]
async fn main() {
  let h = task::spawn(async {
    "result from task"
  });
  let out = h.await.unwrap();
  println!("{out}");
}

Sleeping and intervals

Tokio timers integrate with the scheduler. sleep suspends a task until a deadline. interval yields on a fixed cadence, which is useful for heartbeats and housekeeping.

use tokio::time::{sleep, interval, Duration};

#[tokio::main]
async fn main() {
  let mut tick = interval(Duration::from_millis(200));
  for _ in 0..3 {
    tick.tick().await;
    println!("tick");
  }
  sleep(Duration::from_secs(1)).await;
  println!("done");
}
💡 Prefer tokio::time::sleep to busy loops. Yielding lets the runtime schedule other tasks and improves throughput.

async traits and pitfalls

Traits cannot contain async fn directly without indirection because the return type would be an opaque future whose exact type depends on the implementer. You can use explicit return futures, boxed futures, or helper crates that generate the necessary plumbing.

Approaches to async traits

One option is to return an associated type that implements Future. Another is to return Pin<Box<dyn Future<Output = T> + Send + '_>>. The associated type avoids allocation; the boxed approach simplifies signatures at the cost of a heap allocation.

use core::future::Future;
use std::pin::Pin;

trait Fetch {
  type Fut<'a>: Future<Output = String> + Send where Self: 'a;
  fn get<'a>('a self) -> Self::Fut<'a>;
}

struct Client;

impl Fetch for Client {
  type Fut<'a> = impl Future<Output = String> + Send + 'a;
  fn get<'a>('a self) -> Self::Fut<'a> {
    async move { "ok".to_string() }
  }
}

// Boxed variant for simpler signatures.
trait BoxedFetch {
  fn get(&self) -> Pin<Box<dyn Future<Output = String> + Send + '_>>;
}

impl BoxedFetch for Client {
  fn get(&self) -> Pin<Box<dyn Future<Output = String> + Send + '_>> {
    Box::pin(async move { "ok".to_string() })
  }
}

Common pitfalls

There are a few sharp edges to watch for in async Rust. Keeping them in mind will save time and avoid subtle bugs.

⚠️ Borrowing across .await creates temporary lifetimes that may not live long enough. Clone small data or restructure code so borrows do not span suspension points.

Chapter 13: Testing, Benchmarking, and Docs

Quality in Rust is built into the toolchain. You can write tests next to your code, run them with a single command, measure performance with reliable harnesses, and publish documentation generated from your comments. This chapter shows how unit and integration tests work, how to add property tests with proptest, how to benchmark with criterion, how to write doc tests, and how to wire everything into a simple continuous integration setup.

💡 The examples in this chapter use cargo commands. Run them at your project root; the workspace layout affects how tests and benches are discovered.

Unit and integration tests

Rust has two primary testing styles. Unit tests live close to the code they validate. Integration tests exercise your public API through the crate boundary. Both styles use the same test runner that cargo invokes. You can mix and match; start with focused unit tests, then add integration tests for end to end behaviour.

Module based unit tests with #[cfg(test)]

A common pattern places unit tests in a private tests module within the same file. The module is compiled only during tests; it is excluded from normal builds. This keeps helper functions and fixtures close to the code under test without leaking them into your final binary or library.

// src/math.rs
pub fn add(a: i32, b: i32) -> i32 {
  a + b
}

#[cfg(test)]
mod tests {
  use super::*;

  #[test]
  fn adds_positive_numbers() {
    assert_eq!(add(2, 3), 5);
  }

  #[test]
  fn adds_negative_numbers() {
    assert_eq!(add(-2, -3), -5);
  }

  #[test]
  #[should_panic(expected = "overflow")]
  fn panics_on_overflow_in_debug() {
    let _ = add(i32::MAX, 1);
  }
}

Run unit tests for the current crate with cargo test. Use cargo test name_fragment to run a subset. The runner filters by test function names and module paths.

Integration tests in the tests/ directory

Integration tests go in a top level tests/ folder and are compiled as separate crates. Each .rs file becomes its own test crate that depends on your library crate. This ensures you exercise only the public interface, not private internals.

// src/lib.rs
pub fn slugify(s: &str) -> String {
  s.to_lowercase().replace(' ', "-")
}

// tests/slug_test.rs
use mycrate::slugify;

#[test]
fn turns_spaces_into_hyphens() {
  assert_eq!(slugify("Hello World"), "hello-world");
}

Use cargo test --test slug_test to run a single integration file. Add a tests/common.rs module or a submodule tree like tests/common/mod.rs when you need shared helpers across multiple integration tests.

Assertions, matching, and custom messages

Rust’s standard library provides helpful assertion macros. Use equality assertions for exact results; use pattern matching when you only care about shape or variants; add custom messages to clarify failures.

#[test]
fn examples_of_asserts() {
  let value = Some(10);
  assert!(value.is_some(), "expected Some(…)");
  assert_eq!(2 * 3, 6, "basic arithmetic must hold");
  match value {
    Some(v) if v > 5 => {}
    other => panic!("unexpected value: {:?}", other),
  }
}
⚠️ Tests run in parallel by default. If your tests rely on global state or the filesystem, provide isolation. Use temporary directories, unique file names, or serialise with -- --test-threads=1 as a last resort.

Fixtures and test organisation

Keep tests readable by extracting setup code into small helpers. Prefer values and builders over mutable global state. For filesystem fixtures, create a temporary directory and clean up using tempfile. Table driven tests are straightforward; iterate over cases and assert each outcome.

#[derive(Debug)]
struct Case {
  input: &'static str,
  want: &'static str,
}

#[test]
fn table_driven_slugify() {
  let cases = [
    Case { input: "Hello World", want: "hello-world" },
    Case { input: "À bientôt",   want: "à-bientôt" },
  ];

  for c in cases {
    assert_eq!(slugify(c.input), c.want, "input: {}", c.input);
  }
}

Property testing with proptest

Property tests state invariants that must always hold, then generate many inputs automatically. This complements example based tests. Instead of a single example for each behaviour, you declare properties like idempotence, symmetry, or round trip encoding, and the framework searches for counterexamples.

Declaring properties and strategies

Install proptest in Cargo.toml. A strategy describes how to generate values. You can compose strategies to build complex inputs. The macro proptest! expands into multiple tests that cover many random cases with shrinking.

# Cargo.toml
[dev-dependencies]
proptest = "1"

# tests/prop_slug.rs
use mycrate::slugify;
use proptest::prelude::*;

proptest! {
  #[test]
  fn slug_is_lowercase(ref s in "\\PC*") {
    let slug = slugify(s);
    prop_assert!(slug.chars().all(|c| !c.is_uppercase()));
  }

  #[test]
  fn idempotent(input in "\\PC*") {
    let once = slugify(&input);
    let twice = slugify(&once);
    prop_assert_eq!(once, twice);
  }
}

When a failure occurs, proptest shrinks the input to a minimal counterexample. This makes debugging practical; you get a small case that reproduces the issue.

💡 Use domain specific strategies for realistic data. For example, define a strategy that yields valid URLs, JSON snippets, or constrained numbers; this produces failures that reflect real usage.

Composing strategies for structured data

Strategies are first class values. You can map and filter them to model constraints. The following example builds a simple URL like scheme://host/path and checks a round trip parse property.

use proptest::prelude::*;

#[derive(Debug, Clone, PartialEq, Eq)]
struct SimpleUrl {
  scheme: String,
  host: String,
  path: String,
}

fn parse_simple(s: &str) -> Option<SimpleUrl> {
  let parts: Vec<_> = s.splitn(3, '/').collect();
  if parts.len() < 3 { return None; }
  let scheme_host = parts[0];
  let path = format!("/{}", parts[2]);
  let mut it = scheme_host.splitn(2, "://");
  let scheme = it.next()?.to_string();
  let host = it.next()?.to_string();
  Some(SimpleUrl { scheme, host, path })
}

fn render_simple(u: &SimpleUrl) -> String {
  format!("{}://{}{}", u.scheme, u.host, u.path)
}

proptest! {
  #[test]
  fn parse_render_roundtrip(
    scheme in "(http|https)",
    host in "[a-z]{1,8}\\.example\\.com",
    path in "/[a-z0-9/]{0,16}"
  ) {
    let url = SimpleUrl { scheme: scheme.to_string(), host, path };
    let s = render_simple(&url);
    prop_assert_eq!(parse_simple(&s), Some(url));
  }
}

Benchmarks and criterion

Microbenchmarks help you track performance over time. The criterion crate provides statistics, warm up, resampling, and regression detection. It is more reliable than the unstable built in harness. Benchmarks live under benches/. Each file defines groups of related measurements.

Setting up criterion with benches/

Add criterion as a development dependency. Create a bench file in benches/, then run cargo bench. The harness records results to target/criterion and can compare against a baseline.

# Cargo.toml
[dev-dependencies]
criterion = { version = "0.5", features = ["html_reports"] }

# benches/slug_bench.rs
use criterion::{black_box, criterion_group, criterion_main, Criterion};
use mycrate::slugify;

fn bench_slugify(c: &mut Criterion) {
  let input = "The quick brown fox jumps over the lazy dog";
  c.bench_function("slugify baseline", |b| {
    b.iter(|| slugify(black_box(input)))
  });
}

criterion_group!(benches, bench_slugify);
criterion_main!(benches);

Open the HTML report to visualise distributions; it appears under target/criterion/…/report/index.html. For stable results, pin CPU frequency scaling and close background applications that create noise.

⚠️ Benchmark code runs in release mode by default. If your function relies on side effects, use black_box to prevent the optimiser from eliminating work.

Comparing algorithms and tracking regressions

Benchmarks are most useful when you compare competing approaches. Group related functions, then examine which inputs change the ordering. Criterion can track changes between runs and flag regressions with its statistical tests.

use criterion::{Criterion, BenchmarkId, criterion_group, criterion_main};

fn algo_a(s: &str) -> usize { s.bytes().filter(|b| b.is_ascii()).count() }
fn algo_b(s: &str) -> usize { s.chars().filter(|c| c.is_ascii()).count() }

fn compare_algos(c: &mut Criterion) {
  let mut group = c.benchmark_group("ascii_count");
  for size in [16usize, 256, 4096] {
    let input = "a".repeat(size);
    group.bench_with_input(BenchmarkId::new("bytes", size), &input, |b, s| {
      b.iter(|| algo_a(s))
    });
    group.bench_with_input(BenchmarkId::new("chars", size), &input, |b, s| {
      b.iter(|| algo_b(s))
    });
  }
  group.finish();
}

criterion_group!(benches, compare_algos);
criterion_main!(benches);

Doc tests and examples

Rust can compile and run examples embedded in your documentation. This prevents code drift. A doc comment marked with triple slashes becomes part of your public documentation and its code blocks are treated as tests by default. You can also publish standalone examples under examples/; these build like small binaries that depend on your crate.

Writing rustdoc comments with executable blocks

Use fenced code blocks with rust language tags. Prefer concise, self contained snippets. If your example requires extern crate or use lines, include them so the snippet compiles in isolation.

// src/lib.rs

/// Convert a string into a slug.
/// 
/// # Examples
/// 
/// ```rust
/// use mycrate::slugify;
/// let s = slugify("Hello World");
/// assert_eq!(s, "hello-world");
/// ```
pub fn slugify(s: &str) -> String {
  s.to_lowercase().replace(' ', "-")
}

Run all doc tests with cargo test --doc. Use attributes like ignore or no_run for examples that cannot execute, such as those that require network access. Keep these rare; tested code gives the strongest guarantee.

The examples/ directory for runnable demos

Place small programs under examples/ to demonstrate usage. They appear in documentation and can be executed directly. This is helpful for tutorials and quick start guides that readers can compile and run without scaffolding.

// examples/cli.rs
use mycrate::slugify;

fn main() {
  let arg = std::env::args().nth(1).unwrap_or_else(|| "Hello World".into());
  println!("{}", slugify(&arg));
}

Run the example with cargo run --example cli -- "Some Input". Keep examples focused; a single concept per file helps readers learn quickly.

💡 You can show multiple sections inside a doc comment using headers like # Panics, # Errors, and # Safety. Readers look for these conventional sections; use them consistently.

Continuous integration basics

Continuous integration runs your checks on every push. Even a lightweight pipeline improves confidence. Start with stable Rust; add nightly only when needed. Cache builds to keep runs fast. Fail the build on any test or lint error so that problems never merge undetected.

A minimal GitHub Actions workflow

Create a workflow file that installs the toolchain, caches dependencies, runs tests, and saves criterion reports as artifacts. This example uses stable Rust; adjust names and paths to match your crate. The parts with indicate optional steps.

# .github/workflows/ci.yml
name: CI

on:
  push:
    branches: [ main ]
  pull_request:

jobs:
  build-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: dtolnay/rust-toolchain@stable
      - uses: Swatinem/rust-cache@v2
      - name: Build
        run: cargo build --verbose
      - name: Lint
        run: cargo clippy --all-targets --all-features -- -D warnings
      - name: Format check
        run: cargo fmt -- --check
      - name: Unit and integration tests
        run: cargo test --all-features
      - name: Doc tests
        run: cargo test --doc
      - name: Benchmarks & reports
        run: cargo bench || true  # allow benches to run without failing the job
      - name: Upload criterion reports
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: criterion-reports
          path: target/criterion/**/report/index.html

Keep CI fast by avoiding unnecessary feature matrices. Add a second job for nightly or additional platforms later. For crates with network calls, provide feature flags that swap real clients for fakes during tests.

⚠️ Do not run microbenchmarks on shared runners for performance gating. Results vary across machines. Use criterion’s comparison locally or on dedicated hardware; treat CI benches as informational artifacts.

Catching undefined behaviour with sanitizers

You can enable sanitizers in CI to catch memory and thread issues early. Use a nightly toolchain for the thread sanitizer; address and leak sanitizers work on stable for many targets. Add dedicated jobs because these flags slow builds.

# Example CI step
- name: Address sanitizer
  run: RUSTFLAGS="-Zsanitizer=address" RUSTDOCFLAGS="-Zsanitizer=address" \
       cargo +nightly test -Zbuild-std --target x86_64-unknown-linux-gnu

Summary of test types

The following table contrasts common test and example locations. Use it as a quick reference when deciding where to place new checks.

Location Purpose Visibility Runs with
#[cfg(test)] module Focused unit tests near implementation Private to crate cargo test
tests/ Public API integration tests Through crate boundary cargo test
examples/ Runnable usage demos Users can run directly cargo run --example …
Rustdoc code blocks Executable documentation Published docs cargo test --doc
benches/ with criterion Performance tracking and comparisons N/A cargo bench

Checklist before publishing

Before releasing a version, run through a short checklist. Confirm formatting, linting, tests, doc tests, and examples. Review benchmark trends. Ensure your CI workflow reflects these steps so that every pull request gets the same scrutiny.

# Local quality sweep
cargo fmt -- --check
cargo clippy --all-targets --all-features -- -D warnings
cargo test --all-features
cargo test --doc
cargo bench  # review reports under target/criterion/…

Chapter 14: Macros and Metaprogramming

Rust’s macro system lets you extend the language at the syntactic level. Declarative macros match patterns and expand into new code; procedural macros let you inspect and transform syntax trees. Macros allow expressive libraries and safe abstractions while preserving compile time guarantees. This chapter explores macro_rules patterns, hygiene, procedural macros, derive systems, and guidance for choosing when macros are the right tool.

💡 Macros run during compilation. Think of them as code that writes code. This avoids runtime overhead and keeps abstractions lightweight.

Declarative macros with macro_rules!

Declarative macros match token patterns and rewrite them using rules. The system resembles powerful pattern substitution with strict syntax matching. Each rule is a match arm. When a call fits a pattern the corresponding rule expands into its replacement. Declarative macros excel at small domain specific expressions, forwarding APIs, or repetitive boilerplate.

Basic pattern matching

A macro accepts input as tokens. You describe the structure with fragment specifiers such as $e:expr for expressions or $t:ty for types. The expansion can repeat fragments using * or +. The following macro computes a vector literal by capturing expressions and pushing them into a generated Vec.

macro_rules! collect_vec {
  ( $( $x:expr ),* ) => {
    {
      let mut v = Vec::new();
      $( v.push($x); )*
      v
    }
  };
}

fn main() {
  let nums = collect_vec!(1, 2, 3, 4);
  println!("{:?}", nums);
}

Patterns must match exactly. If a user writes input that does not match a rule the compiler reports an error at the call site.

Using repetition and separators

Repetition allows flexible input. You can specify separators inside the pattern to make syntax cleaner. The following example demonstrates a simple map literal macro that collects key value pairs.

macro_rules! simple_map {
  ( $( $k:expr => $v:expr ),* $(,)? ) => {
    {
      let mut m = std::collections::HashMap::new();
      $( m.insert($k, $v); )*
      m
    }
  };
}

fn build() {
  let m = simple_map!(
    "alice" => 1,
    "bob" => 2,
  );
  println!("{:?}", m);
}

The optional trailing comma pattern $(,)? improves ergonomics and mirrors Rust’s built in collection syntax.

⚠️ Avoid extremely permissive patterns. Narrow patterns lead to clearer error messages because the compiler can point directly at the mismatch.

Hygiene and pattern design

Macro hygiene ensures that names inside a macro do not accidentally collide with names in the caller’s scope. Rust assigns each macro expansion its own namespace for generated identifiers. This prevents accidental capture of variables or shadowing unless you explicitly opt in. Hygienic expansion is crucial for reliability and maintainability.

Identifier hygiene

When you write a macro that introduces new bindings such as let temp = …, the binding receives a fresh hygienic name. The user cannot clash with it by naming their variable temp. Conversely, a macro can refer to identifiers from its definition scope when needed. This lets you reuse helper functions without exposing them directly.

macro_rules! safe_add {
  ( $a:expr, $b:expr ) => {{
    // `sum` is hygienic and cannot collide with caller variables.
    let sum = $a + $b;
    sum
  }};
}

fn main() {
  let sum = 5; // no conflict with the macro's internal `sum`
  let v = safe_add!(2, 3);
  println!("{}", v);
}

Hygiene prevents a common class of errors found in older macro systems. You can assume that macro internals remain isolated unless you explicitly expose names.

Designing robust macro patterns

Good macro patterns anticipate common user input. Match the simplest shapes first. Handle trailing commas. Avoid ambiguous forms that confuse parsing. When building a mini language with macros, treat patterns as grammar rules. Ensure each fragment has a clear role. Prefer explicit tokens rather than overloading punctuation with multiple meanings.

💡 Think of declarative macros as parsers with pattern arms. When patterns read like grammar rules users understand the macro more easily.

Procedural macros

Procedural macros operate on Rust’s syntax tree. They receive a TokenStream as input and produce another TokenStream as output. This permits arbitrary code generation, inspection, and transformation. Procedural macros live in their own crate type with proc-macro = true in Cargo.toml. They are compiled as compiler plugins and linked by crates that use them.

Structure of a procedural macro crate

A procedural macro crate usually depends on syn for parsing and quote for emitting Rust code. The following example demonstrates a tiny attribute macro that prints the function name at runtime. The attribute wraps the function body in generated code.

// my_macro/src/lib.rs
use proc_macro::TokenStream;
use quote::quote;
use syn::{parse_macro_input, ItemFn};

#[proc_macro_attribute]
pub fn trace(_attr: TokenStream, item: TokenStream) -> TokenStream {
  let input = parse_macro_input!(item as ItemFn);
  let name = &input.sig.ident;
  let block = &input.block;
  let gen = quote! {
    fn #name() {
      println!("entering {}", stringify!(#name));
      #block
    }
  };
  gen.into()
}

Consumers enable the macro by adding the proc macro crate as a dependency, then using the attribute in their code. The macro runs during compilation and emits the modified function.

Parsing and generating structures

The syn crate maps Rust syntax to rich types. You can pattern match on ItemFn, Expr, Type, and more. At expansion time you create a new syntax tree with quote! and return it. This gives you expressive metaprogramming capabilities. Keep expansions predictable so that generated code remains readable when developers inspect it with compiler output tools.

⚠️ Procedural macros can slow compile times because they run during the parsing and expansion phases. Use them judiciously and keep parsing minimal.

Derive, attribute, and function-like macros

Procedural macros come in three flavours. Derive macros attach to items such as structs. Attribute macros attach to items and wrap or replace them. Function-like macros resemble calls like sql!( … ) and expand to code fragments. Each flavour serves a different role.

Derive macros

Derive macros generate implementations for traits. The standard library provides several such as Debug, Clone, and PartialEq. Custom derive macros let libraries expand boilerplate such as serializers, builders, or database models. A typical derive macro accepts a struct or enum and emits an impl block with generated methods.

use serde::Serialize;

#[derive(Serialize)]
struct Point {
  x: i32,
  y: i32,
}

Derive macros shine when multiple types need similar implementations. They centralise logic and reduce repetition in user code.

Attribute macros

Attribute macros attach to items such as functions, modules, or impl blocks. They can wrap the item, modify signatures, or expand decorators. For example you might write #[trace] to instrument functions or #[route] to bind a function to a web handler. Attribute macros are flexible but require careful design to keep generated code explicit and understandable.

#[timeit]
fn heavy_work() {
  // body …
}

The macro behind #[timeit] might measure execution time by expanding to code that records timestamps and prints results.

Function-like macros

Function-like macros look like regular function calls but operate at compile time. They often provide small DSLs for expressing patterns such as SQL queries, embedded resources, or bitfield definitions. They receive their input tokens and emit new Rust code.

let query = sql!(SELECT * FROM users WHERE id = 1);

The macro expands to a struct or function call that encodes the query safely. Function-like macros behave like textual invocations but benefit from Rust’s type system because they operate on structured tokens rather than plain strings.

When to use macros

Macros are powerful. Use them when regular functions or generics cannot solve the problem with equal clarity. If you want to eliminate boilerplate, enforce patterns, embed declarative syntax, or perform compile time transformations, macros may help. If simple functions or traits suffice, prefer those instead because they are easier to reason about and often compile faster.

Guidelines for choosing macros

Reach for macros only when you need one of these capabilities. Each point reflects a situation where ordinary Rust constructs reach their limits.

⚠️ Avoid macros that hide complex behaviour or that surprise users with implicit work. Favour explicit expansion patterns that developers can reason about by examining generated code.

Understanding both declarative and procedural macros gives you a broad foundation for expressive metaprogramming in Rust. The best macros clarify code instead of obscuring it. When used thoughtfully they elevate libraries without sacrificing safety or readability.

Chapter 15: Unsafe Rust and FFI

Rust’s safety guarantees are strict by design. When you work inside safe Rust the compiler prevents data races, null pointer dereferences, unaligned loads, and many other hazards. Sometimes you must perform operations that the compiler cannot verify. The unsafe keyword opens carefully controlled escape hatches. This chapter explains what unsafe permits, how raw pointers work, how to manage layouts and aliasing, how to call C code, and how to build safe abstractions that wrap unsafe internals.

💡 Unsafe does not mean unchecked chaos. The language still enforces many invariants. Unsafe blocks let you assert that specific operations uphold Rust’s rules even though the compiler cannot confirm them.

The unsafe keyword and guarantees

The unsafe keyword allows operations that require manual proof of correctness. These include dereferencing raw pointers, calling unsafe functions, accessing mutable static variables, implementing unsafe traits, and performing certain intrinsics. Outside these actions the compiler continues to enforce Rust’s standard checks. You must ensure that each unsafe action upholds memory safety, aliasing guarantees, and concurrency rules.

What unsafe can and cannot do

unsafe does not disable borrowing rules or allow unrestricted memory access. It grants permission only for narrowly defined operations. You still cannot violate Rust’s aliasing rules or type system. Unsafe code is responsible for maintaining invariants that safe code relies upon.

unsafe fn add_one(p: *mut i32) {
  // Caller must guarantee `p` is valid for writes.
  *p += 1;
}

fn main() {
  let mut x = 10;
  let p = &mut x as *mut i32;
  unsafe { add_one(p) };
  println!("{x}");
}

The surrounding safe code must uphold the assumptions required by the unsafe function. These assumptions form part of the function’s contract.

Unsafe traits

A trait becomes unsafe when incorrect implementations would break invariants that the compiler depends on. For example Send and Sync are unsafe traits because the compiler relies on their correctness for concurrency safety. You must audit unsafe trait implementations with care.

⚠️ Place the smallest amount of code inside an unsafe block. A tiny unsafe boundary is easier to audit.

Raw pointers and references

Rust has two raw pointer types: *const T and *mut T. They behave like C pointers. They can be null; they can be unaligned; they do not carry lifetimes; and they are not subject to borrow checking. Turning them into references safely requires checking alignment, validity, and aliasing conditions.

Creating and using raw pointers

You can obtain raw pointers from references with as casts. Converting back to references requires an unsafe block because the compiler cannot validate pointer correctness.

fn raw_pointer_roundtrip() {
  let mut x = 5;
  let p: *mut i32 = &mut x;

  unsafe {
    // safe because `p` points to a valid, aligned, writable i32
    *p = 20;
    let r: &i32 = &*p;
    println!("{}", r);
  }
}

Because raw pointers are not subject to Rust’s borrowing rules they enable low level data structures or bindings. You must preserve all safety invariants manually.

Null pointers and pointer arithmetic

Raw pointers permit null and arithmetic; references do not. Pointer arithmetic is unsafe because it risks leaving the valid object or slice bounds. In FFI contexts you often inspect C arrays through raw pointers and perform manual iteration.

unsafe fn sum(ptr: *const u32, len: usize) -> u32 {
  let mut acc = 0;
  for i in 0..len {
    acc += *ptr.add(i);
  }
  acc
}

This style parallels C code. You must ensure that the pointer covers len readable elements and that alignment requirements hold.

Layouts, repr, and aliasing

Rust’s default layout for structs and enums is implementation defined. When interfacing with foreign code you need predictable layouts. The repr attribute controls field ordering, padding, and ABI conventions. Aliasing rules affect how the compiler optimises loads and stores; violating them leads to undefined behaviour.

Controlling structure layout with repr

Use repr(C) to match C layouts. This arranges fields in a stable order with standard ABI alignment. Use repr(transparent) to wrap a single field without adding padding. Use repr(u8) or similar to fix discriminant sizes for fieldless enums.

#[repr(C)]
struct Point {
  x: f64,
  y: f64,
}

This struct now matches an equivalent C struct with two double fields. Without repr(C) the Rust compiler may reorder or pad fields differently.

Aliasing and the strict provenance model

Rust assumes references obey strict aliasing. A mutable reference guarantees exclusive access to its target. Raw pointers may alias but converting them into references must not violate exclusivity. The strict provenance model requires that pointers originate from valid bases. Because of this optimisations assume no unexpected aliasing through mutable references.

⚠️ Never create two mutable references to the same location at the same time. Doing so breaks Rust’s aliasing rules even inside unsafe blocks.

Alignment and uninitialised memory

Types have alignment requirements. Loading from misaligned addresses is undefined on many platforms. Use std::ptr::read_unaligned and friends for unaligned work. For uninitialised memory use MaybeUninit, which avoids undefined behaviour while constructing complex values.

use std::mem::MaybeUninit;

fn build_pair(a: i32, b: i32) -> (i32, i32) {
  let mut slot: MaybeUninit<(i32, i32)> = MaybeUninit::uninit();
  unsafe {
    slot.as_mut_ptr().write((a, b));
    slot.assume_init()
  }
}

MaybeUninit gives you space without telling Rust that the contents are valid until you initialise them manually.

Calling C from Rust

Foreign function interfaces allow Rust to call C libraries and vice versa. The extern "C" ABI ensures compatible call conventions. You declare function signatures in an extern block and link against system or bundled libraries. Arguments and return types must match the C definitions exactly.

Declaring C functions

Declare an external function with extern "C". Use primitive types or repr(C) structs to mirror the C side.

// Rust declaration
extern "C" {
  fn abs(x: i32) -> i32;
}

fn main() {
  unsafe {
    let v = abs(-10);
    println!("{v}");
  }
}

Calling foreign functions is unsafe because Rust cannot verify the validity of pointers or other invariants expected by the C function.

Passing pointers and buffers

FFI often involves passing pointers to buffers. You must ensure the lifetime, size, and alignment meet C’s expectations. A slice converts to a pointer plus length with methods such as as_ptr and len. For output buffers pass as_mut_ptr and guarantee that enough space exists.

extern "C" {
  fn fill(buf: *mut u8, len: usize);
}

fn call_fill() {
  let mut data = [0u8; 32];
  unsafe {
    fill(data.as_mut_ptr(), data.len());
  }
  println!("{:?}", &data[..]);
}

Memory ownership must match what the C function expects. If C allocates memory your Rust code must free it using the matching deallocator.

💡 Bindgen can generate Rust bindings from C headers automatically. Review generated code because FFI correctness depends on accurate types.

Exposing Rust functions to C

You can export Rust functions for use from C by applying #[no_mangle] and using extern "C". Use only FFI safe types. Wrap Rust specific behaviour behind safe boundaries.

#[no_mangle]
pub extern "C" fn add_safely(a: i32, b: i32) -> i32 {
  a + b
}

Link the resulting library into a C program. For cross language data exchange use stable C structs or plain buffers.

Building safe abstractions over unsafe code

The best unsafe code hides behind a small safe API. The safe layer enforces invariants so users cannot misuse the underlying operations. This separation mirrors the design of Rust’s standard library. Many core types such as Vec, Box, and String use unsafe code internally while exposing safe interfaces.

Designing invariants

Before writing unsafe internals specify the invariants that must always hold. Examples include valid pointer ranges, correct lengths, null termination, or exclusive access. Encode these invariants into constructors and safe methods so callers cannot create invalid states.

pub struct Buffer {
  ptr: *mut u8,
  len: usize,
}

impl Buffer {
  pub fn from_raw(ptr: *mut u8, len: usize) -> Option<Self> {
    if ptr.is_null() { return None; }
    Some(Buffer { ptr, len })
  }

  pub fn get(&self, i: usize) -> Option<u8> {
    if i >= self.len { return None; }
    unsafe { Some(*self.ptr.add(i)) }
  }
}

This abstraction ensures callers cannot index out of bounds. The unsafe code enforces pointer safety behind the scenes.

Minimising unsafe blocks

Keep unsafe logic isolated. Place checks in safe wrappers, then perform the minimal necessary unsafe operations. Prefer explicit assertions over assumptions so that violations fail loudly in debug builds.

⚠️ Never assume pointer validity without justification. Document assumptions so future maintainers understand the reasoning.

Using types to express safety

Rich types reduce the need for unsafe code. Use enums to represent modes, newtypes to wrap raw handles, and lifetimes to prevent invalid references. When combined with unsafe internals these types enforce correct usage patterns statically.

Unsafe Rust and FFI integration require care but reward you with performance and flexibility. By isolating unsafe code, documenting invariants, and exporting safe APIs you can unlock low level capabilities while preserving Rust’s strong safety story.

Chapter 16: I/O and the System

Many real programs spend most of their time moving bytes in and out of files, sockets, and the console. Rust provides capable synchronous primitives in std, and a thriving asynchronous ecosystem built around tokio. This chapter shows how to work with files and paths, build small networked programs, parse command line options and environment variables, serialize data with serde, and use time, randomness, and operating system specific features safely and clearly.

Files, paths, and directories

Rust’s std::fs and std::path modules provide portable tools for common tasks such as opening files, reading and writing bytes or text, walking directories, and building paths that work across platforms. The types are small and focused which makes error handling straightforward and composable.

Reading and writing with std::fs

You can read an entire file into memory or stream it incrementally. Both approaches are useful; choose by file size and access pattern. The following example shows text read and write with helpful errors.

use std::fs;
use std::io::{self, Write};

fn main() -> io::Result<()> {
  let content = fs::read_to_string("notes.txt")?;
  println!("Read {} chars", content.len());

  let mut f = fs::File::create("out.txt")?;
  f.write_all(b"Hello, file\n")?;
  Ok(())
}
💡 Prefer read_to_string and write for whole-file operations, and buffered readers or writers for large streams. A buffered wrapper reduces syscalls which improves throughput.

Buffered I/O with BufReader and BufWriter

Buffers smooth out small reads and writes. The reader exposes line oriented helpers and the writer batches output before flushing. This keeps code simple while remaining efficient.

use std::fs::File;
use std::io::{self, BufRead, BufReader, BufWriter, Write};

fn copy_lines(src: &str, dst: &str) -> io::Result<()> {
  let f = File::open(src)?;
  let mut out = BufWriter::new(File::create(dst)?);
  for line in BufReader::new(f).lines() {
    let line = line?;
    writeln!(out, "{}*", line)?;
  }
  Ok(())
}

Working with Path and PathBuf

Paths are more than strings. Use Path for borrowed views and PathBuf for owned, mutable paths. Join components with push and use display for printing.

use std::path::{Path, PathBuf};

fn log_path(base: &Path, name: &str) -> PathBuf {
  let mut p = base.to_path_buf();
  p.push("logs");
  p.push(name);
  p.set_extension("txt");
  p
}

Creating directories and walking trees

The filesystem API can create nested directories and iterate directory entries. Always inspect each entry’s metadata because entries can be files, directories, or symlinks.

use std::fs;
use std::io;
use std::path::Path;

fn ensure_and_list(dir: &Path) -> io::Result<()> {
  fs::create_dir_all(dir)?;
  for entry in fs::read_dir(dir)? {
    let entry = entry?;
    let meta = entry.metadata()?;
    println!("{} (dir: {})",
             entry.path().display(),
             meta.is_dir());
  }
  Ok(())
}
TaskConvenienceStreaming
Read textfs::read_to_stringBufRead::read_line
Read bytesfs::readRead::read_exact
Write bytesfs::writeWrite::write_all
List entriesfs::read_dirwalkdir crate

Networking with std and tokio

Rust offers synchronous networking in std::net, which suits small utilities or simple servers. For high concurrency or nonblocking workloads, the tokio runtime exposes async I/O that scales to many connections with one thread pool and cooperative scheduling.

A tiny TCP server using std::net

This server accepts one connection at a time. It reads bytes and echoes them back. This pattern is great for exploring protocols and learning the I/O traits.

use std::io::{Read, Write};
use std::net::{TcpListener, TcpStream};

fn handle(mut s: TcpStream) {
  let mut buf = [0u8; 1024];
  while let Ok(n) = s.read(&mut buf) {
    if n == 0 { break; }
    let _ = s.write_all(&buf[..n]);
  }
}

fn main() {
  let listener = TcpListener::bind("127.0.0.1:4000").expect("bind");
  for conn in listener.incoming() {
    handle(conn.expect("conn"));
  }
}
⚠️ Blocking I/O ties up the thread while waiting. For many concurrent clients, prefer async I/O to avoid creating a thread per connection.

The same idea with tokio

Async code uses async functions and await points. The runtime multiplexes tasks. Add a dependency and choose features in your manifest.

# Cargo.toml
[dependencies]
tokio = { version = "1", features = […] }
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};

async fn handle(mut s: TcpStream) {
  let mut buf = [0u8; 1024];
  loop {
    let n = match s.read(&mut buf).await {
      Ok(0) -> break,
      Ok(n) -> n,
      Err(_) -> break,
    };
    if s.write_all(&buf[..n]).await.is_err() { break; }
  }
}

#[tokio::main]
async fn main() -> tokio::io::Result<()> {
  let listener = TcpListener::bind("127.0.0.1:4000").await?;
  loop {
    let (sock, _) = listener.accept().await?;
    tokio::spawn(async move { handle(sock).await; });
  }
}

UDP and timeouts

std::net::UdpSocket and tokio::net::UdpSocket support datagrams. With TCP or UDP you can set timeouts to keep calls from hanging forever. In async code, use tokio::time::timeout; in blocking code, use set_read_timeout and set_write_timeout.

use std::net::UdpSocket;
use std::time::Duration;

fn main() {
  let s = UdpSocket::bind("0.0.0.0:0").unwrap();
  s.set_read_timeout(Some(Duration::from_millis(500))).unwrap();
  s.send_to(b"hi", "127.0.0.1:4001").unwrap();
}

Command line parsing and env

Small utilities often read positional arguments, flags, and environment variables. For quick scripts, std::env is enough. For robust parsing and usage text, a dedicated crate such as clap keeps code declarative and user friendly.

Quick reads with std::env::args

args yields an iterator of program arguments. The first element is the executable path. Convert to numbers or paths as needed and validate input early for better error messages.

use std::env;

fn main() {
  let mut a = env::args();
  let _exe = a.next();
  let name = a.next().unwrap_or_else(|| "world".into());
  println!("Hello, {}", name);
}

Loading environment variables with std::env

Environment variables configure behavior without editing code. Use a default when a variable is missing. Treat secrets carefully and avoid printing them.

use std::env;

fn endpoint() -> String {
  env::var("API_ENDPOINT").unwrap_or_else(|_| "https://example.com".into())
}

Structured parsing with clap

clap derives a parser from a struct which provides automatic help and validation. This approach centralizes the interface and reduces boilerplate.

# Cargo.toml
[dependencies]
clap = { version = "4", features = […] }
use clap::Parser;

#[derive(Parser, Debug)]
#[command(author, version, about)]
struct Opts {
  #[arg(short, long, default_value_t = 4000)]
  port: u16,
  #[arg(short = 'H', long)]
  host: Option<String>,
}

fn main() {
  let opts = Opts::parse();
  println!("Listening on {}:{}",
           opts.host.as_deref().unwrap_or("127.0.0.1"),
           opts.port);
}
💡 Keep the CLI stable. Add new options in backward compatible ways and reserve breaking changes for major releases.

Serialization with serde

serde provides fast, general purpose serialization for Rust. With a few traits you can serialize to formats such as JSON, TOML, and CBOR. The same data structures work with multiple formats which encourages clean domain models.

Deriving Serialize and Deserialize

Add the derive feature, then mark your types. Optional fields and enums map naturally across formats. Custom field names are also supported with attributes.

# Cargo.toml
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
use serde::{Deserialize, Serialize};

#[derive(Debug, Serialize, Deserialize)]
struct Config {
  host: String,
  port: u16,
  #[serde(default)]
  debug: bool,
}

Reading and writing JSON

The serde_json crate reads and writes UTF-8 JSON. Use to_string_pretty when humans will read the output. For large data, use reader and writer streams.

use serde_json::{from_str, to_string_pretty};

fn roundtrip() {
  let s = r#"{"host":"localhost","port":4000}"#;
  let cfg: Config = from_str(s).unwrap();
  let out = to_string_pretty(&cfg).unwrap();
  println!("{}", out);
}

Swapping formats without changing types

Because serde is format agnostic, the same Config works with other crates. For TOML, use toml. For CBOR, use serde_cbor. This keeps I/O choices outside of core logic.

# Cargo.toml
[dependencies]
toml = "0.8"
fn parse_toml(txt: &str) -> Result<Config, toml::de::Error> {
  toml::from_str(txt)
}
⚠️ When persisting data, version your schema. Add new fields with defaults and avoid changing meanings of existing fields. Compatibility reduces friction for your users.

Time, random, and OS specifics

Programs measure durations, schedule retries, generate random values, and occasionally need platform specific behavior. The standard library covers durations and monotonic time. For randomness, the rand crate is the common choice. For platform details, conditional compilation keeps code portable while enabling targeted features where available.

Durations and monotonic clocks

Instant and Duration measure elapsed time without being affected by system clock changes. Use SystemTime only when you need a timestamp that matches the operating system clock.

use std::time::{Duration, Instant};

fn main() {
  let start = Instant::now();
  // work …
  let elapsed = start.elapsed();
  if elapsed > Duration::from_millis(10) {
    println!("Slow path: {:?}", elapsed);
  }
}

Random numbers with rand

rand provides secure generators and distributions. Use thread_rng for convenience or create a dedicated RNG if you need reproducibility with a seed.

# Cargo.toml
[dependencies]
rand = "0.8"
use rand::Rng;

fn main() {
  let mut rng = rand::thread_rng();
  let x: u32 = rng.gen_range(0..100);
  println!("Picked {}", x);
}
💡 For cryptography, use emphasis on audited crates designed for security, not plain PRNG output. Choose APIs that make misuse difficult.

Sleeping and scheduling

Sleeping yields control so other work can run. In async code you should use the runtime’s sleep which does not block the thread. In synchronous code you can sleep the current thread.

std::thread::sleep(std::time::Duration::from_millis(200));
tokio::time::sleep(std::time::Duration::from_millis(200)).await;

Conditional compilation for OS behavior

Some APIs exist only on certain platforms. Use cfg attributes to select the correct code path while keeping a single codebase. Provide a fallback when a feature is not available.

#[cfg(unix)]
fn home_dir() -> Option<std::path::PathBuf> {
  std::env::var_os("HOME").map(Into::into)
}

#[cfg(windows)]
fn home_dir() -> Option<std::path::PathBuf> {
  std::env::var_os("USERPROFILE").map(Into::into)
}

With these tools you can build utilities that feel native on every target platform. Combine precise error handling with careful resource management and your programs will behave predictably even under load or failure.

Chapter 17: Building Real Projects

It is time to turn your Rust skills into complete applications. This chapter walks through common project types and the practices that carry them from a blank repository to a shipped artifact. You will see how to structure a binary crate, add dependencies, keep configuration outside of code, test what matters, and publish with confidence.

CLI tools from zero to release

Command line tools are a great way to learn how Rust scales from small programs to robust utilities. You will start with a new binary crate, add a command line parser, handle I/O and errors, and finish by producing signed, reproducible release builds.

Project layout and cargo scaffolding

A binary project starts as a crate with a src/main.rs entry point. Keep logic in src/lib.rs where possible, so it can be tested and reused by the binary.

# Create a new binary crate
cargo new ripgrepish --bin
cd ripgrepish

# Minimal main that calls into a library function
// src/main.rs
use ripgrepish::run;

fn main() {
  if let Err(e) = run() {
    eprintln!("error: {e}");
    std::process::exit(1);
  }
}
// src/lib.rs
pub fn run() -> Result<(), Box<dyn std::error::Error>> {
  // real logic lives here
  Ok(())
}
💡 Keeping logic in lib.rs makes it easy to expose a clean API and write unit tests without spinning up the binary each time.

Parsing arguments with clap

The clap crate provides structured parsing, validation, and usage text. Prefer derive based definitions so your help output stays in sync with your code.

// Cargo.toml (snippet)
[dependencies]
clap = { version = "4", features = ["derive"] }
// src/cli.rs
use clap::{Parser, Subcommand};

#[derive(Parser)]
#[command(author, version, about)]
pub struct Args {
  /// Search pattern
  pub pattern: String,
  /// Path to search (defaults to current directory)
  #[arg(default_value = ".")]
  pub path: String,
  #[command(subcommand)]
  pub cmd: Option<Cmd>,
}

#[derive(Subcommand)]
pub enum Cmd {
  /// Generate shell completions
  Completions { shell: String },
}
// src/main.rs (wire up)
mod cli;
use cli::Args;

fn main() {
  let args = Args::parse();
  // handle args.cmd and args.pattern …
}

I/O, errors, and logging

For pleasant diagnostics, combine anyhow or eyre with thiserror in library code and a lightweight logger in the binary. Structured logs help users and future you.

// Cargo.toml (snippet)
[dependencies]
anyhow = "1"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["fmt"] }
// src/main.rs
fn main() {
  tracing_subscriber::fmt::init();
  if let Err(err) = ripgrepish::run() {
    tracing::error!("fatal error: {err:?}");
    std::process::exit(1);
  }
}

Testing, benchmarking, and releasing

Automate quality and releases. Add fast unit tests, a few integration tests in tests/, and set up CI to run across platforms. Use cargo build --release for optimized binaries and attach them to a tagged release.

# Typical flow
cargo fmt -- --check
cargo clippy -- -D warnings
cargo test
cargo build --release
# Create v1.0.0 tag and attach binaries …

Web services with axum or actix-web

Rust is a strong fit for network services. You will pick a framework, define routes and handlers, apply middleware, and serve with an async runtime. The patterns are similar across frameworks; choose based on ergonomics and ecosystem fit.

Choosing axum vs actix-web

axum sits on tokio and tower with a focus on routing and middleware composition. actix-web offers a batteries included experience with an actor model at its core. Both are production ready.

⚠️ If you depend on a specific async runtime in your wider stack, align your web framework with it to avoid mixing runtimes in one process.

Routing and handlers

Handlers are async functions that take typed extractors and return responses. Keep them small and delegate to services in your application layer.

// Cargo.toml (snippet for axum)
[dependencies]
axum = "0.7"
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
// src/main.rs (axum hello)
use axum::{routing::get, Router};

#[tokio::main]
async fn main() {
  let app = Router::new()
    .route("/health", get(health))
    .route("/api/items/…", get(list_items));
  let listener = tokio::net::TcpListener::bind("0.0.0.0:8080").await.unwrap();
  axum::serve(listener, app).await.unwrap();
}

async fn health() -> &'static str { "ok" }
async fn list_items() -> String { "[]" .to_string() }

Middleware, state, and errors

Use middleware for cross cutting concerns like logging, timeouts, authentication, and compression. Share state by injecting an Arc<AppState> and use a consistent error type for clean responses.

// Shared state
use std::sync::Arc;
#[derive(Clone)]
struct AppState {
  // db pool, config, cache …
}

Serving static files and TLS

For assets, mount a static directory or place a reverse proxy in front. For TLS, terminate at a proxy or use rustls if the service will handle certificates itself.

Database access with SQLx and Diesel

Rust offers both query first and schema first approaches. SQLx executes raw SQL with compile time checking when a database URL is available. Diesel generates strongly typed queries from schema definitions.

Connecting and pooling

Create a connection pool during startup and pass it through application state. Prefer a small, bounded pool and instrument it to detect saturation.

// SQLx example
# Cargo.toml (snippet)
[dependencies]
sqlx = { version = "0.7", features = ["runtime-tokio", "postgres"] }

// src/db.rs
use sqlx::{Pool, Postgres};

pub type PgPool = Pool<Postgres>;

pub async fn connect(url: &str) -> Result<PgPool, sqlx::Error> {
  sqlx::postgres::PgPoolOptions::new()
    .max_connections(5)
    .connect(url)
    .await
}

Migrations

Version your schema with migrations that can be applied forward and backward. Treat the database as part of your codebase and keep migration files under version control.

# SQLx
cargo sqlx migrate add create_users
# creates migrations/…/up.sql and down.sql

Queries and type safety

With SQLx, write SQL directly and map rows to structs. With Diesel, build queries with combinators that mirror SQL and leverage Rust types for safety.

// SQLx typed fetch
use serde::Serialize;

#[derive(Serialize, sqlx::FromRow)]
struct User { id: i64, name: String }

pub async fn list(pool: &PgPool) -> Result<Vec<User>, sqlx::Error> {
  sqlx::query_as::<User>("SELECT id, name FROM users ORDER BY id")
    .fetch_all(pool)
    .await
}
💡 Enable SQLx offline mode to keep compile time checks without a live database by committing the metadata file generated from your development database.

Testing against a real database

Use ephemeral databases or schemas for integration tests. Apply migrations during test setup and tear down afterwards for isolation.

// tests/db_it.rs
#[tokio::test]
async fn creates_user() {
  // spin up test pool, run migrations, call functions …
}

Configuration and secrets

Keep configuration outside of code and separate secrets from non secret settings. Use layered sources such as defaults, configuration files, and environment variables. Do not commit secrets to version control.

Layered configuration

Load defaults first, then override with file based settings, and finally with environment variables. This order makes local development smooth while letting deployments adjust behavior.

// Example structure
#[derive(serde::Deserialize, Clone)]
pub struct Settings {
  pub host: String,
  pub port: u16,
  pub database_url: String,
}

Environment variables and .env

During development, a .env file can populate variables. In production, inject environment variables from a secret store or deployment system.

# .env
HOST=127.0.0.1
PORT=8080
DATABASE_URL=postgres://…

Secret management

Use a dedicated secret manager when available. If you must read files at runtime, mount secrets as files with strict permissions and read them at startup.

⚠️ Never log the contents of configuration structures that include secrets. Redact fields or implement a custom formatter that hides sensitive values.

Configuration validation

Validate configuration at startup and fail fast with a clear message if anything is missing or malformed. Keep the validation near the loading code for discoverability.

Packaging, versioning, and publishing

Reliable builds and clear versions help users trust your project. Package artifacts consistently, follow semantic versioning, and publish to the right channel for your audience.

Cargo.toml metadata and features

Populate package metadata and organize optional functionality with features. Good metadata improves discoverability and sets expectations for users.

# Cargo.toml (snippet)
[package]
name = "ripgrepish"
version = "1.0.0"
edition = "2021"
license = "MIT"
repository = "https://…"
description = "A tiny grep-like tool in Rust"

[features]
default = ["cli"]
cli = []
tls = []

Semantic versioning and change management

Use version numbers to communicate compatibility. Increment the patch when fixing bugs, the minor when adding compatible features, and the major when you make breaking changes. Keep a changelog that maps changes to versions.

Building and signing release artifacts

Produce release binaries for target platforms and attach checksums. Consider signing artifacts so users can verify integrity.

# Example targets
cargo build --release --target x86_64-unknown-linux-gnu
cargo build --release --target x86_64-pc-windows-msvc
cargo build --release --target aarch64-apple-darwin
shasum -a 256 target/…/ripgrepish > ripgrepish-….sha256
💡 For cross compilation, use a container that matches your target or a project like cross so your builds are reproducible on any host.

Publishing crates and binaries

If you are sharing a library, publish to crates.io. If you are shipping a CLI, create a tagged release with attached binaries and a short installation section that shows one or two commands. Provide a checksum file and instructions for verification.

# Library crate
cargo publish

# Binary release (outline)
git tag v1.0.0
git push origin v1.0.0
# attach artifacts and write release notes …

With these practices, your Rust projects move from experiments to dependable tools and services. The next chapters expand on testing strategies, performance tuning, and deployment patterns so you can operate your software with confidence.

Chapter 18: Patterns, Style, and Next Steps

This chapter gathers the habits that make Rust code readable and reliable. It highlights the idioms that guide everyday work, the traps that pull projects off track, the principles behind rustic APIs, and the techniques that expose performance issues. You will also weigh synchronous and asynchronous designs and finish with a short tour of the evolving ecosystem.

Common idioms and anti-patterns

Rust culture leans toward clarity and explicit behavior. The standard library and community crates follow patterns that keep data ownership clear, errors approachable, and concurrency predictable. Understanding these patterns makes it easier to integrate with existing libraries and avoid costly mistakes.

Using Option and Result with intent

Option expresses the presence or absence of a value. Result expresses potential failure. Both types make control flow descriptive and help the compiler check correctness. You can chain operations with map, and_then, and the ? operator for linear error propagation.

fn load_user(id: u64) -> Result<User, LoadError> {
  let row = query_user(id)?;  // fails fast
  row.into_user().ok_or(LoadError::BadData)
}

Borrowing instead of cloning

Reach for references before cloning. Cloning is not bad by itself but cloning without thinking can hide unnecessary work. Borrowing often keeps code faster and more accurate about ownership.

💡 Clone only when the semantics or performance profile call for a new owned value. When in doubt, profile before optimizing.

Avoiding large trait objects without a reason

Trait objects are powerful but can hide dynamic dispatch costs and complicate lifetimes. Use them when runtime polymorphism is appropriate and prefer generics when static dispatch gives clearer structure.

Controlling panics

Panics are for unrecoverable situations. Using panics for normal error handling is an anti-pattern. Keep panics rare and meaningful so any crash points to real logical flaws.

APIs that feel rustic

Rust APIs often expose the smallest complete interface that supports useful behavior. They lean on compiler checks, do not hide allocations, and encourage structured errors. The result is predictable code that communicates intent.

Clear ownership boundaries

A rustic API makes ownership explicit. Passing &str or &[u8] communicates borrowing. Passing String or Vec<T> transfers ownership. This reduces silent copies and makes performance expectations obvious.

fn tokenize(src: &str) -> Vec<Token> { … }
fn with_temp_data(data: Vec<u8>) -> Output { … }

Returning iterators

Iterators let callers choose how to consume data. This avoids premature allocation and keeps APIs flexible. Most collections implement IntoIterator, so chaining becomes natural.

pub fn words(s: &str) -> impl Iterator<Item = &str> {
  s.split_whitespace()
}

Error types that explain what went wrong

Use structured errors instead of plain strings. A good error type communicates failure modes clearly and makes recovery possible when it matters.

⚠️ Avoid boxing all errors behind Box<dyn Error> unless the API genuinely supports many unrelated error types. A specific error enum is easier to match on.

Performance profiling and tuning

Rust produces fast binaries by default, but good performance still depends on measuring real workloads. Profiling reveals bottlenecks such as excessive allocation, lock contention, or unnecessary cloning. Tuning usually means reshaping data or rethinking an algorithm rather than sprinkling micro-optimizations.

Using cargo tools

cargo bench and criterion provide micro-benchmarks. cargo flamegraph produces visual call stacks that expose hot paths. Build with --release because debug builds do not represent real performance.

# install flamegraph
cargo install flamegraph

# run benchmark
cargo flamegraph

Allocation profiling

Tools such as dhat or heaptrack show where allocations occur. Reducing allocation inside tight loops often yields solid improvements. Switching from owning containers to borrowed slices can also help.

Optimizing data structures

Using the right collection matters. Vec is fast and cache friendly. HashMap handles random lookups. BTreeMap keeps keys sorted. Match the structure to the access pattern for best results.

Choosing sync vs async

Rust supports both synchronous and asynchronous models. The decision shapes architecture, deployment, and performance. Each model has strengths, and most projects mix them at the boundaries.

When synchronous designs work well

Synchronous code shines when tasks are CPU bound or when concurrency needs are modest. It is easy to reason about and integrates smoothly with threading primitives like Mutex and Condvar.

When asynchronous designs scale better

Async is useful for large numbers of mostly idle tasks such as network servers waiting on I/O. The runtime schedules many tasks on a small pool of threads. This allows high concurrency without a thread per connection.

// async wait example
tokio::time::sleep(std::time::Duration::from_secs(1)).await;

Practical boundaries

Mixing sync and async in the same subsystem can cause deadlocks or thread starvation. Keep boundaries clear and convert between the two models at explicit entry points.

💡 If your program is mostly network or file I/O, async usually pays off. If it is mostly computation, keep it synchronous for simpler control flow.

The Rust ecosystem and roadmap

The Rust community evolves through RFCs, steady compiler improvements, and expanding libraries. Stable Rust receives frequent releases that refine performance, ergonomics, and safety without breaking existing code.

Key ecosystem areas

The most active spaces include web services, data processing, embedded systems, command line tooling, and game development. Tooling such as cargo, rustup, and rust-analyzer keeps the ecosystem welcoming for newcomers.

Language improvements

Future releases explore areas such as generic associated types, improved error messages, more robust compile time evaluation, and better debugging support. The goal is smooth scaling from small scripts to industrial systems.

Long term stability

Rust maintains stability through its strong backward compatibility promise. Code written years ago usually compiles today. This reliability makes Rust attractive for long lived software and large refactorings.

By adopting these patterns and exploring the broader ecosystem, you can move from basic proficiency to confident, idiomatic Rust. The ideas in this chapter set the stage for ongoing growth and more advanced problem solving.



© 2025 Robin Nixon. All rights reserved

No content may be re-used, sold, given away, or used for training AI without express permission

Questions? Feedback? Get in touch