Macros Aren't As Bad As Other Languages Make Them To Be!
🦀 Tip of the day 01: Macros Aren’t As Bad As Other Languages Make Them To Be!
If you are coming from other languages, you were probably taught that macros are a dangerous “code smell.” In Rust, they are a celebrated, incredibly safe feature!
Why they aren’t a code smell in Rust:
No “Copy-Paste” Bugs: C&C++ macros (#define) do dumb text substitution before compilation, easily breaking math logic. Rust macros operate on the Abstract Syntax Tree (AST). They understand the actual structural boundaries of your code and output safe, compiler-checked expressions.
Hygienic Scoping: Variables created inside a Rust macro cannot accidentally leak out and overwrite variables in your main code.
What we actually use them for:
Variable Arguments (Variadics): Regular Rust functions require a strict, fixed number of arguments. Macros bypass this, letting you pass in as many items as you want (which is why println!(“{} {}”, a, b) and vec![1, 2, 3] are macros!).
Banishing Boilerplate: Attribute macros like #[derive(Debug, Clone)] automatically write hundreds of lines of repetitive implementation code for your structs behind the scenes.
Domain-Specific Languages (DSLs): They allow you to safely bend Rust’s syntax rules. For example, some web frameworks use macros to let you write raw HTML directly inside your Rust file: html! { <div>Hello</div> }.
The takeaway: In some languages, macros are a brittle hack to bypass the compiler. In Rust, macros are a safe, compiler-aware tool designed to write code that writes code.
🔇 Don’t ask me why a hyper-modern language forces you to use advanced meta-programming to print a simple println!(“Hello, World!”); and still hasn’t figured out native variadic functions yet. We don’t talk about that!
macro_rules! - The Dark Arts of Token Matching!
🦀 Tip of the day 02: macro_rules! - The Dark Arts of Token Matching!
You loved macros so much yesterday, we are back for round two! While other languages just let you write functions or do blind text-replacement, Rust gives you macro_rules!, a powerful mini-language inside Rust that lets you play compiler. It’s essentially regular expressions, but for parsing your actual code!
Core Features: AST Pattern Matching: Instead of passing runtime variables, you pass literal syntax. You capture chunks of code using designators like $x:ident (for variable names) or $y:expr (for expressions). Safe Expansion: The compiler fully checks the expanded code. If your custom macro generates garbage, Rust catches it at compile time, ensuring your clever shortcuts are perfectly safe. What we actually use them for: Custom Data Structures: Rust doesn’t have a built-in dictionary literal, so we use macros to fake it! You can create a hashmap!{ “a” => 1, “b” => 2 } macro to initialize collections cleanly. Inventing Control Flow: You can literally invent new syntax. Want a Ruby-style unless keyword? A macro can turn unless!(condition, { action }) into a standard if !condition { action } behind the scenes. Mass Implementing Traits: If you have 5 different integer wrapper structs, a single macro can write the Add, Sub, and Mul traits for all of them in one go. The takeaway: macro_rules! is a superpower that lets you safely bend the laws of Rust’s syntax to eliminate boilerplate and create beautiful custom APIs.
🔇 If you moved to Rust and you miss Java, you can actually use a macro to match literal tokens and recreate that exact enterprise experience. Just do this:
macro_rules! enterprise_edition {
// We can match the EXACT literal tokens of Java's main method!
(public static void main(String[] args) $body:block) => {
fn main() $body
};
}
enterprise_edition! {
public static void main(String[] args) {
println!("Ah, finally... some good, verbose enterprise boilerplate!");
}
}
…so you can wrap your elegant Rust program in that corporate nostalgia
Banning Null
🦀 Tip of the day 03: Banning Null
First, a confession: We were supposed to cover Procedural Macros today. But explaining how to write a custom compiler plugin from scratch to manipulate Abstract Syntax Trees requires a 10-part blog post, a whiteboard, and way too much caffeine. We are skipping it! Instead, let’s look at a feature you will actually use every single day: The death of null.
If you are coming from Java, Python, or C#, you probably live in constant fear of the NullPointerException. In most languages, almost any variable can secretly be empty, and you usually only find out when your program crashes in production. Rust doesn’t have null. At all.
How it works in practice: Like many modern languages, Rust uses an Option type (Some or None) to represent values that might be missing. The difference is that the Rust compiler forces you to handle the None case. You cannot accidentally use the inner data without explicitly unpacking it first. What you write:
#![allow(unused)]
fn main() {
// The return type 'Option<String>' explicitly warns you this might be empty
fn get_user_name(id: u32) -> Option<String> {
if id == 1 {
Some("Alice".to_string())
} else {
None
}
}
let user = get_user_name(2);
// If you try to print or use `user` directly here, the code will NOT compile!
// The compiler FORCES you to handle the empty case:
match user {
Some(name) => println!("Welcome back, {}!", name),
None => println!("User not found."),
}
}
Wait, do I really have to write a match block in every single function? Thankfully, no! While the compiler forces you to acknowledge the Option, it gives you ergonomic shortcuts so you don’t spend half your day writing boilerplate: The “Pass the Buck” operator (?): If you put a question mark at the end of a function call (like let name = get_user_name(2)?;), Rust will automatically extract the data if it exists. If it’s None, it immediately aborts the current function and returns None to whoever called you. It’s a clean way to bubble up missing data without nesting. The “I Live Dangerously” method (.unwrap()): You can tell the compiler, “Trust me, I am absolutely certain this is not empty.” It bypasses the safety checks and gives you the data instantly.
🔇 Rust gracefully eliminates the unpredictable, billion-dollar mistake of the classic null pointer crash you’d get in Java, Python, or C#. Instead, it provides you with the incredibly convenient .unwrap() method, which allows your program to violently crash with a highly predictable panic! on line 42 instead. So much safer!
Reaching the Point of No return!
🦀 Tip of the day 04: Reaching the Point of No return!
If you are coming from Java, Python, or C++, you are deeply conditioned to type return whenever you want a function to hand data back. You might be surprised to learn that in idiomatic Rust, we rarely use the return keyword at all. In Rust, you don’t formally declare you are returning a value; you just… stop typing.
How it works in practice: Rust is an expression-based language. This means almost every block of code evaluates to a value, even things like if blocks, loops, and match statements. The core rule of Rust functions is wonderfully simple: The last expression in a block is automatically returned, provided it doesn’t end with a semicolon.
What you write:
#![allow(unused)]
fn main() {
// The C/Java/Python way (Valid in Rust, but your linter will judge you)
fn add_boring(a: i32, b: i32) -> i32 {
return a + b;
}
// The Idiomatic Rust way
fn add_idiomatic(a: i32, b: i32) -> i32 {
a + b // Look ma, no `return`! And no semicolon!
}
}
Expressions vs. Statements (The Semicolon Trap): This is where new Rustaceans usually trip up. Expression (No Semicolon): Evaluates to a value and passes it outward. a + b hands back an integer. Statement (With Semicolon): Performs an action, suppresses the value, and evaluates to the empty unit type (), which is Rust’s safe version of void. a + b; calculates the math, immediately throws the integer in the trash, and returns ().
Wait, so do I ever use the return keyword? Yes! The return keyword is still alive and well, but it is strictly reserved for early returns. If you need to bail out of a function prematurely, maybe because of an error or a guard clause, you explicitly return.
#![allow(unused)]
fn main() {
fn divide(a: i32, b: i32) -> i32 {
if b == 0 {
return 0; // Early return requires the keyword AND a semicolon
}
a / b // The implicit return at the very end of the function
}
}
The takeaway: By treating blocks of code as expressions, Rust allows your logic to flow naturally into its output, cutting down on visual clutter and letting the structural boundaries of your code dictate what gets returned.
🔇 I was going to leave you with a clever joke about semicolons to wrap this up, but sorry, I have no pun to return;. I guess I’ll just have to punt this one to tomorrow!
Variables That... Don't Vary
🦀 Tip of the day 05: Variables That… Don’t Vary
If you are coming from Python, JavaScript, or C++, you are used to declaring a variable and changing its value whenever you need to. It is right there in the name: a variable is supposed to vary. In Rust, variables are immutable by default. Once you bind a value to a name, it is locked in.
How it works in practice: Rust assumes that once you set a value, you probably don’t want it changing behind your back. If you do actually need to modify it later, you have to formally request permission from the compiler using the mut (mutable) keyword.
What you write:
#![allow(unused)]
fn main() {
// The Python/JS way (The Rust compiler will respectfully reject this)
let score = 10;
// score = 20; // ERROR: cannot assign twice to immutable variable `score`
// The idiomatic Rust way
let mut score = 10; // `mut` flags that this value will change
score = 20; // All good!
}
Why do we do this? It is all about trust and readability. When you are reading a massive function and see let config = load_settings();, you have a compiler-backed guarantee that config won’t be secretly modified by a random if statement 50 lines down. Conversely, when you see let mut, it acts as a polite heads-up: “Pay attention, this data is going to change over time.” It makes debugging much faster. If you are ever tracking down an unintended state change, you only have to suspect the variables explicitly marked as mutable. The takeaway: By making you opt-in to mutation, Rust forces you to be intentional about state changes, eliminating a whole category of accidental overwrite bugs.
🔇 Yes, we still call them “variables” even though 90% of them will never vary. Just think of it as a historical naming quirk, exactly like how we still click a picture of a floppy disk every time we want to save a file!
Shadowing, Reusing Variable Names on Purpose!
🦀 Tip of the day 06: Shadowing, Reusing Variable Names on Purpose!
If you are coming from Java, Python, or C++, you were probably taught that reusing a variable name in the same scope is sloppy, confusing, and a guaranteed source of bugs. Your linter screams at you. Your code reviewer leaves a passive-aggressive comment. Your professor deducts points. In Rust, shadowing is not only legal, it is idiomatic and actively encouraged.
How it works in practice: When you write let x = …; a second time in the same scope, you are not mutating the old variable. You are creating an entirely new variable that happens to have the same name. The old value is effectively dead. The compiler knows it is unreachable and can drop it.
What you write:
#![allow(unused)]
fn main() {
let input = " 42 "; // A string
let input = input.trim(); // Still a string, but trimmed
let input = input.parse::<i32>().unwrap(); // Now it's an integer!
println!("{}", input + 1); // 43, works perfectly, it's an i32 now
}
We just changed the type of input from &str to i32, using the same name. Try doing that with mut and the compiler will slap you:
#![allow(unused)]
fn main() {
let mut input = " 42 ";
input = input.trim(); // OK: &str → &str, same type
input = input.parse().unwrap(); // ERROR: expected `&str`, found `i32`
// `mut` lets you change the VALUE, not the TYPE.
}
Why it is NOT a code smell in Rust: No silent mutation. In Python, reusing a name silently overwrites the old value with zero safety checks:
connection = open_database()
connection = "oops, I'm a string now"
connection.execute("SELECT * FROM users") # Runtime crash at 3 AM. Good luck.
In Rust, shadowing creates a completely new binding, the old variable is not mutated, it is replaced. The compiler audits this: if the old value was never used before being shadowed, you get a warning. And if you accidentally call .execute() on a &str, the type system catches it at compile time. The bug that would have been a 3 AM production incident is a squiggle in your editor before you even hit save. Type transformations are explicit: Shadowing is the idiomatic way to progressively refine a value through a pipeline. Without it, you end up with input_raw, input_trimmed, input_parsed: a graveyard of throwaway names that clutter your function and make you play a naming game instead of writing logic. Where we actually use it:
#![allow(unused)]
fn main() {
// Narrowing Option/Result types:
let config = load_config(); // Returns Option<Config>
let config = config.unwrap_or_default(); // Now it's just Config, same name, no confusion
// Immutable "freeze" after setup:
let mut buffer = Vec::new();
buffer.push(1);
buffer.push(2);
buffer.push(3);
let buffer = buffer; // Shadow with an immutable binding, buffer is now locked
// buffer.push(4); // ERROR: cannot borrow as mutable, the setup phase is over
}
You get a mutable setup phase, then freeze the variable by shadowing it with an immutable let. The compiler guarantees no code below this line can accidentally modify it.
The takeaway: In most languages, variable shadowing is an accidental footgun that hides bugs. In Rust, it is a deliberate, compiler-checked tool for transforming data through a clean pipeline, without inventing throwaway names or resorting to mutation.
🔇 In Python, you can shadow a database connection with a 🥪 and nobody will know until the pager goes off.
const -- Values the Compiler Knows Before Your Program Even Starts
🦀 Tip of the day 07: const – Values the Compiler Knows Before Your Program Even Starts
You already know from tip 5 that let variables are immutable by default. But const is a different beast entirely.
#![allow(unused)]
fn main() {
let max_players = 8; // Decided at runtime, when this line runs
const MAX_PLAYERS: u32 = 8; // Decided at compile time, baked into the binary
}
The compiler does not reserve memory for const. It replaces every use of MAX_PLAYERS with the literal 8 before your program even runs.
Rules that come with it:
Since the value must be known at compile time, you are limited to literals, math on other constants, and a few built-in operations. You cannot call non-const functions to initialize a const (more on that later).
const TIMEOUT: u64 = 30; // Type annotation is required, no inference allowed
const PI: f64 = 3.14159; // Can live at global scope, unlike `let` which can only exist inside functions
const ROWS: usize = 8;
const COLS: usize = 8;
const TOTAL: usize = ROWS * COLS; // Math on other consts works
const NAME: String = String::from("hello"); // ERROR: String::from is not a const function
fn main() {
const SIZE: usize = 10;
let numbers = [0; SIZE]; // Array sizes must be compile-time, so const works here but let does not
}
Naming convention: ALL_CAPS_SNAKE_CASE. The compiler will not enforce it, but every Rustacean will judge you.
How other languages handle it:
Go and Kotlin also have true compile-time constants. Go’s is interesting: constants are untyped by default and pick up their type at the point of use. Kotlin’s const val is stricter, only primitives and String, and it must live at the top level or inside a companion object. C++ has constexpr/consteval, which are the closest cousins to Rust’s const and the original inspiration for compile-time evaluation.
Then there are the pretenders. JavaScript’s const just means “can’t reassign”, the value is still computed at runtime, and you can freely mutate the object it points to. Java’s static final looks like a constant but the language makes no compile-time guarantee. Python does not have constants at all. ALL_CAPS is just a polite suggestion that everyone ignores.
Think of const as a fact about your program that is true before it even starts, like the number of cards in a deck, or the value of pi. let is for things you figure out while running. const is for things you already know.
🔇 JavaScript really named their “you can’t reassign this but go ahead and mutate whatever is inside” feature const. Brave naming choice.
No Free Lunches, No Free Defaults
🦀 Tip of the day 08: No Free Lunches, No Free Defaults
In most languages, you can declare a variable and use it without ever giving it a value. What happens next depends on the language, and none of the options are great.
In C++, an uninitialized int contains whatever garbage was sitting in memory. Your program compiles, runs, and produces different results each time. This is undefined behavior; the compiler is not required to warn you, and debuggers will not always catch it.
In Python, you get a NameError at runtime. Maybe better than garbage 🤔? But you only find out when that exact line of code executes. If it is behind a rare if branch, it might not blow up until production.
In Rust, nothing has a default value unless you explicitly ask for one. If you try to use a variable before assigning it, the compiler rejects your code.
#![allow(unused)]
fn main() {
let x: i32;
println!("{}", x); // ERROR: used binding `x` isn't initialized
}
This is not a runtime crash. This is a compile-time error. Your program will not build until every variable has a value before it is used. Every code path, every branch, is checked before your code ever runs.
Late initialization is fine, as long as every path sets the value:
#![allow(unused)]
fn main() {
let temperature;
if sensor_available {
temperature = read_sensor();
} else {
temperature = 0; // Explicit fallback, your choice
}
println!("Temp: {}", temperature); // Works, both branches set it
}
The compiler tracks every possible code path. If even one branch leaves the variable unset, it will not compile.
What if I actually want a default?
Rust has the Default trait. But you have to opt in. Nothing gets it for free.
#![allow(unused)]
fn main() {
let x: i32 = Default::default(); // 0, but you asked for it explicitly
let s: String = Default::default(); // "", same idea
}
The key difference: in Rust, you wrote Default::default(), so anyone reading the code knows the default was intentional, not an accident.
The takeaway: Rust does not assume you want zero, false, or empty. If a value matters enough to exist, it matters enough to be set on purpose.
🔇 C++ lets you read uninitialized memory and calls it “performance.” Python waits until your code is running in production to tell you a variable does not exist. Rust just says no at compile time. Boring, but you sleep better.
Structs, or "Classes Without the Drama"
🦀 Tip of the day 09: Structs, or “Classes Without the Drama”
If you are coming from Java, Python, or C#, you are used to classes. A class bundles your data and your methods together, usually with inheritance, constructors, access modifiers, and a 200-line file before you even get to the interesting part.
Rust has structs. They hold your data. That’s it.
#![allow(unused)]
fn main() {
struct Player {
name: String,
score: u32,
online: bool,
}
}
No constructors. No inheritance. No public/private on each field (yet). Just: here is the shape of my data.
Creating one:
#![allow(unused)]
fn main() {
let alice = Player {
name: String::from("Alice"),
score: 0,
online: true,
};
println!("{} has {} points", alice.name, alice.score);
}
Every field must be set. Just like you learned in tip 8, Rust has no implicit defaults. The compiler will reject your struct if you forget even one field.
Quick shorthand: If your variable names match the field names, Rust lets you skip the repetition:
#![allow(unused)]
fn main() {
let name = String::from("Bob");
let score = 100;
let online = false;
let bob = Player { name, score, online }; // No need for `name: name`
}
The struct update syntax (..):
Remember the Default trait from tip 8? Here is where it really shines. If all your fields already implement Default, just derive it:
#![allow(unused)]
fn main() {
#[derive(Default)]
struct Point3D {
x: f64, // defaults to 0.0
y: f64, // defaults to 0.0
z: f64, // defaults to 0.0
}
let p = Point3D { y: 1.0, ..Default::default() }; // x = 0.0, y = 1.0, z = 0.0
}
When the standard defaults are not what you want, implement it manually:
#![allow(unused)]
fn main() {
struct ServerConfig {
host: String,
port: u16,
max_connections: u32,
}
impl Default for ServerConfig { // more on this later
fn default() -> Self {
Self {
host: String::from("localhost"),
port: 8080,
max_connections: 100,
}
}
}
let config = ServerConfig { port: 3000, ..Default::default() };
// host = "localhost", port = 3000, max_connections = 100
}
The .. syntax fills in all the fields you did not specify from another instance. It also works for copying from an existing struct:
#![allow(unused)]
fn main() {
let base = Point3D { x: 1.0, y: 2.0, z: 3.0 };
let moved = Point3D { z: 10.0, ..base }; // x = 1.0, y = 2.0, z = 10.0
}
You set the fields you care about, everything else comes from the source. No builders, no optional parameters, no 10-argument constructor.
Tuple structs: When naming every field feels like overkill, you can use positional fields instead:
#![allow(unused)]
fn main() {
struct Color(u8, u8, u8);
let red = Color(255, 0, 0);
println!("R: {}", red.0); // Access by position
}
Printing structs with #[derive(Debug)]:
If you try to println! a struct, the compiler will tell you it does not know how to display it. #[derive(Debug)] is a macro (everything is a macro) that auto-generates a Debug trait implementation for your struct, so Rust knows how to print it.
Why {:?} and not {}? Rust has two formatting traits. {} uses Display, which is for user-facing output. Rust will not auto-generate that because it cannot guess how you want to present data to users. {:?} uses Debug, which is for developer-facing output. Since the format is mechanical (dump the struct name and all fields), it can be derived automatically.
#![allow(unused)]
fn main() {
#[derive(Debug)]
struct Player {
name: String,
score: u32,
}
let alice = Player { name: String::from("Alice"), score: 42 };
println!("{:?}", alice); // Player { name: "Alice", score: 42 }
}
The takeaway: Structs are Rust’s way of saying “define your data, nothing more.” Methods, behavior, and all the fun stuff come separately (tomorrow!).
🔇 Rust looked at the 100-line Java class with one name field, a constructor, a getter, a setter, a toString, an equals, a hashCode, and said: “What if we just… didn’t?” Though you will still end up putting #[derive(Debug)] on almost every struct, so the boilerplate found a way back in.
impl -- Where Your Structs Learn New Tricks
🦀 Tip of the day 10: impl – Where Your Structs Learn New Tricks
Yesterday you learned that structs are just data. No methods, no behavior, just fields. So where do the methods go?
In Rust, data and behavior are separate. You define your data in a struct, then attach behavior in an impl block.
#![allow(unused)]
fn main() {
struct Player {
name: String,
score: u32,
}
impl Player {
fn increase_score(&mut self, points: u32) {
self.score += points;
}
fn display(&self) {
println!("{}: {} points", self.name, self.score);
}
}
}
The self parameter:
Every method takes self as its first parameter, like Python’s explicit self or the implicit this in Java/C++. The difference is that Rust makes you specify how you are using it:
&selfmeans “I just need to read the data”&mut selfmeans “I need to modify the data”
The compiler uses this to make sure nobody else is messing with your struct at the same time. For now, just remember: read-only gets &self, mutation gets &mut self.
Associated functions and the new convention:
Rust has no constructors. Instead, you write a regular function inside impl that returns a new instance. By convention, it is called new:
#![allow(unused)]
fn main() {
impl Player {
fn new(name: String) -> Player {
Player { name, score: 0 }
}
}
let alice = Player::new(String::from("Alice"));
}
Notice new does not take self. It is called with Player::new(...), not alice.new(...). These are called associated functions, similar to static methods in Java.
Why not just write Player { name, score: 0 } directly? For that, you need to understand visibility.
Visibility: pub and private by default
In Rust, struct fields are private by default. Only code in the same module can access them. pub makes a field public.
A module is simply a way to group related code together, like a module in Python or a package in Java. You create one with the mod keyword and put code inside the curly braces:
#![allow(unused)]
fn main() {
mod game {
pub struct Player {
name: String, // private: only code inside `game` can see this
pub score: u32, // public: anyone can read and set this
}
impl Player {
pub fn new(name: String) -> Player {
Player { name, score: 0 }
}
pub fn name(&self) -> &str {
&self.name // public method that gives read access to the private field
}
}
}
let alice = game::Player::new(String::from("Alice"));
println!("{}", alice.score); // Works, score is pub
println!("{}", alice.name()); // Works, going through the method
// println!("{}", alice.name); // ERROR: field `name` is private
}
pub is the only keyword. There is no private keyword: no keyword means private. There is no protected, no package-private, not even C++ friend 😢. Just public or private.
This is why the new convention exists. If your fields are private, outside code cannot construct the struct directly. new becomes the only door in, and you control what comes through it.
You can have multiple impl blocks for the same struct. Unusual if you come from other languages, but perfectly legal and sometimes useful for organizing code.
The takeaway: In Rust, data and behavior live in separate blocks. Structs define the shape, impl blocks define what it can do.
🔇 Rust’s approach to constructors: there are none. Just write a function called new and hope everyone follows the convention. It is the Python ALL_CAPS constant naming of object creation.
Enums -- More Than Just a List of Labels
🦀 Tip of the day 11: Enums – More Than Just a List of Labels
If you are coming from Java, C++, or C#, you know enums as a fixed list of named constants. Red, Green, Blue. They are just labels with integers hiding underneath. If you are coming from TypeScript, discriminated unions are closer to what Rust does, but Rust bakes the concept deeper into the type system. Rust enums can carry data.
The simple version (looks familiar):
#![allow(unused)]
fn main() {
enum Direction {
Up,
Down,
Left,
Right,
}
let move_dir = Direction::Up;
}
The interesting version (each variant holds different data):
#![allow(unused)]
fn main() {
enum Message {
Quit, // No data
Text(String), // Holds a String
Move { x: i32, y: i32 }, // Holds named fields
Color(u8, u8, u8), // Holds a tuple
}
let msg = Message::Text(String::from("hello"));
let pos = Message::Move { x: 10, y: 20 };
}
You have already seen this. Option<T> from tip 3 is just an enum:
#![allow(unused)]
fn main() {
enum Option<T> {
Some(T),
None,
}
}
No compiler magic. No special syntax. Just a regular enum with a generic type. Result<T, E> works the same way.
Enums can have methods, just like structs. You use match (think switch, but it also extracts the data inside each variant. Tomorrow: match in depth) to handle them:
#![allow(unused)]
fn main() {
impl Message {
fn to_json(&self) -> String {
match self {
Message::Quit => String::from(r#"{"type":"quit"}"#),
Message::Text(s) => format!(r#"{{"type":"text","body":"{s}"}}"#),
Message::Move { x, y } => format!(r#"{{"type":"move","x":{x},"y":{y}}}"#),
Message::Color(r, g, b) => format!(r#"{{"type":"color","rgb":[{r},{g},{b}]}}"#),
}
}
}
}
In Java, you would reach for a sealed interface. In C#, an abstract class. Either way, one subclass per variant.
One thing to watch: Rust stores enums inline, not behind a pointer. The layout is a small discriminant tag (which variant?) plus enough space for the largest variant’s data. Think C++ std::variant: one contiguous block sized for the biggest case.
#![allow(unused)]
fn main() {
enum Data {
Small(u8), // 1 byte of data
Large([u8; 1024]), // 1024 bytes of data
}
// sizeof Data == 1025 bytes, 1 byte discriminant + 1024 for the largest variant, even when it holds Small(42)
}
The takeaway: Rust enums are not just labels. They are full tagged unions that can hold different data in each variant, and the compiler tracks which variant you have.
🔇 In most languages, if you want a type that can be “a string, a pair of coordinates, or nothing,” you reach for inheritance, interfaces, and a class per variant. In Rust, that is four lines inside an enum.
match -- The Switch Statement That Actually Works
🦀 Tip of the day 12: match – The Switch Statement That Actually Works
You saw match in tip 11 destructuring enum variants. But match goes much further than enums.
It works on any value with a known structure. Integers, tuples, strings, ranges:
#![allow(unused)]
fn main() {
let point = (3, -5);
match point {
(0, 0) => println!("origin"),
(x, 0) => println!("{x} on the x-axis"),
(0, y) => println!("{y} on the y-axis"),
(x, y) => println!("({x}, {y})"),
}
}
Patterns are checked top to bottom, first match wins. No fall-through. The compiler also rejects any match that does not cover every possible value. This means adding a new enum variant turns every unhandled match into a compile error, giving you a checklist of what needs updating.
Combine patterns with |, refine them with if guards, name values with @:
These three tools let you express complex conditions inside patterns without nesting if statements:
#![allow(unused)]
fn main() {
match status_code {
200 | 201 => println!("success"),
301 | 302 => println!("redirect"),
code if code >= 500 => println!("server error: {code}"),
_ => println!("other"),
}
}
code binds the value so you can use it in both the guard and the body. If you also need to test against a pattern, @ does both at once:
#![allow(unused)]
fn main() {
match score {
perfect @ 100 => println!("perfect score: {perfect}!"),
good @ 80..=99 => println!("{good} is solid"),
_ => println!("keep going"),
}
}
if let for when you only care about one case. Writing a full match with a _ => {} arm just to handle one variant is noisy. if let is the shorthand:
#![allow(unused)]
fn main() {
if let Some(value) = maybe_result {
println!("got {value}");
}
}
Quick boolean check with matches!. Sometimes you do not need the inner value, just whether something matches a pattern:
#![allow(unused)]
fn main() {
let is_success = matches!(status_code, 200 | 201);
let has_value = matches!(maybe_result, Some(_));
}
The takeaway: match checks patterns top to bottom, forces you to handle every case, and works on far more than enums. if let handles the single-pattern case, and matches! gives you a quick true/false.
🔇 Other languages let you add a new enum variant and find out you missed a case in production. Rust lets you find out before lunch. Bon app!
if/else Returns a Value, So Ternary Can Retire
🦀 Tip of the day 13: if/else Returns a Value, So Ternary Can Retire
If you are coming from Java, C++, or JavaScript, you have two separate tools: if/else for control flow, and the ternary operator (? :) for inline value selection. Rust merged them. In Rust, if is an expression. It evaluates to a value, just like match, just like the blocks you saw back in tip 4. That makes the ternary operator redundant, so Rust simply doesn’t have one.
The basics look familiar, minus the parentheses. Rust does not require parentheses around the condition, but the braces are mandatory, even for one-liners:
#![allow(unused)]
fn main() {
let temperature = 35;
if temperature > 30 {
println!("it's hot");
} else if temperature > 20 {
println!("nice out");
} else {
println!("bring a jacket");
}
}
Coming from C-style languages, you will forget the braces and add unnecessary parentheses for the first week. The compiler will gently nudge you on both.
Because if returns a value, you can bind the result directly to a variable:
#![allow(unused)]
fn main() {
let weather = if temperature > 30 { "hot" } else { "fine" };
}
In Java or C++ you would write temperature > 30 ? "hot" : "fine". In Rust, that syntax does not exist. You already have if. Both branches must return the same type, same rule as match arms.
No implicit truthiness. Unlike C++, Python, or JavaScript, Rust requires the condition to be bool. There is no if (0), if (""), or if (null). If you want to check whether a collection is empty, you write if list.is_empty(), not if !list. This removes an entire class of bugs where a value is accidentally falsy.
let else is the inverse of if let (tip 12). Instead of “if it matches, run this block,” it means “this must match, otherwise bail out”:
#![allow(unused)]
fn main() {
let Ok(config) = load_config() else {
return default_config();
};
// config is available here, no nesting
}
The else branch must diverge (return, break, continue, or panic). This keeps the happy path unindented.
Skipping else means the block returns (), Rust’s unit type. That is fine for side effects, but you cannot assign it to anything useful.
The takeaway: Rust’s if is an expression that returns a value, requires real bool conditions, and pairs with let else for clean early exits.
🔇 The ternary operator spent 50 years being the clever shortcut. Rust just made every if clever by default and quietly showed it the door.
Loops, Three Ways
🦀 Tip of the day 14: Loops, Three Ways
If you are coming from C++, Java, or JavaScript, you are used to for, while, and maybe do-while. Rust gives you three loop constructs too, but the lineup is different: loop, while, and for. No do-while, no C-style for(int i = 0; i < n; i++).
loop: the infinite loop that knows it is infinite.
Most languages spell this while(true). Rust has a dedicated keyword, and the real payoff is that loop can return a value through break:
#![allow(unused)]
fn main() {
let answer = loop {
let guess = get_input();
if guess == secret {
break guess; // this value becomes `answer`
}
};
}
You saw in tip 4 that Rust is expression-based. loop with break value fits right in. A while loop cannot do this because the compiler cannot guarantee the body ever runs, so the value might never be produced.
while and while let: conditional loops.
while works exactly as you expect. The interesting variant is while let, which loops as long as a pattern matches. Think of it as if let (tip 12) on repeat:
#![allow(unused)]
fn main() {
let mut stack = vec![1, 2, 3]; // vec! creates a growable list, like ArrayList or std::vector
while let Some(top) = stack.pop() {
println!("{top}"); // prints 3, 2, 1
}
}
Cleaner than a loop with a match inside and a break on None.
for: ranges and iterators.
There is no C-style for(init; cond; step). Rust uses ranges and iterators instead, which eliminate off-by-one errors and out-of-bounds access:
#![allow(unused)]
fn main() {
for i in 0..5 {
print!("{i} "); // 0 1 2 3 4 (use 0..=5 for inclusive)
}
let names = vec!["Alice", "Bob", "Carol"];
for name in &names { // & to borrow; No, we aren't ready to have this talk yet...
println!("{name}");
}
}
Python’s for x in range(5) is the closest equivalent. Java and C++ developers will miss the classic three-part for, but range-based loops turn out to be harder to get wrong. We will dig into iterators in the next tip.
Loop labels let you control nested loops.
break and continue work as expected, but in nested loops they only affect the innermost one. Labels let you target the outer loop:
#![allow(unused)]
fn main() {
'outer: for x in 0..5 {
for y in 0..5 {
if x + y == 6 {
break 'outer;
}
}
}
}
Labels work with all three loop types, and with continue too:
#![allow(unused)]
fn main() {
'rows: for row in &matrix {
for cell in row {
if *cell < 0 {
continue 'rows; // skip the rest of this row, move to the next
}
process(cell);
}
}
}
You can also combine labels with break value in a loop:
#![allow(unused)]
fn main() {
let result = 'search: loop {
for item in &candidates {
if item.is_valid() {
break 'search item.clone(); // return a value from the labeled loop
}
}
refresh_candidates();
};
}
Java, Kotlin, and Go have labeled breaks as well. In Python, you need a flag variable or a helper function to break out of nested loops.
The takeaway: Rust gives you loop for intentional infinite loops (with the bonus of returning values), while let for pattern-based repetition, and iterator-based for that replaces C-style counting with something safer.
🔇 C++ has goto for when you need to jump anywhere, and no labeled break for when you need to jump somewhere specific. One can only dream.
Iterators, Part 1
🦀 Tip of the day 15: Iterators, Part 1
In tip 14, you saw for item in collection as Rust’s way of looping. But that is only the surface. Under the hood, Rust has a full iterator system that will feel familiar if you have used Java Streams, Python list comprehensions, C# LINQ, or JavaScript’s .map().filter() chain. The difference: Rust iterators are lazy and zero-cost.
Quick detour: closures.
Iterator methods take closures as arguments. A closure is an inline function, what other languages call lambdas or arrow functions. Where Python writes lambda x: x * 2, JavaScript writes (x) => x * 2, and Java writes x -> x * 2, Rust writes:
#![allow(unused)]
fn main() {
|x| x * 2
|x, y| x + y
|name| name.len() > 3
}
Pipes instead of arrows or keywords. Closures can capture variables from their surroundings, but that is a topic for another tip. For now, just read |n| n % 2 == 0 as “a function that takes n and returns whether n is even.”
Lazy means nothing happens until you consume.
When you write .filter().map(), Rust builds a recipe. No elements are processed yet. Work only starts when you consume the iterator with collect(), sum(), count(), or a for loop. Java Streams and C# LINQ are lazy the same way. JavaScript is not: array.map().filter() creates a brand new array at every step in the chain, even if you only need the first match.
The chaining pattern:
#![allow(unused)]
fn main() {
let evens: Vec<i32> = (1..=10)
.filter(|n| n % 2 == 0)
.collect();
// [2, 4, 6, 8, 10]
}
In Python, this is [n for n in range(1, 11) if n % 2 == 0]. In Java, IntStream.rangeClosed(1, 10).filter(n -> n % 2 == 0).boxed().collect(Collectors.toList()). Same idea, different verbosity.
collect() and the turbofish.
collect() is how you materialize an iterator into a real collection. It can produce a Vec, a HashSet, a String, and more, so the compiler needs to know which one. You can annotate the variable:
#![allow(unused)]
fn main() {
let evens: Vec<i32> = (1..=10).filter(|n| n % 2 == 0).collect();
}
Or use the turbofish:
#![allow(unused)]
fn main() {
let evens = (1..=10).filter(|n| n % 2 == 0).collect::<Vec<_>>();
}
See the ::<>? That is the turbofish. It is how you pass a type argument to a function or method call. The _ tells the compiler “figure out the element type yourself, just give me a Vec.” You will see ::<> in other contexts too, whenever Rust needs a type hint on a generic call.
Three ways to iterate a collection.
You have a collection and you want to loop over it. Rust offers three choices depending on what you need:
.iter()reads each element. The collection is still there afterwards..iter_mut()lets you modify each element in place. The collection survives, but contents may change..into_iter()takes each element out. The collection is consumed and gone.
#![allow(unused)]
fn main() {
let names = vec!["Alice", "Bob", "Carol"];
// .iter(): read, collection survives
for name in names.iter() {
println!("{name}");
}
println!("still have {} names", names.len()); // works fine
// .into_iter(): consume, collection is gone
for name in names.into_iter() {
println!("{name}");
}
// names.len(); // ERROR: names has been consumed
}
#![allow(unused)]
fn main() {
let mut words = vec![String::from("hello"), String::from("world")];
// .iter_mut(): modify in place
for word in words.iter_mut() {
word.push_str("!");
}
// words is now ["hello!", "world!"]
}
Shorthand you already know: for name in &names borrows, for name in names consumes. Under the hood, the for loop always calls .into_iter(). The difference is what you pass it: &names passes a reference, so .into_iter() on a reference just reads. names passes the collection itself, so it gets consumed. This is consistent with how the rest of the language works: passing a value moves it, passing & does not. The loop follows the same rules.
When chaining methods like .filter() and .map(), you call .iter() or .into_iter() explicitly.
The takeaway: Rust iterators are lazy pipelines built from closures. Choose .iter(), .iter_mut(), or .into_iter() based on whether you want to read, modify, or consume your collection. There are dozens of useful iterator methods (enumerate, zip, find, sum, and more), but you can discover those as you need them.
🔇 Rust lets you write elegant iterator chains and then compiles them into the same tight loop a C programmer would write after three cups of coffee and a debugging session. You get the readability. The CPU gets the performance. Everyone wins except the C programmer.
Exceptions, the Feature You'll Wish Your Language Didn't Have
🦀 Tip of the day 16: Exceptions, the Feature You’ll Wish Your Language Didn’t Have
If you are coming from Java, Python, C++, or JavaScript, you are used to throwing and catching exceptions. Something goes wrong, you throw, and somewhere up the call stack a try/catch block deals with it. Maybe. Hopefully. Rust does not have exceptions. No throwing, no catching, no exception to the rule. Errors are just values you return.
Result is just an enum. You already know enums from tip 11:
#![allow(unused)]
fn main() {
enum Result<T, E> {
Ok(T), // Success, here's the value
Err(E), // Failure, here's what went wrong
}
}
Just like Option wraps “value or nothing,” Result wraps “value or error.” The compiler forces you to handle both cases.
The verbose way (you already know this from tip 12):
#![allow(unused)]
fn main() {
fn read_config() -> Result<String, std::io::Error> {
let content = std::fs::read_to_string("config.toml");
match content {
Ok(text) => Ok(text),
Err(e) => Err(e),
}
}
}
That works, but you are just receiving a Result and forwarding it. If you have three operations that can fail, you end up with three nested match blocks. This is basically Go’s if err != nil { return err } pattern, except even more verbose.
The ? operator fixes this. Placing ? after an expression that returns Result does exactly what the match above does: if it is Ok, unwrap the value and continue. If it is Err, return the error from the current function immediately.
#![allow(unused)]
fn main() {
fn read_config() -> Result<String, std::io::Error> {
let content = std::fs::read_to_string("config.toml")?;
// If we get here, content is a String, not a Result
Ok(content)
}
}
Chain multiple fallible operations and the code stays flat:
#![allow(unused)]
fn main() {
fn load_user(path: &str) -> Result<User, std::io::Error> {
let text = std::fs::read_to_string(path)?; // fails? return early
let user = parse_user(&text)?; // fails? return early
Ok(user)
}
}
? only works in functions that return Result (or Option). This is not a limitation, it is the whole point. If your function signature returns Result, anyone calling it can see that it might fail. The error path is right there in the type. No surprises.
When you just want the value: unwrap() and expect().
Sometimes you know a Result should be Ok, or you are prototyping and don’t want to handle errors properly yet. unwrap() extracts the value, but if the Result is Err, your program panics and crashes:
#![allow(unused)]
fn main() {
let text = std::fs::read_to_string("config.toml").unwrap();
// If the file is missing:
// thread 'main' panicked at src/main.rs:2:51:
// called `Result::unwrap()` on an `Err` value: Os { code: 2, ... }
}
That tells you what went wrong and, since Rust 1.65, the file and line it happened on, but not why you expected the value to be there. expect() lets you add that context:
#![allow(unused)]
fn main() {
let text = std::fs::read_to_string("config.toml")
.expect("failed to read config file");
// If the file is missing:
// thread 'main' panicked at src/main.rs:2:6:
// failed to read config file: Os { code: 2, ... }
}
Both are fine in tests, examples, and throwaway scripts. In production code, propagate with ? instead.
If you want a fallback instead of crashing, unwrap_or provides a default value:
#![allow(unused)]
fn main() {
let port: u16 = port_string.parse().unwrap_or(8080); // bad input? use 8080
}
How other languages compare:
- Java checked exceptions put errors in the signature, which is the right idea. In practice, the boilerplate pushed many libraries and most modern frameworks toward unchecked exceptions, which weakens the guarantee.
- C++ tried exception specifications (
throw(Type)in C++98) and removed them in C++17 because they were broken in practice. C++11 addednoexcept, which only tells you a function won’t throw, not what it might throw. So C++ can mark the safe path but not the error path. - Python and JS/TS use unchecked exceptions only. You cannot tell from a function signature whether it might throw. You just have to know, or read the docs, or find out in production.
- C# deliberately rejected checked exceptions. Anders Hejlsberg, C#’s lead designer, identified two problems with Java’s design: “scalability and versionability.” So C# kept only unchecked exceptions, with the same signature blindness as Python and JS, but by design rather than default.
- TypeScript later named the deeper reason. Ryan Cavanaugh, TypeScript’s lead, closing the proposal for a throws keyword in 2023, put it bluntly: “Effectively, exceptions (and checked exceptions doubly so) are a workaround for a lack of union return types in a language.” Twenty years after Hejlsberg rejected checked exceptions in C#, his own team articulated why: exceptions exist because older languages could not express
T | Ein a return type. Which is to say, the feature you wish your language didn’t have. - Go gets the explicitness right with
if err != nil { return err }, but you write that boilerplate on nearly every line. Rust’s?gives you the same visibility with far less noise.
The takeaway: Rust replaces invisible exception-based control flow with Result, a simple enum that makes errors part of the type system, and ?, a one-character shorthand that keeps the code clean. No exceptions, no exception-makers, just values.
🔇 Other languages: “Errors should be exceptional!” Also other languages: throws an exception because a file didn’t exist, which is arguably the most expected thing a file can do.
Iterators, Part 2
🦀 Tip of the day 17: Iterators, Part 2
Part 1 gave you the engine. Part 2 hands you the toolbox. The standard library ships dozens of iterator methods, and the ones below cover most of what you will ever reach for. Skim them once. Future-you will remember “there was a method for that” and grep for it.
map and filter, with a useful guest.
You met these in part 1. Here they are working together, with inspect joining the chain:
#![allow(unused)]
fn main() {
let names = vec!["Alice", "Bob", "Carol", "Dave"];
let short_upper: Vec<String> = names
.iter()
.inspect(|n| println!("checking {n}")) // peek without disturbing the chain
.filter(|n| n.len() <= 4)
.map(|n| n.to_uppercase())
.collect();
// ["BOB", "DAVE"]
}
inspect is the debugging escape hatch: it lets you print or log values mid-pipeline without changing them. Drop it in, find your bug, delete it. Order matters too: filter before map, otherwise you transform elements you are about to throw away.
enumerate: index plus value.
#![allow(unused)]
fn main() {
let colors = vec!["red", "green", "blue"];
for (i, color) in colors.iter().enumerate() {
println!("{i}: {color}");
}
}
Python: for i, c in enumerate(colors). Java: no direct equivalent, you reach for a manual counter or IntStream.range(). Small detail with a reason: i is a usize, the type Rust requires for indexing. If it were u32 you would need vec[i as usize]. With enumerate, no cast needed.
find, any, all: searching and testing.
#![allow(unused)]
fn main() {
let names = vec!["Alice", "Bob", "Carol"];
let found = names.iter().find(|n| n.starts_with("C")); // Some("Carol")
let has_short = names.iter().any(|n| n.len() <= 3); // true
let all_short = names.iter().all(|n| n.len() <= 5); // true
}
All three short-circuit. any stops at the first match, all stops at the first failure, find returns as soon as it has something. The lazy pipeline means you get this for free, no break needed.
zip: pair two iterators.
#![allow(unused)]
fn main() {
let names = vec!["Alice", "Bob", "Carol"];
let scores = vec![85, 92, 78];
for (name, score) in names.iter().zip(scores.iter()) {
println!("{name}: {score}");
}
}
Python: for name, score in zip(names, scores). Nearly identical. If the iterators have different lengths, zip quietly stops at the shorter one. No panic, no error. The reason: iterators describe what they can produce, not what they must. If one runs dry, the chain just ends.
Infinite iterators are real, and useful.
#![allow(unused)]
fn main() {
let first_five_evens: Vec<i32> = (1..) // 1, 2, 3, ... no upper bound
.filter(|n| n % 2 == 0)
.take(5)
.collect();
// [2, 4, 6, 8, 10]
}
(1..) is a range with no upper bound. Eagerly walking it would run until the integer type overflows (over two billion steps for i32), so in practice you’d be waiting longer than the program would tolerate. But iterators are lazy: nothing runs until something consumes. take(5) says “give me five and stop,” and the pipeline stops at the fifth value, never asking the range for a sixth. Python’s itertools.count, Java’s Stream.iterate, and JavaScript generators can all express the same idea, but each is a dedicated API you call into. In Rust, (1..) is just a value: the same .. syntax you already use, minus the upper bound.
take, skip, chain, step_by: slice and combine.
#![allow(unused)]
fn main() {
let first_two: Vec<i32> = (1..=10).take(2).collect(); // [1, 2]
let every_third: Vec<i32> = (1..=10).step_by(3).collect(); // [1, 4, 7, 10]
let glued: Vec<i32> = (1..=3).chain(7..=9).collect(); // [1, 2, 3, 7, 8, 9]
}
sum, count, min, max, and the godfather: fold.
#![allow(unused)]
fn main() {
let total: i32 = (1..=10).sum(); // 55
let evens = (1..=10).filter(|n| n % 2 == 0).count(); // 5
let product: i32 = (1..=5).fold(1, |acc, n| acc * n); // 120
}
fold is the parent of every reducer. sum is fold(0, +). count is fold(0, |a, _| a + 1). When no built-in does quite what you want, fold is the universal fallback. Note the : i32 on total: sum works for any numeric type, so the compiler insists you pick one. Mildly annoying, then forgettable.
Putting it together:
#![allow(unused)]
fn main() {
let sum: i32 = (1..=10)
.filter(|n| n % 2 == 0)
.map(|n| n * n)
.sum();
// 220 (4 + 16 + 36 + 64 + 100)
}
Reads like English: take 1 through 10, keep the evens, square them, add them up. No mutable state, no off-by-one risk, no temporary Vec allocated between each step.
The takeaway: Rust’s iterator toolkit covers transforming, filtering, searching, combining, and reducing. Most of what you used to write as a for loop with mutable state has a method named after it, the chain reads top-to-bottom like a sentence, and the compiler turns it into the same machine code anyway.
🔇 You write (1..).filter(...).take(5) and briefly feel like a functional wizard. Enjoy the spell, it is on borrowed time. Seventeen tips of dodging ownership, and next week the borrow checker comes to collect. Unlike our iterators, it is not lazy.
Ownership, the Rule That Replaces the Garbage Collector
🦀 Tip of the day 18: Ownership, the Rule That Replaces the Garbage Collector
If you are coming from Java, Python, C#, JS, or Go, you barely think about memory. You create something, use it, and a garbage collector frees it later. From C, every malloc needs a free. Forget once and you leak. Do it twice and you crash.
Rust takes a third path. There is no garbage collector, and you almost never write free. The compiler tracks who is responsible for each value and runs cleanup automatically when that party goes away. The system is called ownership, the most distinctive feature in Rust. We will spread it across five tips. This first one is the basic rule and the precise definition of scope it depends on.
Two definitions first.
A value is whatever a variable holds. It might live on the stack (an integer), partly on the stack and partly on the heap (a Vec, where the pointer, length, and capacity sit on the stack while the buffer of elements sits on the heap), or in static memory (constants baked into the binary). Ownership does not care where the bytes physically sit. It cares about who is responsible for cleaning them up.
What “cleanup” actually does depends on the type, not on where the value lives. An i32 has no destructor, so dropping it is a no-op. A Vec has a destructor that frees its heap buffer. A File has one that closes the underlying descriptor. Ownership runs the same rule on all of them: drop at end of scope, and the type decides what work that drop actually does.
A scope is the region of code in which a variable name is valid. The simplest scope is a block, { ... }. A variable declared inside a block is invisible outside it.
fn main() {
let v = vec![1, 2, 3]; // v comes into scope here
println!("length: {}", v.len());
} // v goes out of scope here, value is dropped
That closing brace is a real moment. When control reaches it, Rust runs the cleanup code for v and frees the heap buffer. The cleanup point is determined statically by where the brace is, not by a collector that wakes up later. No free, no delete, no GC pause.
Now the rule, in its simplest form: every value has exactly one owner, and when the owner goes out of scope, the value is dropped.
The “owner” is the variable. “Goes out of scope” means control crosses the closing brace of its declaring block. “Dropped” means the cleanup code for that type runs. The mechanism behind this is a trait called Drop. Library types like Vec already implement Drop for you; you only write your own when a type has custom cleanup, like releasing a non-Rust resource.
This single rule prevents three classes of bug at once: leaks (cleanup always happens), double-frees (it happens exactly once), and use-after-free (the value is gone after drop). Microsoft reported in 2019 that roughly 70% of the CVEs they assign each year are memory safety bugs, and the figure has held for over a decade. Chrome, Firefox, and the Linux kernel tell the same story. GC languages avoid these bugs by taking deallocation away from the programmer, and the convenience is real. The costs are also real, and not just timing: the collector spends CPU on bookkeeping, the heap typically holds well more memory than the live set so the collector has room to work, write barriers slow pointer assignments, and it only manages memory (files, locks, and sockets still need explicit cleanup). Pause times vary widely. Go’s collector is engineered for sub-millisecond pauses by default; Java’s default G1GC trades pause time for throughput and runs in the tens to hundreds of milliseconds, while opt-in collectors bring it down further: ZGC reaches sub-millisecond pauses regardless of heap size, and Shenandoah typically stays in the low single-digit milliseconds. Short is not zero in any of them. Rust avoids the bugs at compile time and pays nothing at runtime for any of this, asking you to think about ownership upfront in exchange.
C++ programmers will recognize the cleanup-at-scope-end behavior as RAII, the pattern smart pointers like unique_ptr use. Rust takes the same idea and applies it to every value as a language rule, not as a library convention you have to remember to follow.
One scope subtlety the compiler treats carefully: values not bound to a variable, called temporaries, are dropped at the end of the statement that produced them, not the end of the block.
fn main() {
let len = vec![1, 2, 3].len(); // the temporary Vec is dropped at the ';'
println!("{}", len); // Vec is gone, len lives on
}
The vec![1, 2, 3] here exists just long enough for .len() to read it, then is dropped at the semicolon. The integer len lives until the end of main. You do not usually have to think about this, but it is the first hint that the compiler’s view of scope is more refined than the lexical block. More on that later this week.
Brief comparison:
- Java, Python, C#, JS: garbage collection. Reliable for memory, with CPU and memory overhead. Pause times depend heavily on the collector chosen. Non-memory resources (files, locks, sockets) need explicit cleanup.
- C: manual
malloc/free. Deterministic, easy to forget. - C++11 smart pointers (
unique_ptr,shared_ptr): RAII as a library convention.unique_ptrmodels single ownership, but raw pointers, manualnew/delete, and aliasing references still compile alongside, and use-after-move is uncaught. - Go: tracing GC tuned for low latency, with typical pauses well under a millisecond, plus
deferfor non-memory cleanup. No ownership tracking, no compile-time aliasing rules. Data races are possible at runtime; the race detector catches some on tested paths. - Rust: ownership as a language rule, enforced at compile time, with zero runtime cost.
The takeaway: every value has exactly one owner, and when the owner goes out of scope the value is dropped. One rule replaces both garbage collection and manual free, with cleanup at compile-time-known points.
🔇 Other languages let you forget about memory, performance, and eventually what a fast program even feels like. C++ lets you remember and shoot yourself in the foot, free of charge, and sometimes twice. Rust insists you remember, then handles it for you anyway.
Move Semantics, or Why Assignment Looks Different in Rust
🦀 Tip of the day 19: Move Semantics, or Why Assignment Looks Different in Rust
In tip 18 we said every value has exactly one owner. That sentence has consequences the moment you assign one variable to another.
In Python or Java, b = a makes b point to the same object as a. They are aliases for one piece of data, and either name can mutate it. In C++, b = a calls the copy constructor or copy assignment operator, and the convention is a deep copy: two independent objects. In Go, the answer depends on the type. None of these languages can stop you from writing through one alias while iterating through another.
In Rust, let b = a moves the value from a to b. After the move, a is no longer valid. The compiler refuses to let you use it.
#![allow(unused)]
fn main() {
let a = vec![1, 2, 3];
let b = a; // ownership moves from a to b
println!("{}", a[0]); // ERROR: borrow of moved value: `a`
}
The error is unmissable: error[E0382]: borrow of moved value, with help text pointing to the line where the move happened. Rust does not silently alias, and it does not silently copy. Every assignment is one or the other, and the compiler tells you which.
Why move? It is the consequence of the one-owner rule. If a and b both owned the Vec, there would be two owners, and cleanup would run twice (a double free). Rust’s solution is the simplest possible: ownership is transferred, the source variable becomes invalid, and exactly one cleanup happens.
Function arguments behave the same way. Passing a value into a function moves it:
#![allow(unused)]
fn main() {
fn consume(v: Vec<i32>) {
println!("{}", v.len());
}
let a = vec![1, 2, 3];
consume(a);
println!("{}", a.len()); // ERROR: a was moved into consume
}
The function now owns the value, so the caller cannot use a after the call. This may feel restrictive, but it makes ownership transfer visible at every call site. Most functions you write will not want ownership; next tip covers how to read a value without taking it.
The exception: types that implement the Copy trait. Small, fixed-size, plain types like i32, bool, char, f64, and tuples of Copy types are copied on assignment instead of moved.
#![allow(unused)]
fn main() {
let x = 42;
let y = x; // x is copied, not moved
println!("{}", x); // works, x is still valid
}
Why are these special? Copying a stack value like an i32 is cheap (just bit duplication) and there is no heap allocation to worry about. Move semantics would buy you nothing and add friction. Owned heap-managing types (Vec, Box, HashMap) are not Copy. Move is the default, and it is the safer default.
When you actually want a duplicate of an owning value, you ask explicitly:
#![allow(unused)]
fn main() {
let a = vec![1, 2, 3];
let b = a.clone(); // explicit deep copy, both a and b are now valid
}
clone() is always explicit. The cost of a deep copy is visible in the source, not hidden behind an =.
Comparison with other languages:
- Python, Java, JS, C#: assignment creates an alias. No copy, no move, just two names for one object. Cheap, but two writers can race or invalidate iterators.
- Go: assignment copies for value types, aliases for slices, maps, and pointers. Implicit, depends on the type.
- C++: assignment copies by default. Move only happens when you write
std::move(x)and the type has a move constructor. After a move, the source is in a “valid but unspecified state,” and reading it is allowed. The compiler does not stop you, and what you get back is whatever the type’s move chose to leave behind, usually an empty husk. - Rust: assignment moves owning types, copies
Copytypes. The compiler tracks moved values and refuses any further use.
The one-owner rule plus move semantics is what makes deterministic cleanup possible. You always know who owns each value, because moves explicitly transfer ownership at every assignment.
The takeaway: assignment in Rust is either a move (ownership transfers, source invalidated) or a copy (cheap stack duplicate). There are no implicit aliases, and the compiler tells you which one happened on every line.
🔇 In Java, mutating a list mid-iteration throws ConcurrentModificationException. In Rust it does not compile. Somehow Java is still the mature, “enterprise” language. That is what marketing, comfort zones, and “nobody got fired for picking Java” will buy you. The compiler had no input.
Borrowing, or Reading Without Owning
🦀 Tip of the day 20: Borrowing, or Reading Without Owning
After tip 19 you might wonder: if assigning a Vec moves it, how do I write a function that just reads one without taking ownership? Cloning works but copies the data. The answer is borrowing.
A reference is a non-owning pointer to a value. You write &value to create one and &Type to take one as a parameter:
#![allow(unused)]
fn main() {
fn first(v: &Vec<i32>) -> i32 {
v[0]
}
let a = vec![1, 2, 3];
let n = first(&a); // we lend a to the function, we do not give it
println!("{}", a.len()); // a is still valid, we still own it
}
The function signature &Vec<i32> says “I accept a reference; I will not take ownership.” When you call first(&a), you create a reference and lend it for the duration of the call. The function reads through the reference, returns, and the reference goes away. Your variable a was never moved.
This is the key shift: ownership and access are different things. The owner is responsible for cleanup. A borrower can read but is not responsible. Most functions in Rust do not need ownership of their arguments, just the ability to read them. So most function signatures take references.
You can have many shared references at once:
#![allow(unused)]
fn main() {
let a = vec![1, 2, 3];
let r1 = &a;
let r2 = &a;
let r3 = &a;
println!("{} {} {}", r1[0], r2[0], r3[0]); // all valid, all reading
}
These are read-only borrows. None of them can modify the Vec. Multiple readers are safe because no one is changing anything underneath. And while any shared borrow is alive, even the owner cannot mutate the value, otherwise readers could see the data shift underneath them.
The compiler tracks how long each borrow is actually used, not just where it was declared. If a reference is created and never used past a certain line, the compiler treats the borrow as having ended at that line, even if the lexical block continues. This feature is called non-lexical lifetimes, and it is what makes ordinary code work without ceremony:
#![allow(unused)]
fn main() {
let mut a = vec![1, 2, 3];
let r = &a;
println!("{}", r[0]); // last use of r, the borrow ends here
a.push(4); // works, no borrow is alive anymore
}
The reference r is in lexical scope until the end of the function, but the compiler sees that r is never used after the println!. The borrow is treated as ended, and we are free to mutate a again.
Now the obvious question: what if the owner goes out of scope while a reference to it is still alive? In C++ that compiles and hands you a dangling pointer. In Rust the compiler refuses:
#![allow(unused)]
fn main() {
let r;
{
let a = vec![1, 2, 3];
r = &a; // ERROR: `a` does not live long enough
} // a is dropped here
println!("{}", r[0]); // r would point into freed memory
}
The compiler tracks how long the borrowed value is alive and how long the reference needs to be alive, and rejects any program where the reference would outlive its target. The mechanism is called lifetimes, and we will expand on it in a later tip. For now, trust that the compiler will not let a reference dangle.
What can you do through a &T?
- Read fields and call read-only methods on the value.
- Pass it to other functions that accept
&T. - Create more shared references from it.
What you cannot do:
- Modify the value (that needs
&mut T, coming in tip 21). - Move out of the borrow (you do not own it).
The takeaway: &T lets you read a value without taking ownership. You can have as many shared references as you want, the compiler tracks how long each is actually used, and it refuses any reference that would outlive its target.
🔇 Feeling overwhelmed? Tempted to crawl back to a GC language? Stop, life is too short for GC pauses. You do not have to remember any of this; the compiler is keeping the list, and will read it back to you, line by line.
Follow-up
🦀 Tip of the day 20 follow-up
Follow-up questions from the discussion on Tip 20: Borrowing, or Reading Without Owning.
Does a value’s Drop cleanup run at the end of the lexical scope, or at the end of the non-lexical lifetime the borrow checker computes?
The tip mentioned two separate behaviors. Non-lexical lifetimes (NLL) let the compiler treat a borrow as ended at its last use, even when the lexical block continues. And types can implement Drop, a cleanup function the runtime calls automatically when a value goes away. So which clock does Drop run on, the lexical one or the non-lexical one?
Drop runs at the end of the lexical scope. NLL has no effect on it.
The two features are decoupled because they answer different questions:
- NLL is a compile-time question: “is this reference still observed past this point?” The borrow checker uses the answer to decide whether a conflicting operation, like a mutation, is allowed.
Dropis a runtime event tied to ownership: “who owns this value, and when does that ownership end?”
A reference going dead does not mean the referent disappears. The owner still owns it. The value is only destroyed when the owner’s storage goes away, which happens at one of these points:
- the end of the enclosing block,
- a move out of the binding,
- a reassignment (the old value is dropped on the assignment line),
- an explicit
drop(x)call.
NLL is not on that list, because it is about reference liveness, not ownership.
There is also a design reason to keep Drop lexical. Destructors can have observable side effects: closing files, releasing locks, freeing memory. If they fired at points the borrow checker chose, the exact line a destructor ran on would depend on a subtle compile-time dataflow analysis that can shift as you edit unrelated code. Lexical scope is something you can see by looking at the braces.
It is easy to convince yourself of the wrong answer with an experiment, so watch the setup. Here is the canonical NLL example from RFC 2094, the proposal that introduced non-lexical lifetimes:
#![allow(unused)]
fn main() {
fn bar() {
let mut data = vec!['a', 'b', 'c'];
let slice = &mut data[..]; // <-+ borrow starts
capitalize(slice); // | last use of slice
data.push('d'); // | OK under NLL
data.push('e'); // |
data.push('f'); // |
}
}
Before NLL this did not compile: the borrow slice was considered alive until the closing brace, so the push calls conflicted with it. NLL ends the borrow at the call to capitalize, and the code compiles. But notice what ended early: the lifetime of the &mut reference, not the lifetime of any owned value. Nothing was dropped here, because slice is just a reference.
Now suppose you wrap that reference in a struct with a Drop impl to watch when cleanup fires:
struct MutReference<'a, T> {
inner: &'a mut [T],
}
impl<'a, T> Drop for MutReference<'a, T> {
fn drop(&mut self) {
println!("dropped");
}
}
fn capitalize(data: MutReference<char>) {
for c in data.inner.iter_mut() {
*c = c.to_ascii_uppercase();
}
println!("end of capitalize");
}
fn main() {
let mut data = vec!['a', 'b', 'c'];
let slice = MutReference { inner: &mut data[..] };
capitalize(slice);
println!("after capitalize");
data.push('d');
data.push('e');
data.push('f');
println!("{:?}", data);
}
This prints:
end of capitalize
dropped
after capitalize
['A', 'B', 'C', 'd', 'e', 'f']
It looks like the drop happened “early,” before after capitalize, which might suggest NLL drove it. It did not. By passing slice by value into capitalize, you moved ownership of the MutReference into the function. The value’s lexical scope is now the body of capitalize, so it is dropped at the end of that body. That is the move rule from the list above, not NLL. The experiment changed where the value lives; it did not change the rule for when Drop runs.
The takeaway: borrows end at their last use, but owned values are destroyed at the end of their lexical scope (or when moved, reassigned, or explicitly dropped). NLL makes the borrow checker smarter about references; it never moves a destructor.
Mutable Borrowing and the XOR Rule
🦀 Tip of the day 21: Mutable Borrowing and the XOR Rule
Shared references (&T, tip 20) are read-only. To modify a value through a reference, you need a mutable reference, written &mut T. That comes with the strictest rule in the language.
Here is mutable borrowing:
#![allow(unused)]
fn main() {
fn append_zero(v: &mut Vec<i32>) {
v.push(0);
}
let mut a = vec![1, 2, 3];
append_zero(&mut a);
println!("{:?}", a); // [1, 2, 3, 0]
}
Three things are required, all visible. The variable a is mut. The call site uses &mut a. The function signature accepts &mut Vec<i32>. Rust never lets mutation sneak in through one of these alone.
Now the rule: at any given time, you can have either one mutable reference, or any number of shared references, but never both. Many readers OR one writer. Never two writers, never a writer alongside readers. This is sometimes called the XOR rule of borrowing.
#![allow(unused)]
fn main() {
let mut a = vec![1, 2, 3];
let r1 = &a;
let r2 = &a;
// let m = &mut a; // ERROR: cannot borrow as mutable, already borrowed as immutable
println!("{} {}", r1[0], r2[0]);
let m = &mut a; // OK now: r1 and r2 are no longer used
m.push(4);
// let r3 = &a; // ERROR: cannot borrow as immutable, already borrowed as mutable
// a.push(99); // ERROR: even a itself cannot access while m is alive
m.push(5);
}
Worth pausing on that last error: the “one writer” in the XOR rule includes the owner. While &mut a exists, even a itself cannot read or mutate the value. Exclusive access is fully transferred to the borrower until the borrow ends, so there are never two parties writing at once, just one party at a time holding the right to write.
Why this rule? It eliminates two huge categories of bugs at compile time, and as a bonus unlocks optimizations.
First, data races. A data race happens when two threads access the same memory and at least one is writing, with no synchronisation. The XOR rule, applied across thread boundaries, makes data races impossible in safe Rust. There is no way to share a writable pointer with multiple threads without going through synchronisation primitives that respect the rule.
Second, iterator invalidation. Modify a collection while iterating it, and other languages give you a range of bad outcomes. C++ treats it as undefined behavior. Python silently skips or repeats elements in a list. Java throws ConcurrentModificationException at runtime, only on tested paths. Rust forbids it at compile time: the iterator holds a borrow of the collection, and the compiler refuses to let you mutate the collection while that borrow is alive.
#![allow(unused)]
fn main() {
let mut a = vec![1, 2, 3];
for x in &a { // borrow of a starts
if *x == 1 {
// a.push(99); // ERROR: cannot mutate a while borrowed
}
} // borrow of a ends
a.push(99); // OK now
}
Third, better optimizations. Because &mut T is guaranteed not to alias any other access to the same value, the compiler can keep data in registers across writes, skip reloads, and reorder operations more aggressively. C and C++ compilers cannot do this in general because pointers may alias; the restrict keyword exists in C to opt in, and most code does not bother. The XOR rule is not just a safety constraint, it is information the optimizer would otherwise have to assume away.
Now the tradeoff, which is mostly about this rule. The XOR rule is the source of most “fighting the borrow checker” stories, especially when learning. Some patterns natural in other languages become genuinely awkward to express in safe Rust:
- Doubly-linked lists, where each node would need mutable pointers to its neighbours.
- Graphs with shared mutable nodes.
- Observer patterns where many objects hold mutable references to one shared subject.
- Self-referential structs.
In practice the impact is smaller than internet discussion suggests. Doubly-linked lists are in the standard library (std::collections::LinkedList), implemented once with unsafe so application code never has to. They are also rarely the right structure: Vec<T> beats them on most workloads thanks to cache locality. Graphs and self-referential structs do come up, and for those the standard library provides escape valves like Rc and Arc (covered in later tips) at a small runtime cost. For most code you will not reach for them. For graph-like code you sometimes have to, and that is the friction Rust costs you.
In return, the compiler eliminates an entire class of concurrency bugs that other languages catch with luck, race detectors, or production incidents.
The takeaway: &mut T is an exclusive reference, and the XOR rule (many readers OR one writer) is what eliminates data races and iterator invalidation at compile time. The cost is friction with patterns that depend on shared mutable state, which Rust pushes you to express through explicit synchronisation.
🔇 Want to mutate? Sign here, here, and here.
Lifetimes, or Why a Reference Cannot Outlive Its Referent
🦀 Tip of the day 22: Lifetimes, or Why a Reference Cannot Outlive Its Referent
Borrowing (tips 20 and 21) lets you read or mutate a value without owning it. But what if the borrow outlives the data it points to? In C++, this is the classic dangling reference: a function returns a pointer to a local, the function exits, the local is destroyed, and the caller now holds a pointer into freed memory. Compiles fine. Crashes later, somewhere unrelated.
Rust refuses to let this happen. For every reference, the compiler tracks how long the data it points to is actually alive, and refuses to let the reference escape past it. The mechanism for expressing this is lifetimes.
A lifetime is a label, written 'a (read “tick a”), that names a region during which a reference is valid. The name 'a is convention, not syntax. Any identifier works ('src, 'input). Two reserved names: 'static (lives the whole program) and '_ (placeholder, “infer this one”).
Most code never writes lifetimes. Take a real string-parsing function, extracting the first word:
#![allow(unused)]
fn main() {
fn first_word(s: &str) -> &str {
let end = s.find(' ').unwrap_or(s.len());
&s[..end]
}
}
The output is a slice into the input. The compiler knows the result borrows from s, so it cannot outlive s. You wrote no annotations because the relationship is unambiguous: one input reference, one output reference. This automatic inference is called lifetime elision, and it covers the vast majority of code.
The realistic case where you do write lifetimes is structs that hold references. Picture parsing a CSV line into a view that does not allocate any of its columns:
#![allow(unused)]
fn main() {
struct Row<'a> {
name: &'a str,
email: &'a str,
}
fn parse(line: &str) -> Row<'_> {
let mut parts = line.split(',');
Row {
name: parts.next().unwrap(),
email: parts.next().unwrap(),
}
}
}
Row<'a> says: a Row borrows from a string with lifetime 'a, and the Row cannot outlive that string. You get a parsed view with zero allocation, and the compiler ensures the original line stays alive as long as the Row is used. Drop line while a Row still points into it, and your code does not compile. This pattern is everywhere in real Rust: parsers, log readers, cursor types, anything that wants to inspect a buffer without copying. Without lifetimes on the struct, the language could not safely allow it.
Both fields share 'a here because both columns come from the same line. If the two fields came from different sources, you would declare two parameters and name them separately:
#![allow(unused)]
fn main() {
struct Row<'a, 'b> {
name: &'a str,
email: &'b str,
}
}
Most of the time one lifetime is enough. Reach for two only when the fields really do borrow from different places and you don’t want the intersection.
The other place you sometimes write lifetimes is functions that return one of several input references:
#![allow(unused)]
fn main() {
fn longer<'a>(a: &'a str, b: &'a str) -> &'a str {
if a.len() > b.len() { a } else { b }
}
}
The <'a> ties all three references to a common region, the intersection of the actual input lifetimes. At the call site the compiler picks 'a to be the window during which every input is alive; the result cannot be used past that window. Without <'a>, the compiler refuses. From the signature alone, it cannot tell whether the output borrows from a, b, or either.
Inputs share a lifetime only when they need to. If only one input is the source of the output, only that input needs an explicit name:
#![allow(unused)]
fn main() {
fn first_part<'a>(input: &'a str, separator: &str) -> &'a str {
let idx = input.find(separator).unwrap_or(input.len());
&input[..idx]
}
}
The output borrows from input, so input gets 'a. The separator is just &str because elision gives it its own anonymous lifetime, independent of 'a. Nothing ties them together, so the separator can live a much shorter window than input. Picking the same 'a for both inputs, like in longer, is a deliberate constraint, used only when either input could become the output.
The distinction shows up in real code. The compiler catches a dangling reference through input:
#![allow(unused)]
fn main() {
let part;
{
let s = String::from("hello,world");
part = first_part(&s, ",");
} // s dropped here
println!("{}", part); // ERROR: `s` does not live long enough
}
But it does not care about separator dying early, because part does not borrow from it:
#![allow(unused)]
fn main() {
let s = String::from("hello,world");
let part;
{
let sep = String::from(",");
part = first_part(&s, &sep);
} // sep dropped, but part does not borrow from sep
println!("{}", part); // OK
}
If we had used the same 'a for both inputs (like longer), the second snippet would also fail: the compiler would require sep to outlive part. Naming only input’s lifetime is what frees the separator from that constraint.
Why doesn’t the compiler just look inside the body and figure all this out? Two reasons. First, the signature is the public contract: if lifetimes came from the body, a one-line change inside the function could silently tighten or loosen what callers are allowed to do, breaking them with no warning. Second, the compiler and the reader often only have the signature. Across crates, through trait objects, or just when reviewing code, the body is not available. Putting the borrowing relationship in the signature keeps it visible, stable, and independent of implementation.
What does any of this defend against? The dangling reference:
#![allow(unused)]
fn main() {
fn dangling() -> &i32 {
let x = 42;
&x // ERROR: returns reference to local
}
}
x lives only inside dangling. The returned reference would point into a stack frame that has already been popped.
When are lifetimes inferred and when do you write them?
- Function parameters and returns: usually inferred (lifetime elision). You write
<'a>mainly when the function returns a reference and elision can’t figure out which input it borrows from. - Struct fields that are references: the struct must declare a lifetime parameter. There is no elision here.
struct Row<'a> { ... }is mandatory the moment a field is&stror any other reference. - Local variables (
let): never. The compiler infers them entirely from how the binding is used.
Lifetimes have a reputation as the hardest part of Rust, but in everyday code you rarely write them. The compiler usually tells you exactly which annotation it wants.
The takeaway: lifetimes track how long each reference is valid, so references can never outlive their data. Most are inferred. The ones you write usually appear on parser-style structs and on functions returning one of several input references.
🔇 No, that is not a stuck pixel. The cat didn’t walk on your keyboard. That is 'a, and the compiler will make sure you remember it for a lifetime.
Follow-up
🦀 Tip of the day 22 follow-up
Follow-up questions from the discussion on Tip 22: Lifetimes, or Why a Reference Cannot Outlive Its Referent.
Does '_ only work when the function has a single reference parameter, or can it handle multiple?
In fn parse(line: &str) -> Row<'_>, you could equally have written fn parse<'a>(line: &'a str) -> Row<'a>. The '_ is shorthand that asks the compiler to infer the output lifetime using the elision rules. So the real question is: when can elision figure it out on its own?
The rules are a small priority list.
If the function takes &self or &mut self, the receiver wins. Its lifetime is assigned to every elided output lifetime, no matter how many other reference parameters there are. This is why methods rarely need explicit annotations even when they take extra borrowed arguments.
Otherwise, elision fills in the output only when the inputs contribute exactly one lifetime position. Each & and each lifetime slot of a generic input counts as a position; plain value arguments like i32 do not count. Two references are two positions even if you happen to give them the same name, so fn build(a: &str, b: &str) -> Row<'_> does not compile:
#![allow(unused)]
fn main() {
fn build(a: &str, b: &str) -> Row<'_> { // ERROR
Row { name: a, email: b }
}
}
error[E0106]: missing lifetime specifier
= help: this function's return type contains a borrowed value, but the
signature does not say whether it is borrowed from `a` or `b`
The compiler cannot guess whether the result borrows from a or b, so it makes you spell it out:
#![allow(unused)]
fn main() {
fn build<'a>(a: &'a str, b: &'a str) -> Row<'a> {
Row { name: a, email: b }
}
}
A practical note: 'a is no harder to type than '_, and it states the relationship explicitly. When in doubt, write the name. The elision rules are convenient, not something you need to memorize.
Does declaring a lifetime guarantee the function is written correctly, or could the code still compile if the lifetime were omitted from Row?
These are two different guarantees, and it helps to keep them apart.
A struct that holds references cannot omit the lifetime parameter at all. This does not compile:
#![allow(unused)]
fn main() {
struct Row { name: &str, email: &str }
}
error[E0106]: missing lifetime specifier
There is no elision for struct fields. The lifetime parameter is the type’s contract for how long its borrowed contents must stay valid, and the compiler demands it at the definition. A malformed Row that secretly holds a dangling reference cannot exist in the first place, because you cannot even name the type without committing to that contract.
So the annotation is not optional documentation that a careful author remembers to add. It is required, and once it is there the compiler enforces it everywhere the type is used: construct a Row from a string and the Row is held to the string’s lifetime, drop the string early and your code stops compiling. The annotation does not certify the function body is good logic; it certifies the borrowing relationship is sound, which is the part that causes use-after-free bugs in other languages.
Can a Row be built in two different contexts, one where both fields share a string and one where each field borrows from a different string? Do Row<'a> and Row<'a, 'b> need to be different types?
You usually do not need two lifetime parameters for this. A single-lifetime Row<'a> already accepts references from different scopes, because the compiler picks 'a to be the intersection (the shorter) of the actual lifetimes involved:
#![allow(unused)]
fn main() {
struct Row<'a> {
name: &'a str,
email: &'a str,
}
let long = String::from("alice");
{
let short = String::from("bob");
let r = Row { name: &long, email: &short };
println!("{} / {}", r.name, r.email); // prints: alice / bob
}
}
This compiles. The compiler shrinks &long to fit the shorter window of short, so both fields share one valid region and the Row is sound for the duration of the inner scope.
Row<'a> and Row<'a, 'b> are indeed distinct types, and they can coexist in the same program:
#![allow(unused)]
fn main() {
struct Row<'a> {
name: &'a str,
email: &'a str,
}
struct RowSplit<'a, 'b> {
name: &'a str,
email: &'b str,
}
}
You reach for the two-parameter version only when callers genuinely need the two lifetimes tracked independently, for example when each field is produced and consumed under separate constraints and you do not want one to be shrunk to match the other. That is rare. The intersection behavior of a single lifetime is what makes Row<'a> cover almost every case without extra ceremony.
Strings, or Why There Are Two of Them
🦀 Tip of the day 23: Strings, or Why There Are Two of Them
In most languages, a string is just a string. You create one, pass it around, and never think about who is responsible for the memory. Rust has two main string types: String and &str. Now that you have seen ownership, the reason makes sense.
String owns its data. It is heap-allocated and growable, and when the variable goes out of scope, the memory is freed. &str (pronounced “string slice”) is a borrowed view into string data that someone else owns. It can borrow from any source of string bytes: a literal like "hello", an owned String, or any other type that can hand out a &str view of its contents (a mechanism called Deref<Target = str>, covered later). You can read through it, but you cannot change the data behind it, and it cannot outlive the data it points to.
Without a garbage collector, every piece of string data needs an owner. String says “I own this.” &str says “I’m just borrowing a look.”
Underneath, str is the byte data and &str is a borrowed view onto it. str itself is unsized: the compiler does not know its length at compile time, so you can never hold a str directly. You always work through &str, a fat pointer carrying (address, length) into a str somewhere. String, by contrast, is an owned, growable, heap-allocated buffer that owns its bytes outright.
A string literal "hello" has type &'static str. The bytes live in your compiled binary, and the literal evaluates to a view onto them. We saw 'static in tip 22: it lives for the whole program.
#![allow(unused)]
fn main() {
let greeting = "hello"; // &'static str, view onto bytes in the binary
let owned = String::from("hello"); // String, owned heap allocation
}
In practice you will mostly see a &str pointing into a literal, into a whole String (&owned), or into a slice of one (&owned[..3]). Whatever the source, the bytes must always be valid UTF-8; you cannot conjure a &str from arbitrary bytes.
Converting between them:
#![allow(unused)]
fn main() {
// &str -> String (allocates, copies the data)
let s1 = String::from("hello");
let s2 = "hello".to_string(); // same thing, different style
// String -> &str (free, just borrows existing data)
let s3: &str = &owned;
}
Going from &str to String copies data into a new heap allocation. Going the other way is just a borrow.
When to use which: if your function only reads a string, take &str. It accepts both:
#![allow(unused)]
fn main() {
fn greet(name: &str) {
println!("Hello, {}!", name);
}
greet("world"); // &str literal, works
greet(&String::from("Rust")); // String borrowed as &str, works
}
If your struct needs to hold the data after the function that built it returns, store a String:
#![allow(unused)]
fn main() {
struct User {
name: String, // User owns this data
}
}
A common confusion: methods like .replace(), .to_lowercase(), and .trim() are callable on &str. Doesn’t that contradict “read-only”?
No. Those methods do not modify the original. They return a new value:
#![allow(unused)]
fn main() {
let s = "Hello";
let lower = s.to_lowercase(); // new String, "hello"
let trimmed = " hi ".trim(); // new &str, borrowing into the same data
// s is unchanged
}
Methods that actually mutate, like push_str, push, and clear, only exist on String, and only when the binding is mut:
#![allow(unused)]
fn main() {
let mut s = String::from("hello");
s.push_str(", world"); // s is now "hello, world"
}
So the rule holds. Through &str you can read or produce new values, but never change the data behind the borrow.
Strings are UTF-8, and you cannot index them:
#![allow(unused)]
fn main() {
let s = String::from("hello");
// let c = s[0]; // ERROR: String cannot be indexed by integer
}
This surprises everyone. Rust strings are UTF-8, and characters can be 1 to 4 bytes long. é is 2 bytes. 🦀 is 4 bytes. If s[0] returned one byte, it might give you half a character. Rust refuses to pretend bytes and characters are the same thing.
Be explicit about what you want:
#![allow(unused)]
fn main() {
let s = String::from("hello");
let first_char = s.chars().nth(0); // Option<char>, iterates characters
let first_byte = s.bytes().nth(0); // Option<u8>, iterates raw bytes
let slice = &s[0..3]; // "hel", panics if you slice mid-character
}
How other languages handle this: Python, JavaScript, and Java hide encoding from you. You write s[0] and get “a character,” but Java uses UTF-16 internally, Python uses a flexible encoding, and JS uses UTF-16 with surrogate pairs. C++ std::string is a mutable byte sequence that does not enforce UTF-8. Go strings are immutable byte slices, closer to &str, and Go is similarly honest about the bytes-vs-characters distinction.
The takeaway: String owns string data, &str borrows it, and methods on &str either read it or hand you back a new value, never mutate the original.
🔇 Good news, this is the easiest of std’s half-dozen string types. Bad news, you have not met OsString yet.
Follow-up
🦀 Tip of the day 23 follow-up
Follow-up questions from the discussion on Tip 23: Strings, or Why There Are Two of Them.
When you write let slice = &s[0..3], is the range counted in bytes or in characters, and why?
It is bytes. String slicing in Rust indexes the underlying UTF-8 byte buffer, not characters.
The reason goes back to how str is stored. A str is just a run of bytes that happens to be valid UTF-8, and a &str is a fat pointer carrying an address and a byte length. There is no per-character index sitting alongside the bytes, so the only number Rust can use for slicing in constant time is a byte offset. Counting characters would mean walking the string from the start every time, which is exactly the cost Rust refuses to hide from you.
So 0..3 means “the first three bytes,” and how many characters that covers depends on the content:
#![allow(unused)]
fn main() {
let s = "héllo";
let slice = &s[0..3]; // "hé"
}
That slice is three bytes but only two characters. The é is U+00E9, which UTF-8 encodes as two bytes, so h (1 byte) plus é (2 bytes) fills the 0..3 range exactly.
The catch is that a byte range has to land on character boundaries. If a range starts or ends in the middle of a multi-byte character, the slice does not silently hand you half a character. It panics:
#![allow(unused)]
fn main() {
let r = "🦀 hi";
let bad = &r[0..2]; // panics
}
The panic message tells you precisely what went wrong:
byte index 2 is not a char boundary; it is inside '🦀' (bytes 0..4) of `🦀 hi`
The crab is four bytes, so the only valid byte offsets at the front of that string are 0 and 4. Asking for 0..2 cuts the character in half, and rather than return malformed UTF-8, Rust stops.
This is the same honesty the tip is built around. Bytes and characters are different things, and Rust makes you say which one you mean. If you actually want character positions, iterate with s.chars() and pay the linear cost on purpose. If you want a byte slice, use a byte range and accept that it must align to a character boundary.
Method Receivers, or Why Your Methods Have Three Different selfs
🦀 Tip of the day 24: Method Receivers, or Why Your Methods Have Three Different selfs
Tip 10 introduced impl blocks and mentioned that methods take self as their first parameter. It said &self is for reading and &mut self is for mutating, and promised “more later.” This is later.
There are three forms a method receiver can take, and now that you have seen ownership and borrowing, they map cleanly:
&self: borrow the value (shared, read-only).&mut self: borrow the value mutably (exclusive, can modify).self: take ownership of the value (the caller’s variable becomes invalid).
Same rules as tips 19, 20, and 21, applied to the implicit first argument of every method.
#![allow(unused)]
fn main() {
struct Counter {
count: u32,
}
impl Counter {
fn new() -> Counter { Counter { count: 0 } } // no receiver: associated function
fn get(&self) -> u32 { // borrow, read
self.count
}
fn increment(&mut self) { // borrow mutably, modify
self.count += 1;
}
fn into_value(self) -> u32 { // take ownership, consume
self.count
}
}
let mut c = Counter::new();
c.increment(); // &mut c happens automatically
println!("{}", c.get()); // &c happens automatically
let total = c.into_value(); // c is moved, can't be used after this
// c.get(); // ERROR: value used after move
}
The three forms are sugar. &self is short for self: &Self, &mut self for self: &mut Self, and plain self for self: Self. The lowercase self is the value; uppercase Self is the type being implemented (here, Counter). Functions inside impl that skip the receiver entirely are associated functions (aka static functions), called with :: instead of . because there is nothing to bind. Counter::new() is the classic example.
The call site does work for you. When you write c.increment(), the compiler matches the receiver shape and inserts &, &mut, or a move as needed. So c.increment(), (&mut c).increment(), and Counter::increment(&mut c) all compile to the same thing. You almost always write the first.
This auto-adjustment also reaches through wrappers. If you have a Box<Counter> and call .get() on it, the compiler unwraps the Box to find a Counter and borrows that. You will see more of this once references and smart pointers are familiar; for now, just know that the dot operator is doing more than it looks.
When to use which receiver:
&selffor anything that just reads. Most of your methods.&mut selffor setters,push,sort, anything that modifies the value.selffor methods that consume or transform the value into something else.into_iter()consumes a collection; builder-style chains return a new builder at each step. The naming convention isinto_*for “consumes self and produces something else.”
Most other languages give you a single implicit this and trust you not to mutate when you shouldn’t. C++ comes closest with trailing const for read-only methods, and gets even more ergonomic than Rust with C++23 deducing this. Rust’s twist is making all three choices explicit and part of the method’s signature, so the compiler can enforce them.
The takeaway: every method takes a receiver, and the three choices (self, &self, &mut self) are just ownership applied to the implicit first parameter. Pick whichever access the method needs, no more.
🔇 If tip_of_the_day::drop(self) runs two hours late in an empty office and no one observes, is it still tip_of_the_day, or just Some(tip)?
Slices, or Borrowing Just Part of Something
🦀 Tip of the day 25: Slices, or Borrowing Just Part of Something
Borrowing (tips 20 and 21) lets you pass a reference to a whole value. Slices let you pass a reference to a range inside a value.
A slice is a borrowed view into a contiguous chunk of elements. The two you will use most:
&[T]: a borrowed sequence ofT. Backed by aVec, an array, or another slice.&str: a borrowed UTF-8 string. You met this in tip 23.
Both are non-owning views: a pointer plus a length. The slice type [T] is unsized: it has no length at compile time, so it cannot exist on its own and always sits behind a pointer like &[T].
In contrast, an array [T; N] bakes the length into the type. [i32; 3] and [i32; 4] are different types, and the value lives inline (on the stack for local variables). That is what makes &[T] so flexible as a parameter: one type borrows from arrays of any size, Vecs, or other slices.
Mutable forms &mut [T] and &mut str also exist, though &mut str is rarely used: a str is UTF-8 bytes underneath, and editing those bytes can easily break the UTF-8 invariant, so the standard library only exposes a few byte-safe operations like ASCII case conversion.
Indexing returns one element by reference; slicing returns a range:
#![allow(unused)]
fn main() {
let v = vec![10, 20, 30, 40, 50];
let one: &i32 = &v[0]; // index: borrow one element
let some: &[i32] = &v[1..4]; // slice: borrow elements 1, 2, 3
}
The range 1..4 is half-open: “from 1, up to but not including 4.” Shorthand forms: &v[..3] (first three: 0, 1, 2), &v[2..] (from 2 to the end), &v[..] (the whole thing). The inclusive form is ..=, so &v[..=3] would include index 3.
If the bounds are out of range, or fall mid-character for strings, the program panics at runtime:
#![allow(unused)]
fn main() {
let v = vec![1, 2, 3];
let bad = &v[0..10]; // panics: index 10 out of bounds
let s = String::from("héllo");
let half = &s[0..2]; // panics: 'é' is two bytes
}
A panic is a controlled crash, not undefined behavior. You will never read half a character or walk off the end of a buffer. The trade-off is that bad bounds become runtime failures rather than compile errors. To handle the failure instead of panicking, use v.get(0..10), which returns Option<&[T]>.
When you write a function that reads a sequence, take &[T] rather than &Vec<T>. Slice parameters accept more callers at zero cost:
#![allow(unused)]
fn main() {
fn sum(values: &[i32]) -> i32 {
values.iter().sum()
}
let v = vec![1, 2, 3];
let arr = [10, 20, 30];
sum(&v); // Vec borrowed as a slice
sum(&arr); // array borrowed as a slice
sum(&v[1..]); // sub-slice of the Vec
}
A &Vec<T> parameter only accepts Vecs. &[T] accepts every contiguous sequence, and there is no performance penalty: &Vec<T> already dereferences to &[T] to do its work. Same logic for &str over &String.
Rules of thumb for picking a parameter type:
- Reading only? Take
&[T]or&str. - Modifying elements in place (length stays the same)? Take
&mut [T]. - Growing or shrinking the collection? Take
&mut Vec<T>or&mut String. - Need to consume, store, or return the data? Take
Vec<T>orStringby value.
Slices follow the borrow rules from tip 21. A slice is a borrow, so the XOR rule applies: many shared slices are fine, but you cannot mutate the underlying value while a slice is alive. This is the mechanism that catches iterator invalidation at compile time.
The takeaway: a slice (&[T] or &str) borrows a contiguous range. Prefer slice parameters for read-only access, and reach for owned Vec or String only when you actually need ownership.
🔇 A slice is a pointer that knows when to stop.
Derive Macros, or How to Make the Compiler Write Your Boilerplate
🦀 Tip of the day 26: Derive Macros, or How to Make the Compiler Write Your Boilerplate
You have a struct. You want to print it for debugging. You write the natural thing:
#![allow(unused)]
fn main() {
struct Player {
name: String,
score: u32,
}
let p = Player { name: String::from("Alice"), score: 42 };
println!("{:?}", p); // ERROR: `Player` doesn't implement `Debug`
}
The compiler complains: Player does not implement the Debug trait. You can write that implementation yourself, but for a “print every field” version, the compiler will write it for you. Just ask:
#![allow(unused)]
fn main() {
#[derive(Debug)]
struct Player {
name: String,
score: u32,
}
let p = Player { name: String::from("Alice"), score: 42 };
println!("{:?}", p); // Player { name: "Alice", score: 42 }
}
#[derive(...)] is an attribute that asks the compiler to generate a standard implementation for one or more traits. Each name inside the parentheses is a trait. The compiler reads your struct and writes the impl for you.
The standard derives you will use most often:
Debug: enables{:?}formatting inprintln!and friends.Clone: gives your type a.clone()method. It calls.clone()on each field in turn, so the semantics depend on what each field’sCloneimpl does.Copy: opts into copy-on-assignment (tip 19). Only legal when every field is alsoCopy.PartialEq: enables==and!=comparisons.Eq: a marker trait paired withPartialEq, asserting equality is reflexive (x == xalways holds). No methods, no runtime cost. Required alongsideHashforHashMapkeys.Default: gives youType::default(), returning the default value (zero for numbers, empty for collections).Hash: lets the type be a key inHashMapandHashSet.PartialOrd: enables<,<=,>,>=operators. Can answer “less”, “equal”, “greater”, or “incomparable”, and that last case is whyf32andf64only implement this one (NaNis incomparable with anything, including itself).Ord: a total ordering: any two values compare to a definite answer. Required for sorting and forBTreeMap/BTreeSetkeys.Ordis a sub-trait ofPartialOrdandEq, so the four typically travel together:#[derive(PartialEq, Eq, PartialOrd, Ord)].
You can stack them:
#![allow(unused)]
fn main() {
#[derive(Debug, Clone, PartialEq, Default)]
struct Player {
name: String,
score: u32,
}
}
A note on what derive macros actually are. They are not the same machinery as the ! macros from tips 1 and 2. Those are declarative macros: pattern substitution on tokens.
#[derive(...)] is a procedural macro. Proc macros are real Rust code that runs at compile time, takes your syntax as input, and produces new syntax as output. They come in three flavors:
- Function-like (
my_macro!(...)): used the same way as declarative macros, but written as proc-macro code. Example:sqlx::query!(...). - Attribute (
#[my_attr]): can rewrite the entire item it is attached to. Example:#[tokio::main]. - Derive (
#[derive(MyTrait)]): can only add newimplblocks. It never modifies the item itself, which is why derives feel safe to read.
The standard derives are built into the compiler. Custom ones come from external crates: serde::Serialize for JSON serialization, thiserror::Error for error types, and so on.
You do not need to know how to write any flavor to use them. You write the attribute, the compiler runs the macro, the impl appears.
A few things worth knowing about the standard derives (custom derives from crates follow similar conventions but can set their own rules):
- Every field must implement the trait you are deriving.
#[derive(Debug)]on a struct with a non-Debugfield is a compile error. Copycannot be derived withoutClone.Copyis a sub-trait ofClone, so the pair always travels together.- Derives apply to enums as well as structs. The standard ordering and equality derives compare variants by tag first, then by field.
- For
PartialOrdandOrd, the comparison is lexicographic in the order the fields are declared. Ifscoreshould dominatename, declarescorefirst. - To see what was generated by any derive (standard or custom), install
cargo-expandand runcargo expand. It prints your code with all derives expanded inline.
The takeaway: #[derive(...)] asks the compiler to write a standard implementation of a trait for you. Most Rust structs have at least #[derive(Debug)], and many have Clone, PartialEq, Default alongside.
🔇 #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Serialize, Error)] fits on one line. Whether it should is a separate conversation.
Follow-up
🦀 Tip of the day 26 follow-up
Follow-up questions from the discussion on Tip 26: Derive Macros, or How to Make the Compiler Write Your Boilerplate.
Ord is required for sorting, but f64 only implements PartialOrd. Does that mean you cannot sort a Vec<f64>, even when the data contains no NaN?
You can sort it, just not through the Ord-based API. The standard .sort() method requires Ord, and f64 cannot implement Ord because the trait demands a total order over every possible value, and NaN breaks that promise: it compares as incomparable with everything, including itself. The type system has no way to know your particular vector happens to be NaN-free, so it refuses Ord for the type as a whole.
Instead, reach for sort_by and supply a comparator. The standard library gives you f64::total_cmp, stable since Rust 1.62, which defines a total order over all floats by placing NaN at one end rather than panicking:
#![allow(unused)]
fn main() {
let mut data = vec![3.0_f64, 1.0, 2.0];
data.sort_by(f64::total_cmp);
// or, equivalently:
data.sort_by(|a, b| a.total_cmp(b));
}
If NaN truly cannot occur in your domain, the other option is a wrapper type that excludes it. A newtype around f64 whose constructor rejects NaN can soundly implement Ord, and then sorting and BTreeMap keys work normally. Crates like ordered-float provide exactly that. Either way, the burden is on you to prove NaN is handled; the language will not assume it.
In C++, the declaration order of struct fields drives three unrelated things at once: initialization order, memory layout, and the meaning of a defaulted comparison. Designing the order for one use can hurt the others. Does Rust have the same coupling?
Rust decouples all three, so the conflict mostly disappears.
Initialization order is not tied to declaration order at all. A struct literal evaluates its field expressions in the order you write them at the call site, not the order the fields were declared. You can write the fields in any order:
#![allow(unused)]
fn main() {
struct Player { score: u32, name: String }
let p = Player {
name: get_name(), // this expression runs first
score: calculate_score(),
};
}
Memory layout is also decoupled. Under the default representation (repr(Rust)), the compiler is free to reorder fields to minimize padding, and it does. The layout is deliberately unspecified, so declaration order tells you nothing about the bytes. If you need a fixed layout, for example to pass a struct across an FFI boundary to C, you opt in with #[repr(C)], which pins the fields to declaration order:
#![allow(unused)]
fn main() {
#[repr(C)]
struct Header {
version: u8,
flags: u16,
length: u32,
}
}
That leaves the third use, comparison. The derived PartialOrd and Ord do read declaration order: the comparison is lexicographic, top field first. So this is the one place where field order carries semantic weight, and it is the one you noticed in the tip.
The key difference from C++ is that this coupling is shallow and escapable. #[derive(...)] only writes a standard implementation; if the lexicographic order it picks is wrong for you, write your own Ord by hand and order the fields however you like for layout or readability:
#![allow(unused)]
fn main() {
impl Ord for Player {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
// compare by score regardless of field position
self.score.cmp(&other.score)
.then_with(|| self.name.cmp(&other.name))
}
}
}
So where C++ forces declaration order to serve initialization, layout, and comparison simultaneously, Rust ties initialization to the literal, leaves default layout to the optimizer, and only lets declaration order influence the derived comparison, which you are free to override.
Traits, the Shape Your Types Can Promise
🦀 Tip of the day 27: Traits, the Shape Your Types Can Promise
Tip 26 kept saying “implement the Debug trait.” What is a trait? In one sentence: a trait is a set of method signatures that types can promise to provide. Other languages call this an interface (Java, C#, Go), a protocol (Python, Swift), or a type class (Haskell). Rust borrowed the idea most directly from Haskell.
You define a trait by listing the methods it requires:
#![allow(unused)]
fn main() {
trait Greet {
fn hello(&self) -> String;
}
}
Any type can implement Greet by providing that method:
#![allow(unused)]
fn main() {
struct English;
struct French;
impl Greet for English {
fn hello(&self) -> String {
String::from("Hello!")
}
}
impl Greet for French {
fn hello(&self) -> String {
String::from("Bonjour!")
}
}
}
Now both English and French are Greet types. You can write a function that accepts anything implementing Greet:
#![allow(unused)]
fn main() {
fn greet_someone(g: impl Greet) {
println!("{}", g.hello());
}
greet_someone(English); // "Hello!"
greet_someone(French); // "Bonjour!"
}
The impl Greet parameter is shorthand for “any type that implements Greet.” The compiler generates a specialized version of greet_someone for each concrete type that calls it, a process called monomorphization. No virtual dispatch, no runtime cost.
A small but important detail: you can implement a trait for a type you did not write. impl MyTrait for Vec<T> is allowed. This is unusual coming from Java or C#, where a class’s interfaces are fixed at the moment the class is declared. In Rust, you can extend existing types with new behavior, as long as either the trait or the type is local to your crate. A crate is a Rust library or binary built as a single piece by the compiler; a later tip covers crates properly. This restriction has a name: the orphan rule. It stops two unrelated crates from independently writing conflicting impls for the same trait and type pair.
Standard library traits are how you opt into language behavior. impl Display for MyType makes println!("{}", x) work. impl Iterator for MyType makes it work in for loops. impl PartialEq for MyType enables ==. The trait system is how Rust does polymorphism.
Each impl Trait for Type { ... } block implements exactly one trait. You cannot bundle multiple traits into a single block; three traits means three blocks. #[derive(Debug, Clone, PartialEq)] generates three such blocks too, one per trait, sitting alongside the struct rather than modifying it.
Two more shapes worth knowing now. A trait method can ship with a default body; implementors override it only when they need something different. This is why related traits compose cleanly: Ord requires only cmp, and gets min, max, and clamp for free from defaults built on top. And a trait can have no methods at all. Marker traits like Copy (tip 19), Eq (tip 26), and the concurrency markers Send and Sync carry no code, only a promise the type makes to the compiler.
Traits versus other languages:
- Java, C#: classes declare which interfaces they implement at definition time. Default methods (Java 8+, C# 8+) and C# extension methods soften this, but you cannot retroactively make an existing class satisfy a new interface. Dispatch through an interface is dynamic by default (vtable lookup).
- Python: two analogues, not one. ABCs are nominal: you inherit explicitly, and forgetting an abstract method raises
TypeErrorat construction. Protocols (PEP 544) are structural and statically checked by mypy or pyright, closer to Go interfaces than Rust traits. - Go: interfaces are structurally typed; any type with the right methods satisfies them, no
implneeded. Both languages catch typos at compile time. The real difference is that Rust’s explicitimpl Trait for Typerecords intent (one canonical, grep-able declaration), enforces coherence via the orphan rule, and makes semantic opt-ins likeEq’s reflexivity a conscious promise rather than an accident of method shape. - Haskell: type classes. Same idea, different syntax. Rust borrowed heavily here.
Trait bounds with generics (fn f<T: Trait>(x: T)), associated types, and dyn Trait for runtime polymorphism are topics for later tips. For now, the basics: define a trait, implement it for a type, accept impl Trait as a parameter.
The takeaway: a trait is a set of method signatures. Types implement traits by providing those methods. Functions accept impl Trait to take any matching type, with the compiler checking the contract at compile time and no runtime cost.
🔇 Java interfaces describe what a class can do at the moment of birth. Rust traits describe what a type can do, possibly after the type was written, possibly by someone else, possibly with capabilities its original author never imagined.
Follow-up
🦀 Tip of the day 27 follow-up
Follow-up questions from the discussion on Tip 27: Traits, the Shape Your Types Can Promise.
The orphan rule says either the trait or the type must be local to my crate. So if a type from an external library does not implement Display, can I never print it the simple way?
You cannot write impl Display for SomeForeignType when both Display and the type live in other crates. The compiler stops you, and the error spells out exactly why:
error[E0117]: only traits defined in the current crate can be implemented for types defined outside of the crate
|
9 | impl Display for String {
| ^^^^^^^^^^^^^^^^^------
| |
| `String` is not defined in the current crate
|
= note: define and implement a trait or new type instead
This is the orphan rule doing its job. The official definition: a trait implementation is allowed only if either the trait or at least one of the types in the implementation is defined in the current crate (see the reference on implementations). It exists so two unrelated crates cannot ship conflicting impls for the same trait and type pair, which would make adding a dependency randomly break your build.
But you are not stuck. If a library does not tell you how to print its type, it is reasonable that you supply that decision yourself. The compiler’s own hint points at the two standard ways out: define a new type, or define a new trait.
What does the newtype wrapper pattern look like, and how does it get around the orphan rule?
You define a local struct that wraps the foreign type, then implement Display for the wrapper. The wrapper is yours, so the orphan rule is satisfied: the type in the impl is now local.
#![allow(unused)]
fn main() {
struct AsDisplay<'a>(&'a SomeForeignType);
impl<'a> std::fmt::Display for AsDisplay<'a> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "...") // decide here how the foreign value prints
}
}
println!("{}", AsDisplay(&value));
}
The wrapper holds a reference rather than owning the value, so you avoid copying. The lifetime 'a just ties the wrapper’s borrow to the value it points at.
Here is a fuller version that prints a Vec<T> with custom braces. The proxy struct is local, so implementing Display on it is allowed:
#![allow(unused)]
fn main() {
use std::fmt::Display;
// A local proxy type we are allowed to implement Display for.
struct DisplayVecProxy<'a, T: Display> {
reference: &'a Vec<T>,
}
impl<'a, T: Display> Display for DisplayVecProxy<'a, T> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{{")?;
for a in self.reference.iter() {
write!(f, "{}, ", a)?;
}
write!(f, "}}")
}
}
}
Can I make value.display() produce a printable wrapper instead of constructing it by hand every time?
Yes. Define your own local trait with a method that returns the proxy, then implement that trait for the foreign type. The trait is local, so the orphan rule is happy even though Vec<T> is not yours.
trait Displayable<'a> {
type Output;
fn display(&'a self) -> Self::Output;
}
impl<'a, T: Display + 'a> Displayable<'a> for Vec<T> {
type Output = DisplayVecProxy<'a, T>;
fn display(&'a self) -> Self::Output {
DisplayVecProxy { reference: self }
}
}
fn main() {
let v = vec![1, 2, 3];
println!("{}", v.display()); // {1, 2, 3, }
}
This combines a few ideas from later tips: an associated type (type Output) names the proxy the trait hands back, and the lifetimes make sure the proxy never outlives the Vec it borrows. If the lifetimes look intimidating, that is normal; the pattern is worth recognizing even before the syntax feels comfortable.
Is there a lighter-weight alternative if I do not want a whole proxy type?
There is. This is the extension trait pattern, and it skips the wrapper entirely. Define a local trait with a plain method and implement it directly for the foreign type. The trait is local, the type is foreign, so the orphan rule is satisfied.
#![allow(unused)]
fn main() {
trait PrettyPrint {
fn fmt_pretty(&self) -> String;
}
impl PrettyPrint for SomeForeignType {
fn fmt_pretty(&self) -> String {
// build and return the string however you like
String::from("...")
}
}
println!("{}", value.fmt_pretty());
}
The trade-off: this does not make println!("{}", value) work directly, because you have not implemented Display for the type, only your own method. You call value.fmt_pretty() to get the string yourself. In exchange you write much less code, with no proxy struct and no lifetime juggling. Reach for the newtype proxy when you genuinely need {} to work, and for the extension trait when an explicit method call is fine.
Box<T>, Heap Allocation You Can See
🦀 Tip of the day 28: Box<T>, Heap Allocation You Can See
Until now, heap allocation in your Rust code has been hidden inside types like Vec, String, and HashMap. Each of them owns a heap buffer behind the scenes; you never had to allocate explicitly. Box<T> is the simplest case where you do.
Box<T> owns exactly one value of type T, allocated on the heap, with the same ownership rules you already know:
#![allow(unused)]
fn main() {
let b = Box::new(String::from("hi")); // String on the heap, owned by b
println!("{}", *b); // explicit deref: prints the String
println!("{}", b.len()); // auto-deref: String::len called through the box
// b is dropped at end of scope, the heap memory is freed
}
The dereference operator * gives you the T inside. Method calls go a step further: the Deref trait lets you call any T method directly on the Box<T>, no * needed. To Rust, Box<T> is just an owning pointer, the same size as a &T with no refcount or metadata: drop it and the heap allocation goes away, exactly like any other owned value.
Concretely, here is how Box behaves under borrows and moves:
#![allow(unused)]
fn main() {
fn print(s: &String) { println!("{}", s); }
fn append(s: &mut String) { s.push('!'); }
fn take_box(_: Box<String>) {} // receives the Box itself
fn take_string(_: String) {} // receives the inner String
let mut b = Box::new(String::from("hi"));
print(&b); // &Box<String> auto-derefs to &String; b still owns the box
append(&mut b); // same idea, mutably; b still owns the box
take_box(b); // moves the Box (one-word pointer copy); b is dead
let b2 = Box::new(String::from("hi"));
take_string(*b2); // moves the String *out*; b2 is dead, heap slot freed
}
The two move flavors look different in memory (one copies the outer pointer, the other copies the inner value and tears the heap slot down) but leave the original binding in the same state: unusable. The move-out via *b does not follow from the rules you’ve seen so far; it is a Box-specific compiler concession, sound only because Box is the unique owner. The same expression on other smart pointers is rejected:
#![allow(unused)]
fn main() {
let r = Rc::new(String::from("hi"));
let s: String = *r; // ERROR: cannot move out of an Rc
}
Rc<T>, Arc<T>, and &T all refuse it, because moving out would invalidate co-owners or borrowed data.
Why would you ever want this? Three motivating cases.
Recursive types. Without Box, a recursive enum has no fixed size:
#![allow(unused)]
fn main() {
enum Tree {
Leaf,
Node(Tree, Tree), // ERROR: recursive type `Tree` has infinite size
}
}
Rust needs to know the size of every type it stores by value at compile time, and Tree would contain two Trees, each containing two Trees, forever. There is no fixed size to settle on. The fix is to put a Box between them:
#![allow(unused)]
fn main() {
enum Tree {
Leaf,
Node(Box<Tree>, Box<Tree>),
}
}
A Box<Tree> is a pointer to a heap allocation, and a pointer has a known fixed size (one machine word) regardless of what it points to. The enum is now sized; the actual Tree lives on the heap.
Bonus: Box<T> is never null, so Option<Box<T>> is the same size as Box<T>. The compiler uses the would-be-null pointer to represent None (a niche optimization). This is why idiomatic recursive types often use Option<Box<Node>> for children instead of a separate Leaf variant.
Trait objects. When you want to store values of different types in the same collection, Box<dyn Trait> is how you do it. Full coverage in a later tip on dyn Trait. For now, just know that this use case requires Box.
Large values. If a struct is huge (say, a kilobyte of fields), you may want it on the heap so stack frames stay small. Box::new(big_struct) does that. Like any function call, the argument is evaluated before the call (typically on the stack) and then moved into the heap allocation, so this is no magic shortcut around stack temporaries. In practice this case is rare; most types are small enough that the stack handles them fine.
You almost never use Box for plain values like Box::new(42). The motivating cases above are where it earns its keep.
Comparison:
- Java, C#, Python, JavaScript: every object is heap-allocated, accessed through a reference. No choice about where it lives.
- C: explicit
mallocandfree. Same idea asBox, but you free manually. - C++:
std::unique_ptr<T>is the closest analogue. RAII single ownership, moves on assignment. - Go: the compiler decides via escape analysis whether a value lives on the stack or the heap. You do not control it directly.
- Rust:
Box<T>lets you opt into heap allocation explicitly, with the same ownership semantics as any other value.
The takeaway: Box<T> is the simplest smart pointer. It owns one heap-allocated value with deterministic cleanup. Reach for it when you need recursive types, trait objects, or to move a large value off the stack.
🔇 Came to Rust to escape shared_ptr, weak_ptr, and unique_ptr. Was issued Box, Rc, Arc, RefCell, Cell, and Pin at the door.
Follow-up
🦀 Tip of the day 28 follow-up
Follow-up questions from the discussion on Tip 28: Box<T>, Heap Allocation You Can See.
Why does let s = *b move the value out of a Box? It does not seem to follow from the deref and ownership rules covered so far.
You are right to be suspicious: it does not follow. Nothing you have seen so far derives it.
Here is the tempting line of reasoning. *b gives you the T inside (that is Deref), and owned values can be moved around, so let s = *b should move the inner value out and consume b. The premise is correct: *b does expose the T through Deref. The conclusion is wrong: Deref does not hand you ownership, only a view.
For most types that implement Deref, *p produces a place you can read or borrow, but not move out of. The borrow checker treats it like data you do not own outright. Even when the surrounding p is owned, what Deref produces is shaped like &T or &mut T, not a transfer of ownership. So this is rejected:
#![allow(unused)]
fn main() {
let r = Rc::new(String::from("hi"));
let s: String = *r; // ERROR: cannot move out of an Rc
}
The same answer holds for Arc<T>, for the guards you get from RefCell, and for anything reached through a &T. Moving out would either invalidate co-owners (Rc, Arc) or move out of borrowed data (&T), so the compiler refuses.
Box<T> is the one exception. let s = *b; compiles. This is not part of the Deref story at all; it is a Box-specific rule baked into the compiler, sometimes called DerefMove. It is sound only because Box is the unique owner of its heap slot: take the value out, free the slot, and there are no co-owners left dangling.
It is worth flagging this explicitly as an exception, because it cannot be reproduced for your own types. There is no stable DerefMove trait you can implement to get the same behavior. A trait by that name has been proposed but never stabilized, in part because moving a value off the heap without an extra copy needs compiler support that ordinary types do not have. Box gets that support because the compiler treats it as a special primitive rather than as a plain struct.
Smart pointers like shared_ptr and unique_ptr are useful. What did Rust actually replace, and what did it leave alone?
It is less about replacing smart pointers and more about resetting expectations.
The common assumption when moving from C++ to Rust goes like this: smart pointers existed to express ownership, the borrow checker now expresses ownership, therefore smart pointers are no longer needed.
The reality is narrower. Only unique ownership got absorbed into the language. The borrow checker mostly displaced unique_ptr: single ownership with deterministic cleanup is now the default for every value, so you do not reach for a special type to get it.
Everything else is still here, just rearranged.
Rc<T> and Arc<T> do the same shared-ownership job that shared_ptr does. The borrow checker does not track shared ownership for you, so when you genuinely need it, you still ask for it explicitly.
Box<T> took on a different role than unique_ptr. Since unique ownership is now free for plain values, Box is no longer about expressing ownership at all; it is about heap indirection: recursive types, trait objects, and moving large values off the stack.
And RefCell and Cell are a category C++ does not really have: interior mutability. They exist only because the borrow checker enforces “many readers or one writer” at compile time, and sometimes you need to defer that check to runtime instead.
So the borrow checker did not abolish smart pointers. It mostly displaced unique_ptr, kept shared ownership as an opt-in, repurposed Box, and invented a new pointer category along the way.
Rc<T>, Sharing Without Copying
🦀 Tip of the day 29: Rc<T>, Sharing Without Copying
Tip 18 said every value has exactly one owner. That works for most code, but some patterns genuinely need multiple owners of the same value: graphs where several nodes hold the same child, shared configuration read by many parts of the program, persistent data structures that share most of their content between versions. Rc<T> is the escape valve. It does not bend the XOR borrowing rule from tip 21 (you still only get shared &T out of it); it bends the single-owner rule.
Rc stands for reference counted. It is a smart pointer like Box, but multiple Rc<T> values can point to the same heap allocation. Each clone bumps a count up; each drop bumps it down; when the count reaches zero, the value is freed.
#![allow(unused)]
fn main() {
use std::rc::Rc;
let a = Rc::new(vec![1, 2, 3]);
let b = Rc::clone(&a); // b points to the same Vec, count is now 2
let c = Rc::clone(&a); // c too, count is now 3
println!("count: {}", Rc::strong_count(&a)); // 3
// a, b, c dropped at end of scope, count goes 3 → 2 → 1 → 0, then free
}
Rc::clone(&a) is the idiomatic call. It does not deep-copy the Vec; it just bumps the refcount and hands you another pointer to the same data. You will sometimes see a.clone() instead, which means the same thing for Rc, but the explicit Rc::clone makes the cheapness visible.
One sharp edge: plain assignment (let b = a;) moves the Rc rather than sharing it. a becomes invalid and the refcount stays at 1, because Rc<T> is not Copy. To share, you must call clone explicitly. This is the opposite of C++ shared_ptr (which copies on assignment) and Swift/Java/Python (where assignment just duplicates the reference). It is the same rule as every other non-Copy value in Rust; it just surprises people because the whole point of Rc is sharing.
An Rc<T> derefs to T, so anything you can do with a &T works directly. What you cannot do is mutate the data while it is shared: Rc<T> only hands out shared references, because other Rcs might be aliasing the same value. Two escape hatches exist for when aliasing is not actually happening: Rc::get_mut returns Option<&mut T>, Some only when you are provably the unique owner, and Rc::make_mut does clone-on-write, cloning the inner value first if there are other owners so you always get a &mut T. For genuine shared mutability, combine it with RefCell<T> from tip 30: Rc<RefCell<T>>.
Now the trap with reference counting: cycles. If two values hold Rcs pointing at each other, neither count ever reaches zero, even when nothing else points to them, and the memory leaks. You cannot actually build a cycle with plain Rc alone, because the links are immutable once constructed. Cycles only appear once you combine Rc with RefCell (tip 30) and let the inner value rewrite its own pointers.
The fix when cycles do appear is Weak<T>: a non-owning pointer. A Weak<T> does not contribute to the strong count, so making one direction weak breaks the cycle. You upgrade a Weak<T> back to an Rc<T> with .upgrade(), which returns Option<Rc<T>> because the value may already be gone:
#![allow(unused)]
fn main() {
use std::rc::{Rc, Weak};
let weak: Weak<i32> = {
let strong = Rc::new(42);
Rc::downgrade(&strong)
}; // strong dropped here, count goes to 0, value freed
assert!(weak.upgrade().is_none()); // gone
}
One important warning: Rc<T> is single-threaded only. The refcount is not atomic, so sharing an Rc across threads would race on the count. The compiler refuses to let you (Rc<T> is neither Send nor Sync). For multi-threaded shared ownership, you want Arc<T> (tip 33). The non-atomic version exists because atomics cost more; for single-threaded code you save the cost.
Comparison:
- Swift: ARC by language design, deterministic destruction. Cycles need explicit
weak. - CPython: refcounted with a cycle collector. Prompt destruction in the common case, but it is an implementation detail; PyPy and other Python implementations use tracing GC.
- Java, C#, JavaScript, Go: tracing GC. No way to opt a specific reference into
Rc-style refcounting. - C++:
std::shared_ptr<T>is the analogue. Atomic refcount by default (heavier than Rust’sRc, matching Rust’sArc). C++weak_ptrplays the role ofWeak. - Rust:
Rcfor single-threaded,Arcfor cross-thread, opt-in with explicit cost.
The takeaway: Rc<T> lets multiple owners share a heap-allocated value through reference counting. Cheap to clone (one count bump), single-threaded only, watch for cycles and use Weak<T> to break them.
🔇 Rc is for sharing. To share it, call clone. To move it, do nothing. Pick carefully.
RefCell<T>, Borrowing at Runtime
🦀 Tip of the day 30: RefCell<T>, Borrowing at Runtime
Tip 29 left you with a problem. Rc<T> lets multiple owners share a value, but only hands out &T. If you need to mutate that shared value, the compile-time XOR rule blocks you, because the compiler cannot prove no other Rc is reading right now.
The rule is not wrong, just conservative. Plenty of real programs respect XOR in ways the static checker cannot see: a graph where you mutate one node while traversing another, a callback that briefly re-enters the subject, a tree that rewires itself. The borrows really are disjoint in time, but proving that requires understanding the program’s dynamics, not just its types.
RefCell<T> is the opt-out. It hides the value behind a runtime borrow counter:
#![allow(unused)]
fn main() {
use std::cell::RefCell;
let cell = RefCell::new(vec![1, 2, 3]);
let r1 = cell.borrow(); // Ref<T>, behaves like &T
let r2 = cell.borrow(); // another shared borrow, fine
println!("{} {}", r1[0], r2[0]);
drop(r1); drop(r2);
let mut m = cell.borrow_mut(); // RefMut<T>, behaves like &mut T
m.push(4);
cell.borrow(); // PANIC: m is still alive
}
borrow() and borrow_mut() return guards that bump an internal counter on creation and decrement it on drop. The check happens inside those calls themselves: if a conflicting guard is currently alive, the call panics, before you ever touch the result. You do not need to actually use the new borrow for the panic to fire. Asking for it is the violation.
“But m.push(4) is the last line that uses m, isn’t m already gone?” No, and this trips people up. m is an owning value with a Drop impl, not a reference. Owning values live until the end of their lexical scope, regardless of last use. To release the mutable borrow earlier, call drop(m) or wrap it in a smaller { ... } block.
“So I just moved the bug from compile time to runtime?” Sort of, but only for code the compiler would have rejected outright. You assert that this access pattern is actually safe, and pay for the assertion with a cheap runtime check. If you are right, nothing happens, and you get to write a program the static checker would not accept. If you are wrong, you find out immediately and loudly, instead of silently corrupting aliased state.
The most common shape is Rc<RefCell<T>>: shared ownership plus shared mutability.
#![allow(unused)]
fn main() {
use std::rc::Rc;
use std::cell::RefCell;
let shared = Rc::new(RefCell::new(vec![1, 2, 3]));
let copy = Rc::clone(&shared);
shared.borrow_mut().push(4);
println!("{:?}", copy.borrow()); // [1, 2, 3, 4]
}
One thing to clear up before the smaller cousin. For ordinary types, &self forbids mutation, and that is a real guarantee, not a polite suggestion. Cell<T> and RefCell<T> are deliberate exceptions, with a name for the pattern: interior mutability. From the outside the value still only exposes &T; the contents change inside. They are not regular types getting away with something. They are built on a language primitive called UnsafeCell<T>, the one hole Rust leaves for mutation through &. What keeps the exception sound is API discipline.
Cell (for Copy types) never lends a reference into the cell at all, so nobody can observe a partial swap. RefCell does lend references, but tracks them at runtime to enforce XOR.
The use case is when the value is shared through &T (often via Rc) and you still need to bump something inside it:
#![allow(unused)]
fn main() {
use std::cell::Cell;
use std::rc::Rc;
struct Logger { hits: Cell<u32> }
impl Logger {
fn log(&self, msg: &str) { // forced to be &self
self.hits.set(self.hits.get() + 1);
println!("[{}] {}", self.hits.get(), msg);
}
}
let logger = Rc::new(Logger { hits: Cell::new(0) });
let a = Rc::clone(&logger);
let b = Rc::clone(&logger);
a.log("from a");
b.log("from b");
println!("{}", logger.hits.get()); // 2
}
log cannot take &mut self because Rc<Logger> will never hand one out. Without Cell, this design does not type-check. RefCell<u32> would also work, but it tracks borrows at runtime for what is just a u32 swap. Cell is the right tool when the field is Copy and you only ever replace the value wholesale.
When should you reach for either? As a last resort. The compile-time checker catches mistakes earlier, costs nothing at runtime, and never panics on you in production. Use interior mutability only when you genuinely cannot express the pattern statically.
The takeaway: RefCell<T> moves the XOR rule from compile time to runtime so you can write patterns the borrow checker rejects but are still safe, at the cost of a panic if you turn out to be wrong. Cell<T> is the lighter sibling for Copy fields you want to mutate through &self.
🔇 The borrow checker stops you at compile time. RefCell hands the gun back at runtime, in case you came from C and miss it.
Closures, the Anonymous Functions That Borrow
🦀 Tip of the day 31: Closures, the Anonymous Functions That Borrow
Smart pointers boxed you in this week, judging by the comments. There is more to come, but it is Friday, so let us close this week with closures.
You have been using closures since tip 15. Iterators take them: nums.iter().map(|x| x * 2). They look like anonymous functions, and that is most of the truth. The other interesting half is what they do with their environment.
A closure is written |args| body:
#![allow(unused)]
fn main() {
let add_one = |x| x + 1;
println!("{}", add_one(4)); // 5
}
Types are usually inferred from the first call site. You can write them explicitly when you want to be clear:
#![allow(unused)]
fn main() {
let add_one = |x: i32| -> i32 { x + 1 };
}
The interesting part is what happens when a closure refers to a variable from the surrounding scope:
#![allow(unused)]
fn main() {
let factor = 3;
let multiply = |x| x * factor; // closure captures `factor`
println!("{}", multiply(4)); // 12
}
The closure captures factor automatically. Only variables the body actually references are captured; the rest of the enclosing scope is untouched. Each captured variable is taken in the least restrictive way that compiles:
- If the body only reads it, the closure captures it by shared reference (
&factor). - If the body mutates it, the closure captures it by mutable reference (
&mut factor). - If the body moves it out (or you write
move), the closure captures it by value.
A closure that uses several variables can mix capture modes, one per variable.
#![allow(unused)]
fn main() {
let mut count = 0;
let mut increment = || count += 1; // captures &mut count
increment();
increment();
println!("{}", count); // 2
}
The compiler picks the looser option that works. You can override it with the move keyword, which forces every captured variable to be taken by value:
#![allow(unused)]
fn main() {
let v = vec![1, 2, 3];
let owns_v = move || println!("{:?}", v);
owns_v();
// v cannot be used here, it was moved into the closure
}
You reach for move when the closure outlives the scope that created it, otherwise the borrow would dangle. The canonical example is handing a closure to a new thread, which we cover in tip 33.
move is all-or-nothing: it applies to every captured variable, with no syntax for picking which ones. To keep one as a borrow inside a move closure, store a reference to it first and let move capture that reference instead (references are Copy, so the underlying value stays alive).
Now the trait side. Every closure implements one or more of three traits, based on what its body does with the captures:
FnOnce: callable at least once, may consume its captures.FnMut: callable repeatedly, may mutate its captures but not consume them.Fn: callable repeatedly, only reads its captures.
The names look mutually exclusive, but the traits actually nest. FnOnce only requires that the closure can be called at least once, which every closure can. FnMut adds “callable repeatedly with mutation allowed”. Fn adds “no mutation”. So a read-only closure satisfies all three. A mutating closure satisfies FnMut and FnOnce. A consuming closure satisfies only FnOnce. When a function takes a closure parameter, it asks for the loosest trait that fits, which accepts the widest range of closures:
#![allow(unused)]
fn main() {
fn run_twice(mut f: impl FnMut() -> i32) -> i32 {
f() + f()
}
let n = 7;
let reader = || n + 1; // read-only, implements Fn (and FnMut)
println!("{}", run_twice(reader)); // 16
let mut counter = 0;
let counter_fn = || { counter += 1; counter }; // mutates `counter`, implements FnMut (not Fn)
println!("{}", run_twice(counter_fn)); // 3 (1 + 2)
}
The parameter mut f: impl FnMut() -> i32 accepts any closure that takes no arguments, returns i32, and can be called repeatedly. Because FnMut is looser than Fn, both reader (read-only) and counter_fn (mutating) fit. The mut on mut f is needed because calling an FnMut closure requires mutable access to it. (impl Trait was introduced in tip 27.)
Comparison:
- Java: lambdas (since Java 8) require captured local variables to be effectively final, so the local binding cannot be reassigned. The captured object’s state, instance fields, and statics can still be mutated freely.
- C++: lambdas (since C++11) require explicit capture syntax (
[&],[=],[x],[&x]). By-reference captures can mutate the original through the reference. By-value captures are copies that are const by default; themutablekeyword removes the const so the lambda can modify its own copies. - Python: closures can read variables from the enclosing scope freely. Assigning to one inside the closure creates a new local by default; use
nonlocalto make the assignment modify the outer variable instead. - JavaScript: closures can both read and write variables from the enclosing scope. Assigning to a captured variable modifies the outer one directly, no keyword required.
- Go: function literals capture variables by reference, with no capture-mode control.
- Rust: capture is automatic and minimal by default;
moveforces by value. TheFnfamily describes how the resulting closure can be called.
The takeaway: a closure is an anonymous function plus its captured environment. Rust captures in the least restrictive way that compiles, the move keyword forces by-value capture, and the Fn / FnMut / FnOnce traits describe how the resulting closure can be called.
🔇 You have been missing a lot of tips lately. Ferris is keeping a list. You are on it.
Cow<T>, Borrow Or Own, Decide Later
🦀 Tip of the day 32: Cow<T>, Borrow Or Own, Decide Later
Last week we saw some std smart pointers; this week we cover another batch. Here is a high-level recap of the family, so you have the full picture of what exists in std.
Already covered:
Box(tip 28): heap allocation.Rc(tip 29): shared ownership, single-threaded.RefCell(tip 30): interior mutability, single-threaded.
This week:
Cow(today): borrow-or-own, no commitment upfront.Arc+Mutex: thread-safe versions ofRcandRefCell.
Pin is the last std smart pointer in the family. We cover it at some point after async/await.
Now today’s. Sometimes a function does not know in advance whether it will hand back a borrow of its input or build something fresh. The usual workaround is to always allocate (clone the result on the way out) or expose two functions. Rust has a third option: Cow<'a, T>, the “clone on write” smart pointer.
Cow (in std::borrow::Cow) is an enum with two variants:
Borrowed(&'a T): holds a reference to someone else’s data.Owned(...): holds an owned counterpart of T (Stringforstr,Vec<U>for[U], and so on).
The two flavors you will use most are Cow<'a, str> and Cow<'a, [T]>.
#![allow(unused)]
fn main() {
use std::borrow::Cow;
fn normalize(s: &str) -> Cow<'_, str> {
if s.contains('\r') {
Cow::Owned(s.replace('\r', "")) // had to allocate
} else {
Cow::Borrowed(s) // no work needed
}
}
let clean = normalize("hello world"); // Borrowed: no allocation
let dirty = normalize("hello\rworld"); // Owned: a new String
}
The caller treats both the same way. Cow<str> derefs to &str, so methods like len() or contains("x") work without caring which variant is inside. You only pay an allocation when one was genuinely needed.
For the “borrow first, mutate later” pattern, Cow has .to_mut(). It returns a &mut to the owned value, cloning on the first call if the Cow was a borrow:
#![allow(unused)]
fn main() {
let mut c: Cow<str> = Cow::Borrowed("hello");
c.to_mut().push_str(" world"); // clones into String, then mutates
println!("{}", c); // "hello world"
}
After to_mut(), the Cow is Owned. Subsequent calls do not clone again.
A few honest costs:
Cow<'_, T>is larger than&T. ForCow<str>andCow<[T]>, niche optimization tucks the discriminant inside, so the total size matchesStringorVec<T>exactly.- Every deref is a match on the variant. The compiler usually inlines it away, but not always.
- The branching does not vanish.
Cowunifies the return type, not the work; the producer still decides whether to allocate.
The takeaway: Cow<'a, T> is a single type that holds either a borrowed or an owned value, so a function can return either without committing in its signature. Use it to skip allocations that were not actually needed.
🔇 Cow stands for clone-on-write. The mooing is implicit. 🐄
Follow-up
🦀 Tip of the day 32 follow-up
Follow-up questions from the discussion on Tip 32: Cow<T>, Borrow Or Own, Decide Later.
If a function returns a Cow that borrows from its input, does the borrow checker always treat the input as borrowed, even when the value handed back is actually owned at runtime?
Yes, exactly. The borrow checker operates on types and lifetimes, not on runtime values. It cannot peek at which variant the Cow actually holds, so it conservatively assumes the return borrows from the input for the full lifetime named in the signature.
Take normalize from the tip, whose signature ties the returned Cow to the lifetime of s:
#![allow(unused)]
fn main() {
let mut dirty = String::from("hello world");
let cleaned = normalize(&dirty);
dirty.push_str("!"); // error: dirty is still borrowed by cleaned
// even though cleaned is Owned at runtime
use_it(cleaned);
}
The input "hello world" has no \r, so at runtime cleaned is the Owned variant and references nothing. The compiler does not know that. The signature says the result may borrow from s, so the borrow holds for as long as cleaned is alive.
If you want to sever the link, call .into_owned(). It gives you the owned value directly, cloning only if the Cow was a borrow:
#![allow(unused)]
fn main() {
let cleaned: String = normalize(&dirty).into_owned();
dirty.push_str("!"); // fine: cleaned no longer references dirty
}
That is the genuine tax of Cow: you get runtime flexibility, but static analysis assumes the borrow case. The compiler does not get to be smarter than the signature.
Arc<T> and Mutex<T>, the Thread-Safe Versions
🦀 Tip of the day 33: Arc<T> and Mutex<T>, the Thread-Safe Versions
Tip 29 introduced Rc<T> and tip 30 introduced RefCell<T>, both for single-threaded code. The compiler refuses to share either across threads, because the bookkeeping inside them (refcount for Rc, borrow tracker for RefCell) would race.
For multi-threaded shared ownership and mutation, the standard library provides Arc<T> and Mutex<T>, the atomic counterparts.
First, threading basics. std::thread::spawn takes a closure and runs it on a new thread:
#![allow(unused)]
fn main() {
use std::thread;
let handle = thread::spawn(move || {
println!("hello from a thread");
});
handle.join().unwrap(); // wait for the thread to finish
}
The move keyword on the closure (tip 31) forces it to take ownership of any variables it captures from the enclosing scope. Without move, the closure would borrow them, and the borrow checker would refuse because the thread might outlive those borrows.
Now, sharing data with the spawned thread. Arc<T> is the atomic version of Rc<T>:
#![allow(unused)]
fn main() {
use std::sync::Arc;
use std::thread;
let shared = Arc::new(vec![1, 2, 3]);
let copy = Arc::clone(&shared);
let handle = thread::spawn(move || {
println!("thread sees: {:?}", copy);
});
handle.join().unwrap();
}
Arc::clone does the same thing as Rc::clone (bumps a refcount, hands you another pointer) except the count is updated atomically, which is what makes it thread-safe.
That gets you shared reads. For shared writes across threads, wrap the data in Mutex<T>. A Mutex is a lock; calling .lock() gives you exclusive access until the returned guard is dropped.
#![allow(unused)]
fn main() {
use std::sync::{Arc, Mutex};
use std::thread;
let counter = Arc::new(Mutex::new(0));
let mut handles = vec![];
for _ in 0..4 {
let c = Arc::clone(&counter);
handles.push(thread::spawn(move || {
let mut num = c.lock().unwrap();
*num += 1;
}));
}
for h in handles { h.join().unwrap(); }
println!("final: {}", counter.lock().unwrap()); // 4
}
Arc<Mutex<T>> is the standard pattern for shared mutable state across threads. The Arc lets every thread own a reference to the lock; the Mutex ensures only one thread is inside the critical section at a time.
When does the lock release? When the MutexGuard returned by .lock() is dropped. In the loop above that is the closing } of the closure body, right after *num += 1. There is no unlock to call and nothing to forget. To release earlier, scope the guard with a { } block or call drop(num) explicitly:
#![allow(unused)]
fn main() {
{
let mut num = c.lock().unwrap();
*num += 1;
} // lock released here, other threads can take it now
do_expensive_work_without_the_lock();
}
Same RAII pattern as C++’s std::lock_guard, and the opposite of C-style lock()/unlock() pairs where forgetting the unlock is a classic bug.
A few honest costs:
Arcclones do an atomic increment. Atomics are slower than non-atomic operations but cheap on a per-operation basis.Mutex::lock()blocks if another thread holds the lock. Lock contention is real and can dominate performance.lock()returnsResult<MutexGuard, _>because the lock can be “poisoned” if a thread panicked while holding it..unwrap()is fine for most code.
Comparison:
- Java:
synchronizedblocks and methods, plusjava.util.concurrent.locks.ReentrantLock. Same model, no compile-time check that you actually held the lock when accessing the data. - Go: channels are the preferred primitive, but
sync.Mutexexists. Either way, no compile-time guarantee that shared data is locked before access. - C++:
std::mutexplusstd::shared_ptr<T>for shared ownership. Nothing forces you to lock before access, and the resulting data race is undefined behavior. - Rust: the type system enforces lock-before-access. You cannot get a
&mut Tto data inside aMutexwithout going throughlock(), which guarantees you hold the lock.
The takeaway: Arc<T> is the thread-safe Rc, Mutex<T> is thread-safe interior mutability, and Arc<Mutex<T>> is the standard pattern for shared mutable state across threads. The atomic bookkeeping is real cost; pay it only when threads are involved.
🔇 Data races, deadlocks, poisoned locks. The compiler caught one. The other two are character-building.
Follow-up
🦀 Tip of the day 33 follow-up
Follow-up questions from the discussion on Tip 33: Arc<T> and Mutex<T>, the Thread-Safe Versions.
If a Mutex must wrap the data it protects, what do you do when two separate pieces of data need to be protected together?
You package them into one struct and wrap that struct in a single Mutex:
#![allow(unused)]
fn main() {
struct Account {
balance: i64,
history: Vec<Transaction>,
}
let account = Arc::new(Mutex::new(Account { balance: 0, history: vec![] }));
}
One lock, one critical section, and both fields move together. A thread that holds the guard sees balance and history in a consistent state, and no other thread can observe a half-finished update.
This is really a design nudge. If two pieces of data must stay consistent with each other, they usually belong in the same struct anyway, and the Mutex just makes that grouping explicit.
If the data is genuinely independent, you are free to use separate Mutex values. Rust will not stop you. But now you own the classic problem of lock ordering: the moment one thread can hold lock A while waiting for lock B, and another holds B while waiting for A, you have a deadlock. Which brings us to the philosophers.
Does Rust prevent deadlocks the way it prevents data races? How would you write the dining philosophers in Rust?
No. Rust’s type system prevents data races, not deadlocks. Those are different problems.
A data race is two threads touching the same memory at the same time with at least one write and no synchronization. That is what Arc<Mutex<T>> and the borrow checker rule out: you cannot reach the data without going through lock(), so by the time you hold a &mut T, you provably hold the lock.
A deadlock is two or more threads each holding one resource and waiting forever for another. The borrow checker has nothing to say about two threads that each grab one fork and politely wait for the other. Both threads are perfectly synchronized. They are just stuck.
So the dining philosophers in Rust look the same as in C++ or Java, and they need the same fixes:
- Global lock ordering: every philosopher always takes the lower-numbered fork first.
try_lockwith backoff: if you cannot get the second fork, put the first one back and retry.- A single coordinator (a waiter) that hands out permission to eat.
- Or just one
Mutexover the whole table, trading concurrency for simplicity.
A sketch of the lock-ordering fix:
#![allow(unused)]
fn main() {
use std::sync::{Arc, Mutex};
use std::thread;
let forks: Vec<_> = (0..5).map(|_| Arc::new(Mutex::new(()))).collect();
let mut handles = vec![];
for i in 0..5 {
let left = Arc::clone(&forks[i]);
let right = Arc::clone(&forks[(i + 1) % 5]);
// Always lock the lower-indexed fork first to avoid a cycle.
let (first, second) = if i < (i + 1) % 5 { (left, right) } else { (right, left) };
handles.push(thread::spawn(move || {
let _a = first.lock().unwrap();
let _b = second.lock().unwrap();
// eat
}));
}
for h in handles { h.join().unwrap(); }
}
The convention of requesting resources in a fixed order is the same one Dijkstra proposed when he first posed the problem in 1965. Tony Hoare gave it the philosophers-and-forks framing in 1978, and the deadlock it illustrates has outlived every language since.
So Dijkstra and Hoare would still need to seat their guests carefully. Rust only guarantees that once seated, nobody picks up a fork that someone else is already eating with.
Send and Sync, the Marker Traits Behind Thread Safety
🦀 Tip of the day 34: Send and Sync, the Marker Traits Behind Thread Safety
Tip 33 said Rc<T> is single-threaded and Arc<T> is multi-threaded, and the compiler enforces the difference. This tip is on how.
Two traits in the standard library do the work: Send and Sync. They are marker traits, meaning they have no methods. A type either implements them or it does not, and the compiler checks the implementations against operations that cross thread boundaries.
The definitions are tight:
Send: a value of this type can be safely transferred to another thread.Sync: a&Treference can be safely shared with another thread (equivalently,&TisSend).
Saying a type is Send asserts that handing ownership to another thread is safe: nothing inside relies on staying on the original thread, no thread-local handles, no non-atomic counters pointing at neighbors left behind. Saying a type is Sync asserts that two threads can observe the same value through &T at the same time without racing: either there is no interior mutability, or whatever mutation happens is properly synchronized.
The sharper rule for both: Send fails when moving leaves shared state accessible on the old thread and that access isn’t synchronized; Sync fails when an operation through &T mutates state without synchronization.
Most types you write implement both automatically. Send and Sync are auto traits: the compiler implements them for any type whose fields are all Send and Sync. You inherit thread safety just by being made of thread-safe parts.
The interesting cases are when one or both don’t hold. All four combinations exist:
- Both:
i32,String,Vec<T>,Arc<T>,Mutex<T>, most user types. Sendonly:Cell<T>,RefCell<T>. Safe to move, not safe to share by reference.Synconly:MutexGuard<'_, T>. Safe to share by reference, not safe to send.- Neither:
Rc<T>, raw pointers (*const T,*mut T).
The classic example: Rc<T> is not Send. Moving an Rc<T> to another thread moves one handle, not the shared allocation. Other clones may still live on the original thread, and the heap-allocated refcount they all share is a non-atomic usize:
#![allow(unused)]
fn main() {
use std::rc::Rc;
use std::thread;
let a = Rc::new(42);
let b = Rc::clone(&a); // refcount = 2, both handles point to the same allocation
// If Rc were Send, this would compile and the two threads would race:
thread::spawn(move || {
drop(b); // thread B decrements
});
drop(a); // thread A decrements, racing on the same non-atomic counter
}
The move itself is fine; the race comes after, when both threads independently drop or clone their handles. Arc<T> makes that counter atomic, so the same operations become safe. Same shape, different bookkeeping, different trait status.
Rc<T> is also not Sync. Rc::clone takes &self, so handing &Rc<T> to another thread would let both threads call .clone() and bump the same refcount concurrently. Same race, different entry point.
RefCell<T> is the opposite shape: Send but not Sync. Moving the whole RefCell<T> to another thread is fine because the borrow tracker moves with it and only the new thread can touch it. But &RefCell<T> cannot be shared across threads: borrow_mut() takes &self, so two threads could call it at once, race on the non-atomic borrow tracker, and both end up with &mut T to the same data.
Mutex<T> is Sync, because the lock serializes concurrent access.
A small surprise: MutexGuard<T>, the value returned by Mutex::lock(), is not Send. The reason is that the guard releases the lock when dropped, and the lock implementation requires that release to happen on the same thread that took the lock. Sending the guard to another thread would unlock from the wrong thread, which is undefined behavior on most platforms. The compiler refuses, and you typically never notice because there is no good reason to send a guard.
You will almost never write impl Send for MyType or impl Sync for MyType yourself. Both traits are auto-implemented when applicable, and explicitly implementing them is unsafe because you are asserting a property the compiler cannot verify. The places it does come up are when you wrap raw pointers (which are not Send/Sync by default) and need to assert that your wrapper is safe.
The takeaway: Send and Sync are marker traits the compiler tracks to decide which types are safe to move or share across threads. Most types are automatic. The cases that surprise you (Rc not Send, MutexGuard not Send, RefCell not Sync) are exactly the cases where data races would lurk in other languages.
🔇 Send does not send. Sync does not sync. Otherwise, perfectly named.
Panicking, Drop Everything and Run
🦀 Tip of the day 35: Panicking, Drop Everything and Run
If you come from Java, Python, or C#, exceptions are the normal way to signal something went wrong. Throw, catch, recover, continue. Rust deliberately does not work this way. Expected failures use Result (tip 16). panic! is for bugs and impossible states, the kind of error the program cannot meaningfully recover from.
When you call panic!, the current thread starts unwinding: it walks up the stack and drops every local on the way. Files close, locks release, allocations free. RAII still holds. Then, depending on the build configuration, the thread either dies cleanly or the entire process aborts.
You will rarely write panic!("...") yourself. Most panics in real code come from unwrap(), expect(), indexing out of bounds, or integer overflow in dev builds (release builds wrap silently by default).
A panic kills only the panicking thread, not the process:
#![allow(unused)]
fn main() {
use std::thread;
let h = thread::spawn(|| {
panic!("oh no");
});
let result = h.join(); // Err(...) carrying the panic payload
println!("main thread still running");
}
If the panicking thread is main, the process exits. If it is a spawned thread, only that thread ends; the main thread sees Err from join(). This is also why Mutex::lock() returns a Result: a thread that panics while holding the lock leaves it poisoned, and the next acquirer inherits the error (tip 33).
You can catch a panic with std::panic::catch_unwind:
#![allow(unused)]
fn main() {
use std::panic;
let result = panic::catch_unwind(|| {
panic!("boom");
});
assert!(result.is_err());
}
Useful at FFI boundaries (unwinding across the C/Rust boundary is undefined behavior) and in test runners that want to keep going after one test panics. Not a control-flow tool. The closure must be UnwindSafe, and catch_unwind is useless when the binary is built with abort-on-panic.
Which brings us to the build-time knob. How a panic actually behaves is partly set in Cargo.toml:
[profile.release]
panic = "abort"
"unwind" (the default) does what we just described: walk the stack, run every destructor, optionally catch. The per-thread containment from earlier is a consequence of unwinding. "abort" skips all of it: any panic, on any thread, terminates the entire process immediately. No Drop, no catch_unwind, no thread-level isolation.
The library angle: if you spawn a worker thread and want to recover from its panic (a job runner re-queuing a task, a server isolating one request, a thread pool surviving a worker crash), that recovery only works under "unwind". Under "abort" the worker’s panic terminates the whole process. Libraries cannot pick the profile; they can only document that they require unwind.
Why opt out? Smaller binary (no unwind tables), simpler runtime, and a “restart the process” recovery model that fits some domains. It is the only option on bare-metal embedded targets (e.g. thumbv7em-none-eabihf for Cortex-M microcontrollers, riscv32imac-unknown-none-elf for RISC-V) and historically on WebAssembly (wasm32-unknown-unknown). A common choice for CLI tools shipped to end users. Rare in long-running servers and libraries.
When should you panic versus return Err? Panic for programmer errors: an invariant you swore was true, an index you swore was valid. Return Result for environment errors: missing files, bad JSON, network failures. Rough rule: if the user could fix the situation without touching your code, it should be Result.
Comparison:
- Java, Python, C#: exceptions for everything. Catch blocks are normal control flow.
- Go:
panic/recover. The idiom is the same as Rust’s: do not use them for ordinary errors. - C++: several paths. Exceptions are the only standard way to unwind, with destructors.
std::abort()andstd::terminate()end immediately, no destructors.std::exit()runsatexithandlers and destroys statics but skips local destructors. Codebases that ban exceptions (-fno-exceptions) use error codes orstd::expected.
The takeaway: panic! is “this should not happen” for the current thread. It unwinds (or aborts) the thread, runs destructors on the way, and is meant for bugs, not for expected failures.
🔇 panic! does not panic. It cleans up before slamming the door. You do.
Follow-up
🦀 Tip of the day 35 follow-up
Follow-up questions from the discussion on Tip 35: Panicking, Drop Everything and Run.
In unwind mode with catch_unwind, is panic! basically an exception, just without any data about the cause?
Half right. In unwind mode a panic does walk the stack and run destructors, and catch_unwind lets you intercept it at a boundary, so the mechanism rhymes with exceptions. But the “no data about the cause” part is wrong: a panic carries a payload.
When you call panic!, the payload is the message you passed:
#![allow(unused)]
fn main() {
panic!("file not found: {path}"); // payload is a String
std::panic::panic_any(MyError { code }); // payload is your own type
}
panic_any (stable since Rust 1.51) lets you panic with a value of any 'static + Any + Send type, not just a string. That is how you attach structured data to a panic.
On the catching side, catch_unwind returns std::thread::Result<R>, which is Result<R, Box<dyn Any + Send + 'static>>. The Err holds the payload, type-erased. You recover the original type with downcast_ref:
#![allow(unused)]
fn main() {
use std::panic;
let result = panic::catch_unwind(|| {
panic!("boom");
});
if let Err(payload) = result {
if let Some(s) = payload.downcast_ref::<String>() {
eprintln!("panicked with: {s}");
} else if let Some(e) = payload.downcast_ref::<MyError>() {
eprintln!("panicked with error code {}", e.code);
}
}
}
One catch: the payload type for a plain panic! is not guaranteed. A formatted message like panic!("file: {path}") lands as a String, while a bare literal like panic!("boom") lands as a &'static str. The standard library documents this distinction as unspecified and free to change between versions, so do not lean on a specific type when downcasting a generic panic. If you want a payload you can reliably recover, use panic_any with your own type.
So yes, the payload exists, but treating it like an exception’s getCause() is the wrong instinct. catch_unwind is a boundary tool: FFI edges (unwinding across the C/Rust boundary is undefined behavior), test runners that keep going after one test fails, and thread isolation. It is not a control-flow mechanism. The moment you reach for it to model expected failures, you want Result instead.
🔇 The payload is right there, type-erased and waiting. You just have to guess what it is before you are allowed to look at it.
Generics and Trait Bounds, Writing Code for All Types
🦀 Tip of the day 36: Generics and Trait Bounds, Writing Code for All Types
In tip 27 we said fn f(x: impl Trait) accepts any type implementing the trait. That syntax is shorthand for the generic form, which is more flexible and what you reach for when the function needs more than one type parameter or has multiple constraints.
The full generic form uses angle brackets to declare type parameters:
#![allow(unused)]
fn main() {
fn first<T>(v: Vec<T>) -> Option<T> {
v.into_iter().next()
}
let n = first(vec![1, 2, 3]); // T inferred as i32
let s = first(vec!["a", "b", "c"]); // T inferred as &str
}
T is a type parameter. The function works for any T because it only does things that work for any type (move it around, hand it back).
When the function actually needs T to support specific operations, you constrain it with trait bounds:
#![allow(unused)]
fn main() {
use std::fmt::Display;
fn announce<T: Display>(item: T) {
println!("here it is: {}", item);
}
announce(42);
announce("hello");
}
T: Display reads as “I will accept any type as long as it implements Display.” The body is type-checked against the bounds alone, and at each call site the compiler verifies that the supplied type satisfies them. If you pass a type that doesn’t implement Display, the error points to your call rather than somewhere inside announce.
You can require multiple bounds with +:
#![allow(unused)]
fn main() {
fn copy_and_print<T: Display + Clone>(item: T) {
let copy = item.clone();
println!("{} and {}", item, copy);
}
}
When the bounds get long, a where clause keeps the signature readable:
#![allow(unused)]
fn main() {
fn process<T, U>(t: T, u: U) -> String
where
T: Display + Clone,
U: Display + Default,
{
let copy = t.clone();
let fallback = U::default();
format!("{}/{} (copy: {}, fallback: {})", t, u, copy, fallback)
}
}
Functionally identical to inline bounds, just easier to read once there are several.
Two small extras. When the compiler can’t infer T from context, you write it explicitly with the turbofish (tip 15):
#![allow(unused)]
fn main() {
let parsed = "42".parse::<u32>().unwrap();
let empty = Vec::<i32>::new();
}
And type parameters can have defaults. HashMap<K, V> is actually HashMap<K, V, S = RandomState> in the standard library, with the hasher defaulted. You can do the same on your own types:
#![allow(unused)]
fn main() {
struct Buffer<T = u8> {
data: Vec<T>,
}
let b: Buffer = Buffer { data: vec![0, 1, 2] }; // T defaults to u8
let s: Buffer<String> = Buffer { data: vec![] };
}
Defaults work on type definitions (struct, enum, trait, type aliases), not on free functions.
What does the compiler do with all this? Monomorphization. For each combination of concrete types you call the generic function with, the compiler generates a specialized copy tailored to those types. announce(42) and announce("hello") produce two separately compiled versions of announce. This makes generic code as fast as hand-written specialized code, with zero runtime cost.
The price is in compile time and binary size: a heavily generic function that gets used with many types takes longer to build and produces more machine code. For most code the trade is invisible.
Comparison:
- Java: type erasure. Generics are checked at compile time then discarded; at runtime
List<String>andList<Integer>are the sameList. Reflection cannot recover the original type parameter. - C#: reified generics. The CLR keeps type parameters at runtime; the JIT specializes value-type instantiations and shares one implementation across reference types.
typeof(T)works inside generic methods. Closer in spirit to Rust, just done at JIT time rather than AOT. - C++: templates are the closest analogue. One copy per concrete type, no runtime cost, and a Turing-complete compile-time language on top (template metaprogramming, SFINAE, concepts). The big difference is timing: Rust checks generics against the bounds before instantiation, so errors are local to your code; classic C++ templates are checked after instantiation, producing the famously long template error messages. C++20 concepts narrow that gap by checking the constraints at the instantiation point, so a violated
conceptreports at the call site, though the body is still free to use operations the concept never promised. - Go: generics arrived in 1.18 (2022). Type parameters with constraints expressed as interfaces. Runtime cost varies by implementation strategy.
- Python: dynamic at runtime, but static generics have existed since 3.5 (
typing.Generic,TypeVar) and got native syntax in 3.12 (def first[T](v: list[T]) -> T | None). Static checkers like mypy and pyright enforce them; the runtime ignores them, closer to Java’s erasure model than to JavaScript. - JavaScript: no static generics. TypeScript adds them as a separate type-system layer that erases to plain JS at runtime.
- Rust: monomorphization plus trait bounds checked before instantiation. As fast as C++ templates, with friendlier errors. Not as cool for metaprogramming, though: that role goes to
const fnand macros.
The takeaway: <T> declares a type parameter, <T: Trait> constrains it, multiple bounds combine with +, and where clauses keep complex signatures readable. The compiler generates a specialized copy of your function for each concrete type, so generic code costs nothing extra at runtime.
🔇 C++ generic errors: a thousand lines of template instantiation, and somewhere in there is the actual problem. Rust generic errors: the bound on line 3 was not satisfied; here is the call.
Follow-up
🦀 Tip of the day 36 follow-up
Follow-up questions from the discussion on Tip 36: Generics and Trait Bounds, Writing Code for All Types.
Can a single type parameter require two traits at once, like “must be both Display and Copy”?
Yes. That is conjunction, and you write it with +:
#![allow(unused)]
fn main() {
use std::fmt::Display;
fn print<T: Display + Copy>(x: T) {
let copy = x; // Copy lets us duplicate it
println!("{} {}", x, copy);
}
}
The bound T: Display + Copy reads as “T must implement Display and Copy.” You can chain as many as you need.
Watch the syntax, though. This is a common trap:
#![allow(unused)]
fn main() {
fn print<T: Display, Copy>(x: T) // not what you meant
}
Rust reads the comma as a separator between type parameters, so it parses this as “a type parameter T bounded by Display, plus a second type parameter named Copy.” Copy here is just a name, not the trait. The compiler will not stop you from naming a type parameter Copy, which is exactly why this mistake is quiet. Use + to combine bounds on one parameter; use , only to separate distinct parameters.
Can I combine bounds disjunctively, so the function accepts a type that implements Display or Debug, and picks behavior based on which one it has?
Not directly. Generics in Rust are monomorphized: the compiler stamps out one compiled body per concrete T, and that body has to type-check against the bounds you declared, ahead of time. There is no “if T happens to implement Display, call this, otherwise call that” at the signature level. Rust asks you to commit to a fixed set of capabilities up front.
The usual way around it is a macro, because macros operate on syntax before type checking and can branch on a tag you pass in:
#![allow(unused)]
fn main() {
macro_rules! show {
($x:expr; display) => { format!("{}", $x) };
($x:expr; debug) => { format!("{:?}", $x) };
}
let a = show!(42; display); // "42"
let b = show!(vec![1, 2]; debug); // "[1, 2]"
}
This is exactly how println!, dbg!, and friends in the standard library handle formatting: the format string is parsed at compile time, and the macro expands to the right calls.
Is there any real “use Display if the type has it, otherwise fall back to Debug” mechanism, rather than a macro trick?
There is, but it is unstable and you cannot use it on stable Rust. The feature is called specialization, and it lets a more specific trait implementation override a more general one:
#![allow(unused)]
#![feature(specialization)] // nightly only
fn main() {
use std::fmt::{Debug, Display};
trait Pretty {
fn pretty(&self) -> String;
}
// Fallback for any Debug type.
impl<T: Debug> Pretty for T {
default fn pretty(&self) -> String {
format!("{:?}", self)
}
}
// Override for types that ALSO implement Display.
impl<T: Debug + Display> Pretty for T {
fn pretty(&self) -> String {
format!("{}", self)
}
}
42_i32.pretty(); // Display impl -> "42"
vec![1, 2].pretty(); // Vec is Debug only -> "[1, 2]"
}
That is the “use Display if available, fall back to Debug” pattern you were after.
The catch is that specialization has been unstable for years and is still unstable as of 2026, because the general form is unsound. The standard library uses a restricted, sound subset internally (gated behind min_specialization), but neither is available to you on stable.
The core blocker is lifetime-driven specialization. Rust erases lifetimes before code generation: lifetimes are a type-checking artifact and are simply not present in the compiled binary. An implementation chosen based on a lifetime bound would force the compiler to know lifetime information at codegen time, which directly contradicts that erasure. So choosing an implementation by lifetime is the case that breaks soundness, and pinning down a fully sound rule for it is the open problem. The details are tracked in RFC 1210’s tracking issue.
If I want to temporarily add a bound, say to print something while debugging, do I really have to thread that requirement through every caller?
Often, yes, and it is a real friction point with the monomorphized approach. If a deep function suddenly needs T: Debug, that bound has to be satisfiable at every call site, which means it propagates up the call stack: each generic caller that forwards the T now has to declare the bound too.
Library authors work around this by pre-declaring common bounds defensively. You will see signatures carrying T: Clone + Debug + Send + Sync even when not every method needs all of them, precisely to spare future-them the propagation churn when one of those capabilities later becomes necessary.
A where clause keeps that manageable to read, even when several parameters each carry several bounds:
#![allow(unused)]
fn main() {
use std::fmt::Display;
fn process<T, U>(t: T, u: U) -> String
where
T: Display + Clone,
U: Display + Default,
{
format!("{} {}", t, u)
}
}
There is also a design lever. Generic functions tend to drift toward one of two shapes: tightly bounded, which risks rejecting caller types for capabilities they did not actually need, or structured so the deep function never takes T directly. A common version of the second shape is to take a closure that the caller produces, which pushes the bound onto the closure and out of your signature. The temporary-debug case is usually not worth restructuring for, but it is worth knowing the bound does not have to live on the generic function forever.
Can I constrain a trait implementation to a specific const generic value, so a method exists only for, say, 3D vectors?
Yes. Const generics let a type be parameterized by a constant, and you can write implementations for one specific value of that constant. A cross product, for example, is only defined in three dimensions, so you can offer it for Vector<3> and nothing else:
#![allow(unused)]
fn main() {
#[derive(Debug)]
struct Vector<const N: usize> {
coords: [f32; N],
}
impl<const N: usize> Default for Vector<N> {
fn default() -> Self {
Self { coords: [0.0; N] }
}
}
trait CrossProduct {
fn cross(&self, rhs: &Self) -> Self;
}
trait Absolute {
fn abs(&self) -> f32;
}
// Cross product exists only for 3D.
impl CrossProduct for Vector<3> {
fn cross(&self, rhs: &Self) -> Self {
Self {
coords: [
self.coords[1] * rhs.coords[2] - rhs.coords[1] * self.coords[2],
self.coords[2] * rhs.coords[0] - rhs.coords[2] * self.coords[0],
self.coords[0] * rhs.coords[1] - rhs.coords[0] * self.coords[1],
],
}
}
}
// Magnitude exists for every dimension.
impl<const N: usize> Absolute for Vector<N> {
fn abs(&self) -> f32 {
self.coords.iter().map(|x| x * x).sum::<f32>().sqrt()
}
}
}
Now the dimension is part of the type, and the compiler enforces the distinction:
#![allow(unused)]
fn main() {
let x: Vector<3> = Vector { coords: [1.0, 0.0, 0.0] };
let y: Vector<3> = Vector { coords: [0.0, 1.0, 0.0] };
let z = x.cross(&y); // fine, both are 3D
let p: Vector<2> = Vector { coords: [1.0, 0.0] };
println!("{}", p.abs()); // fine, abs works for any N
// p.cross(&p); // error: cross is not defined for Vector<2>
}
cross is reachable only on Vector<3>, while abs is generic over every N. The wrong-dimension call is a compile error, not a runtime check. Const generics have been stable since Rust 1.51 (2021), so none of this needs nightly.
dyn Trait, Runtime Polymorphism
🦀 Tip of the day 37: dyn Trait, Runtime Polymorphism
Generics from tip 36 give you static polymorphism: each call-site type produces a separately compiled version of the function. Fast, but limited. Two limits show up quickly:
- A
Vec<T>holds one type. You cannot putCat,Dog, andCowtogether in one collection through generics alone. - A struct field of type
Tmakes the whole struct generic (S<T>), and that type is fixed when the value is built. When you want a single, non-generic struct whose field holds a type chosen at runtime, generics cannot express that.
For these cases, Rust offers the second flavor: dyn Trait, runtime polymorphism through trait objects.
#![allow(unused)]
fn main() {
trait Animal {
fn sound(&self) -> &'static str;
}
struct Cat;
struct Dog;
impl Animal for Cat { fn sound(&self) -> &'static str { "meow" } }
impl Animal for Dog { fn sound(&self) -> &'static str { "woof" } }
let zoo: Vec<Box<dyn Animal>> = vec![
Box::new(Cat),
Box::new(Dog),
];
for a in &zoo {
println!("{}", a.sound());
}
}
The second case is the struct field, and it is the clearer reason to choose dyn over a generic:
#![allow(unused)]
fn main() {
struct Exhibit {
featured: Box<dyn Animal>, // which species is featured is decided at runtime
}
}
Written as struct Exhibit<A: Animal> { featured: A }, the species would be fixed when the Exhibit is built, and every type holding an Exhibit would have to name A (or become generic itself). The trait object keeps Exhibit a single plain type and defers the choice to runtime, which is exactly what you want when the species comes from config or user input rather than the source.
A bare dyn Animal has no size known at compile time (a Cat and a Dog can differ in size), so you always reach it through a pointer: Box<dyn Animal>, &dyn Animal, and so on. That is why you never see a plain dyn Animal by value.
Box<dyn Animal> is a trait object, and it is two pointers wide: one to the value, one to a vtable, a small lookup table of that type’s method implementations. Calling a.sound() becomes a vtable lookup followed by an indirect call.
Java and C++ do the same thing with one layout difference. There the vtable pointer lives inside the object, so every instance carries it whether or not you ever dispatch on it. In Rust the vtable pointer rides in the trait object instead, and the payoff is concrete: a Cat used by its own type stays exactly as big as its fields and dispatches statically, paying nothing. The extra pointer word and the indirection appear only where you erase it to dyn, and only while you hold it that way.
You can also borrow a trait object with &dyn Trait (or &mut dyn Trait):
#![allow(unused)]
fn main() {
fn announce(a: &dyn Animal) {
println!("an animal says: {}", a.sound());
}
announce(&Cat);
announce(&Dog);
}
Compare with the generic fn announce<T: Animal>(a: &T). The generic version produces a specialized copy per type. The dyn version produces one copy that serves all of them through the vtable. The trade-off:
- Generics /
impl Trait: no indirection, and the compiler can inline the call. But the function exists in multiple compiled forms, and you cannot store mixed types in one collection. dyn Trait: one indirect call the compiler usually cannot inline (the lost inlining often costs more than the indirection itself), in exchange for a single compiled form and heterogeneous values held together.
For most code the per-call cost is invisible. Default to generics when each call site has one type; reach for dyn Trait when you genuinely need a heterogeneous collection or runtime polymorphism. The same dyn works behind Rc and Arc, not just Box: Rc<dyn Animal>, or Arc<dyn Animal + Send + Sync> when the object crosses threads.
A constraint worth knowing: not every trait can become a trait object. The trait must be dyn compatible (these rules were called object safety until they were renamed around Rust 1.84, so you will see both terms). The common disqualifiers are generic methods and methods that take or return Self by value, because the vtable would need one entry per concrete type, or a return size it cannot know at the call site. Clone is the classic example: fn clone(&self) -> Self returns Self, so there is no Box<dyn Clone>. Everyday traits like Display, Debug, and Iterator are fine, and the compiler tells you plainly when you hit one that is not.
Comparison:
- Java: interface calls dispatch through a vtable, with no static-dispatch alternative in the language, though the JIT often devirtualizes hot calls at runtime.
- C#: interface calls are virtual too, but generics over value types are specialized by the JIT, so a generic constrained to an interface gets static dispatch when the type is a struct. Two paths, like Rust.
- C++:
virtualmethods give dynamic dispatch, templates give static. The choice mirrors Rust’sdynversus generics. - Python, JavaScript: every method call is dynamic, with no static-dispatch option in the language itself.
- Go: interface calls go through a method table, the same idea as a vtable. Generics (since 1.18) only partly monomorphize: the compiler groups types by memory shape and shares one copy across all pointer types, reaching methods through a dictionary, so a call on a type parameter is often still indirect rather than the zero-cost static dispatch Rust gets.
- Rust: both options exist as separate constructs, and the trade-off is visible in the syntax you write.
The takeaway: dyn Trait is runtime polymorphism through a vtable. Use Box<dyn Trait>, &dyn Trait, or Rc/Arc<dyn Trait> when you need to hold values of different types in one place, and accept one indirect, un-inlinable call per method in exchange.
🔇 dyn is one vowel from “dine,” which fits. You place your order, the vtable fetches it, dispatch serves it. Never instant, always one table away.
Follow-up
🦀 Tip of the day 37 follow-up
Follow-up questions from the discussion on Tip 37: dyn Trait, Runtime Polymorphism.
If every call on a dyn Trait is virtual, is there any way to get a non-virtual call, for example to implement something like the non-virtual interface (NVI) pattern?
Not every call involving a dyn is virtual, and NVI maps onto Rust cleanly.
A call is virtual when the function to run is chosen at runtime by looking it up in the vtable. In Rust that happens in exactly one situation: you call a method on a dyn Trait value, and that method has a slot in the vtable. So the real question is “what ends up in the vtable?”, and the answer is less than you might expect.
These are never virtual:
- Inherent methods (
impl Cat { fn purr(&self) {} }): they belong to no trait, so they never appear in any vtable. Always static. - Methods marked
where Self: Sized: deliberately excluded from the vtable. They can only be called on a concrete type, never through&dyn. This is the explicit “this method is not part of the dynamic interface” knob. - Methods from a different trait supplied by a blanket impl: resolved at compile time.
- Calls a default method makes to
self’s other trait methods. This is the subtle one, covered below.
Only the trait’s own dispatchable methods, invoked through the &dyn, go through the vtable.
The subtle case is worth seeing directly:
#![allow(unused)]
fn main() {
trait Animal {
fn sound(&self) -> &'static str; // required
fn announce(&self) { // provided (default)
println!("{}", self.sound());
}
}
}
The (Cat, Animal) vtable is built per concrete type. Its announce slot points at the default body instantiated for Cat. Inside that instantiation self has type &Cat, so self.sound() resolves to Cat::sound at compile time: static, and inlinable. So some_dyn.announce() is one virtual hop to enter announce, and the sound() inside it is static. Default methods never cause double dispatch. Called on a concrete Cat, even entering announce is static.
You can confirm this in the generated code. Calling announce on a &dyn Animal loads one slot from the vtable and makes a single indirect call. The Cat instantiation of the default announce body, by contrast, inlines and constant-folds self.sound() away entirely, even with sound marked #[inline(never)]. The inner call is not a second vtable lookup.
That is exactly the template-method shape: fixed logic in announce, the variable part isolated in sound.
How do I translate C++’s non-virtual interface (NVI) pattern, a public non-virtual method wrapping a private virtual hook, into Rust?
C++ NVI is a public non-virtual method that does the fixed pre/post work and calls a virtual hook:
void process() { log("start"); do_process(); log("done"); } // non-virtual, fixed
virtual void do_process() = 0; // the hook
Two ingredients: a fixed, non-overridable public method, and a virtual customization point. Rust gives you both. There are two ways, depending on how strict you need the “non-overridable” guarantee to be.
Version A, a default method. Lightweight and idiomatic:
#![allow(unused)]
fn main() {
trait Widget {
fn do_process(&self); // the hook, virtual through &dyn
fn process(&self) { // the wrapper
println!("start");
self.do_process(); // static inside this instantiation
println!("done");
}
}
}
This is how Iterator is built: you write next, and the dozens of adapters are the fixed interface layered on top. It is clean and common. The one gap versus strict NVI is that an impl Widget for Foo may override process. Rust has no final, so the default-method wrapper is not guaranteed non-overridable.
Version B, a sealed extension trait. This is the faithful, non-overridable form. Put the wrapper on a separate trait, supplied once via a blanket impl:
#![allow(unused)]
fn main() {
trait Widget { // the hooks only
fn do_process(&self);
}
trait WidgetApi { // the fixed public interface
fn process(&self);
}
impl<T: Widget + ?Sized> WidgetApi for T {
fn process(&self) {
println!("start");
self.do_process(); // virtual when T = dyn Widget, static when T = Cat
println!("done");
}
}
fn run(w: &dyn Widget) {
w.process(); // process(): static, resolved to the blanket impl
// do_process(): virtual, the single vtable lookup
}
}
The ?Sized bound on the blanket impl is what lets T be dyn Widget itself, so the wrapper applies to trait objects as well as concrete types.
You can argue this is a stronger form of NVI than C++ offers. The non-virtuality of the public interface is guaranteed by coherence, no other impl can provide process, rather than by you remembering not to mark it virtual. The virtual surface is exactly whatever you put in the core trait, and everything built on top of it is static.
You can keep loving the pattern.
Associated Types and Default Methods, or How Iterator Really Works
🦀 Tip of the day 38: Associated Types and Default Methods, or How Iterator Really Works
You have been using iterators since tip 15. .map(), .filter(), .collect(), .sum(), dozens of others. They all come from the Iterator trait, but tip 27 only showed you traits with required methods. So how does one trait give you a hundred methods?
Two trait features we have not yet shown: default methods and associated types. They are how Iterator actually works.
The simplified definition of Iterator looks roughly like this (real signatures have a few more details):
#![allow(unused)]
fn main() {
trait Iterator {
type Item; // associated type
fn next(&mut self) -> Option<Self::Item>; // required method
// many default methods, including:
fn count(self) -> usize { /* default body uses next() */ }
fn map<B, F>(self, f: F) -> /* ... */ { /* default body */ }
// ... and many more
}
}
Two new features in there.
First, type Item;. This is an associated type: a type that the trait carries with it, supplied by the implementor. When you implement Iterator for your own type, you also pick what Item is. This is different from a generic parameter: a generic parameter is supplied by the caller, an associated type is supplied by the implementor. Each iterator type has exactly one Item.
Second, the count and map definitions in the trait body. These are default methods: the trait provides an implementation, and types that implement the trait get those methods for free unless they choose to override. Iterator ships dozens of these. You implement next(), and you get map, filter, take, collect, sum, and friends without writing a single one.
Implement Iterator once and the payoff is clear:
#![allow(unused)]
fn main() {
struct CountUpTo {
current: u32,
max: u32,
}
impl Iterator for CountUpTo {
type Item = u32;
fn next(&mut self) -> Option<u32> {
if self.current < self.max {
let n = self.current;
self.current += 1;
Some(n)
} else {
None
}
}
}
let counter = CountUpTo { current: 1, max: 5 };
let sum: u32 = counter.map(|n| n * 2).sum(); // 2 + 4 + 6 + 8 = 20
}
We wrote next, the only required method. The trait gave us map and sum (and filter, take, collect, dozens more) automatically. As a bonus, for x in counter { ... } works too: a for loop calls .into_iter() first (tip 15), and every Iterator already counts as an IntoIterator that returns itself, so the loop just keeps calling next().
Why associated types instead of a generic parameter (trait Iterator<T>, the way you would reach for a C++ template parameter or a Java generic)? Because each iterator yields exactly one element type, and fixing it is what makes type inference work.
With a generic Iterator<T>, one type could have many impls: impl Iterator<i32> for CountUpTo and impl Iterator<String> for CountUpTo would both be legal. Now when you write counter.next(), which one did you mean? The compiler cannot tell, so you would have to annotate the element type at every call, and a chain like .map(...).filter(...).collect() would need a hint at each step.
An associated type removes the choice. Given the iterator type, Item is uniquely determined, so the compiler infers it for free and the chain just flows. The implementor picks Item once; everyone else gets it for nothing.
You will see associated types throughout the standard library: Iterator::Item, IntoIterator::Item, Add::Output, Index::Output, Future::Output. Every one of them is “the type that comes out,” fixed per implementor.
Comparison:
- Java: interfaces had only abstract methods until Java 8 added
defaultmethods. Generic interfaces use type parameters (Iterable<T>), so the element type lives in the interface’s name; there is nothing equivalent to associated types. Java still fixes that type per implementor: you cannot implementIterable<String>andIterable<Integer>on one class. - C#: same default-method story (C# 8), but C# does let one class implement
IEnumerable<int>andIEnumerable<string>at once. That is exactly the ambiguity associated types forbid: withItem, each implementor has one element type and no more. - Haskell: type classes have associated types (with the
TypeFamiliesextension) and default method implementations. This is where Rust borrowed both ideas. - C++: you can get close. A type carries a nested alias (
using value_type = int;), and a concept (C++20) can require it (requires { typename It::value_type; }). The difference is ownership: that alias belongs to the type, not the concept, so every concept mentioningvalue_typeshares the same one. A Rust associated type belongs to the trait, so one type can carry an independentIterator::Item,Add::Output, andFuture::Outputat once, each fixed by its own impl. - Rust: both built into traits from the start, used heavily across the standard library.
The takeaway: traits can carry associated types (one per implementor) and default method implementations. Iterator uses both. You implement next() and pick Item, and the standard library hands you the rest of the toolkit.
🔇 The trait saves you fifty functions of typing. Don’t get excited: you will pay every minute back fighting the borrow checker.
From and Into, the Conversion Pair
🦀 Tip of the day 39: From and Into, the Conversion Pair
You have written all of these without thinking about it:
#![allow(unused)]
fn main() {
let s = String::from("hello"); // tip 23
let s = "hello".to_string();
let s: String = "hello".into();
}
All three produce a String from a &str, but they do not share one mechanism. String::from("hello") and "hello".into() are the two halves of the pair this tip is about: From and Into. The middle one, "hello".to_string(), comes from a different trait, ToString (which is built on Display), not from From at all. So really there are two syntaxes for one idea, plus a lookalike.
From defines a conversion from one type into the type implementing it:
#![allow(unused)]
fn main() {
trait From<T> {
fn from(value: T) -> Self;
}
}
String implements From<&str>, which is what String::from("hello") calls. Implementing From for your own type is straightforward:
#![allow(unused)]
fn main() {
struct Email(String);
impl From<String> for Email {
fn from(s: String) -> Self {
Email(s)
}
}
impl From<&str> for Email {
fn from(s: &str) -> Self {
Email(s.to_string())
}
}
let e1: Email = String::from("a@b.com").into();
let e2: Email = "c@d.com".into();
}
The Into trait is the inverse direction. If T: From<U>, then U: Into<T> automatically. The standard library provides the matching Into impl through a blanket implementation, so you only ever write impl From. The into() form lets the caller pick the conversion target; From::from lets you write the target type explicitly.
So which trait do you use? Implement From; you never implement Into. Reach for Into only as a bound, as in fn send(to: impl Into<Email>), which lets a function accept anything convertible into what it needs. The two are just spellings of the same conversion, so the choice is ergonomics: where Email: From<T> and impl Into<Email> express the same requirement, and the Into form only reads in the direction the value flows.
When should you implement From? Whenever a conversion is total, infallible, and obvious. The Email above is a thin wrapper that stores whatever string it is handed, so wrapping never fails. The moment a conversion can reject its input it does not belong in From: validating that a string is actually a well-formed email address can fail, and so can parsing a string into an integer. Those use a different trait, TryFrom, which returns Result.
Now the part that explains a piece of magic from tip 16. The ? operator does more than early-return on Err. It also calls From::from on the error value. So when one function returns Result<T, ErrorA> and another returns Result<T, ErrorB>, you can chain them with ? if ErrorA: From<ErrorB>:
#![allow(unused)]
fn main() {
struct AppError;
impl From<std::io::Error> for AppError {
fn from(_: std::io::Error) -> Self {
AppError
}
}
fn read_config() -> Result<String, AppError> {
let text = std::fs::read_to_string("config.toml")?; // io::Error -> AppError automatically
Ok(text)
}
}
Each ? checks for Err. If found, it converts the inner error using the From impl and returns. This is how a single application error type can absorb errors from across the standard library and other crates without explicit conversion code at every call site.
Comparison:
- C++: implicit conversion constructors do something similar, and are notoriously easy to abuse, which is why modern style guides push
expliciton single-argument constructors (C++20 even addedexplicit(bool)for conditionally explicit ones). - C#: the closest analog. A
public static implicit operator Email(string s)is a safe conversion the compiler applies on its own;explicit operatorneeds a cast and is for lossy ones. For conversions that can fail, C# 11 addedIParsable<T>withTryParse, the real parallel toTryFrom. - Java: no user-defined conversions at all. You write factory methods or constructors and call them by name.
- Python: explicit constructors plus conversion dunders like
__int__and__str__, invoked throughint(x)orstr(x). No implicit user conversions. - Kotlin: no implicit conversions either, not even
InttoLong; you call.toLong(). Construction goes throughcompanion objectfactories. - Rust:
Fromplus a blanketIntoimpl, with?integration for error types. Conversions are explicit at the call site (someone wroteinto()orfrom()or used?), but the rest is plumbing the compiler handles.
The takeaway: implement From<U> to define how to convert U into your type, and you get Into<YourType> for free. The same machinery powers ?’s automatic error conversion. Reach for From whenever a conversion is total and obvious; reach for TryFrom when it can fail.
🔇 Python: explicit is better than implicit. C++: needs a keyword named explicit just to turn the implicit ones off. Rust: nods along, then has a single ? quietly do three things you never wrote, check for Err, convert it via From::from, and return.
Follow-up
🦀 Tip of the day 39 follow-up
Follow-up questions from the discussion on Tip 39: From and Into, the Conversion Pair.
If I implement From<A> for B and From<B> for C, can the compiler chain those conversions and turn an A straight into a C?
No. The compiler will not chain conversions, and there are two separate reasons your A does not reach C.
First, plain assignment never converts at all. This fails:
#![allow(unused)]
fn main() {
struct A;
struct B;
impl From<A> for B {
fn from(_: A) -> Self { B }
}
let a = A;
let b: B = a; // error: expected `B`, found `A`
}
Rust does zero implicit conversion on let or assignment. Even with From<A> for B in scope, you always have to ask for the conversion explicitly with .into() or B::from(a). The compiler will helpfully suggest adding .into(), but it will not insert it for you.
Second, even once you do ask, From and Into are not transitive. Add the conversions and try to hop two links at once:
#![allow(unused)]
fn main() {
struct A;
struct B;
struct C;
impl From<A> for B {
fn from(_: A) -> Self { B }
}
impl From<B> for C {
fn from(_: B) -> Self { C }
}
let a = A;
let c: C = a.into(); // error: the trait `From<A>` is not implemented for `C`
}
The blanket Into impl derives exactly one Into from one From. It is a single hop: A: Into<B> and B: Into<C> exist, but A: Into<C> does not. The compiler can even see the adjacent From<B> for C link and tells you about it in the error, yet it still refuses to bridge A to C.
That refusal is deliberate. Transitive conversion would make conversion paths ambiguous: if two different routes from A to C existed, the compiler would have to pick a winner, and silent multi-step coercions are exactly the kind of magic Rust avoids. If you want the two-step conversion, write it out: let c: C = B::from(a).into();.
The same one-hop rule applies to the ? operator. The error conversion that ? performs calls From::from exactly once on the error value, it does not search for a chain. So if a function returns Result<_, ErrA> and the enclosing function returns Result<_, ErrC>, ? needs ErrC: From<ErrA> directly. Having From<ErrA> for ErrB and From<ErrB> for ErrC will not satisfy it:
#![allow(unused)]
fn main() {
struct ErrA;
struct ErrB;
struct ErrC;
impl From<ErrA> for ErrB { fn from(_: ErrA) -> Self { ErrB } }
impl From<ErrB> for ErrC { fn from(_: ErrB) -> Self { ErrC } }
fn inner() -> Result<(), ErrA> { Err(ErrA) }
fn outer() -> Result<(), ErrC> {
inner()?; // error: `?` couldn't convert the error: `From<ErrA>` is not implemented for `ErrC`
Ok(())
}
}
One From, one hop, no exceptions.
Modules, How Rust Splits a Program Into Pieces
🦀 Tip of the day 40: Modules, How Rust Splits a Program Into Pieces
Tip 10 introduced inline modules: mod foo { ... } to group related code in the same file. That works for small examples but not for projects with thousands of lines. The same mod keyword has a second form that pulls code in from another file.
Two ways to declare a module:
#![allow(unused)]
fn main() {
mod inline {
pub fn hello() {}
}
mod external; // looks for external.rs (or external/mod.rs) in the same directory
}
When you write mod external; with a semicolon instead of a body, the compiler loads the contents of external.rs (or external/mod.rs) and treats them as if they had been written inline. The two forms are equivalent in every other way.
The flip side: a .rs file that has no mod declaration points at is not part of your crate at all. The compiler never reads it. An orphaned file compiles to nothing and produces no error, which surprises people who expect every file in src/ to matter.
So is every file a module? Effectively yes: a file becomes a module the instant some mod points at it, and the root file is the crate’s top module. But the mapping runs one way only. A single module’s items come from exactly one place, its inline { } block or its one file; you cannot split one module across two files. A module can contain submodules that live in their own files, but it is never itself spread across them. (Go differs: one package spans many files. So do C# namespaces.)
The module tree starts at one root, one tree per crate. A crate is the library or binary the compiler builds as a single unit; the Cargo tip covers crates in full. That root is src/lib.rs for a library, src/main.rs for a binary. Every module is reached by descending from there. A typical layout for a small library:
src/
├── lib.rs // module root
├── parser.rs // mod parser; in lib.rs
├── runtime.rs // mod runtime; in lib.rs
└── runtime/
├── value.rs // mod value; in runtime.rs
└── env.rs // mod env; in runtime.rs
Inside lib.rs, you write mod parser; and mod runtime;. Inside runtime.rs, you write mod value; and mod env;. The compiler walks the tree.
That runtime.rs-next-to-runtime/ layout is the modern style. Before the 2018 edition, a module with submodules had to live in runtime/mod.rs, so a large project accumulated dozens of files all named mod.rs, indistinguishable in your editor’s tabs. The 2018 edition let runtime.rs sit beside a runtime/ directory instead. Both styles still compile, so you will meet mod.rs in older codebases.
Now visibility. Items declared inside a module are private by default. To expose one, put pub on it:
#![allow(unused)]
fn main() {
pub mod parser { // a module is private by default; `pub` exposes its path
pub fn parse(text: &str) -> Ast { /* ... */ } // visible outside this module
fn helper() { /* ... */ } // private to this module
pub struct Ast { /* ... */ } // type is visible
// but Ast's fields are still private unless individually marked pub
}
}
pub has finer-grained variants: pub(crate) exposes only within the current crate, pub(super) only to the parent module, pub(in path) to a specific subtree. Default is private. There is no private keyword; the absence of pub is private.
A related guard: a public signature cannot quietly hand out a more private type. If a pub fn returns a private (or pub(crate)) type, the compiler warns type X is more private than the item, since callers who can reach the function cannot name what it returns. Make the type at least as visible as the signature that exposes it, or make the signature no more visible than the type.
To use an item from another module, name its full path or import it with use:
use crate::parser::parse;
use crate::runtime::env::Env;
fn main() {
let ast = parse("input");
let env = Env::new();
}
Path roots:
crate::starts at the current crate’s module root.super::goes up one level (the parent module).self::is the current module. As a prefix it is almost always optional, since a bare name already resolves in the current module first; it only adds explicitness. The keyword earns its keep in the group formuse std::fmt::{self, Display}, which imports the modulefmtitself alongsideDisplay.- A bare name like
stdor a dependency name starts at that crate’s root.
Do names have to be unique? Only within a single module, and only within the same namespace (Rust keeps types, values, and macros in separate ones, so a mod thing and a fn thing can sit side by side). The same name in two different modules is always fine, fully distinguished by its path: parser::Node and runtime::Node never collide, which is the entire point of a tree of scopes. That applies to module names themselves too: most files carry their own mod tests, and a crate’s dozens of tests modules coexist happily because each sits under a different parent. The error you will actually hit is declaring two items of the same kind with the same name in one module: the name X is defined multiple times. Across crates there is no conflict either, since every path is rooted at a crate name, so serde::Error and std::io::Error coexist. Cargo can even link two different versions of the same crate, keeping each one distinct.
A useful pattern is re-exporting with pub use. It lets the inside of your crate be organized one way while presenting a different API to users:
#![allow(unused)]
fn main() {
// in lib.rs
mod internal { // the module itself stays private
pub struct Thing; // but Thing is pub within it
}
pub use internal::Thing;
// users write `mycrate::Thing`, not `mycrate::internal::Thing`
}
Notice what is and isn’t required. The internal module can stay private; pub use lifts one chosen item out without exposing its neighbors. But the item itself must already be pub. pub use changes where a name lives, not how visible it is, so re-exporting a private or pub(crate) item as pub is a compile error. You cannot launder something into the public API just by re-exporting it.
This is how big standard library types can sit at convenient names while their definitions live deeper in the tree.
Why a tree at all? Modules are about names and visibility, nothing more, so the required file layout can look like busywork. It is not a separate rule. A file named after a module is just the default place the compiler looks for that module’s body, and you could nest the entire tree inline in one file instead, the way the mod inline { ... } example above does. The layout is a convention that keeps large projects navigable, not something the compiler requires.
What the compiler does require is a crate. Modules are not compilation units; the crate is. The compiler reads a crate’s whole module tree and compiles it as a single piece, then a linker joins each crate’s output into the final binary. Compilation does not run module by module, so adding more modules does not change what gets built or make it faster on its own. The parallelism lives at two other levels: Cargo builds independent crates in parallel from the dependency graph, and within one crate the backend splits work into codegen units that LLVM compiles at the same time. What cargo build does end to end, including the linker, and how profiles tune codegen units, each get a tip of their own later.
Comparison:
- Java: package equals directory, and the file path mirrors the package path. Visibility uses
public,private,protected, plus package-private (default). - Python: a directory with
__init__.py(or any.pyfile) is a module. Imports withimportandfrom. No public/private at the language level; convention uses leading underscores. - C++:
namespaceonly groups and names;public/private/protectedencapsulate class members; and file-level “public” rides on the header/source split, which exists for separate compilation, withstatic/anonymous namespaces tying visibility to linkage. So C++ braids visibility and the build model together. C++20modules tidy this with explicitexport, but a module is still both a compilation unit and a visibility boundary. - Go: package equals directory (one package per directory). Names starting with uppercase are exported, lowercase are private.
- JavaScript / TypeScript: ES modules with
import/export. Each file is a module; exports are explicit. - Rust: modules form a tree rooted at
lib.rsormain.rs, declared withmod, private by default, exposure graded withpub. Visibility lives entirely in that tree; how the code compiles is the crate’s concern, a separate axis.
The takeaway: Rust modules are a tree of named scopes rooted at one file. mod name; pulls in a file, pub exposes an item, use brings a name into scope, and pub use re-exports it as part of your public API.
🔇 The file is right there. The compiler can see the file. It just wants to hear you say mod.
Cargo, Crates, and Packages
🦀 Tip of the day 41: Cargo, Crates, and Packages
Most languages need three or four tools to build a project: a compiler, a build system, a package manager, a test runner. Rust ships one tool that does all of them. It is called Cargo, and it is the closest the ecosystem comes to a single answer for “how do I build, run, test, document, and publish my code.”
Three terms that get confused all the time:
- A crate is a unit of compilation. The compiler takes one crate as input and produces a single binary or library as output. Each crate is either a binary (entry point in
main()) or a library (no entry point, code that other crates pull in). - A package is what
Cargo.tomldescribes. A package contains one library crate (at most) and any number of binary crates. Both kinds can coexist in the same package. - A workspace is a top-level
Cargo.tomlthat groups multiple packages so they share aCargo.lockand a singletarget/directory. Useful for projects split into several internal crates.
A typical package layout:
mypkg/
├── Cargo.toml // package metadata and dependencies
├── Cargo.lock // exact resolved versions, like a lockfile
├── src/
│ ├── lib.rs // optional library crate
│ ├── main.rs // optional binary crate (named "mypkg")
│ └── bin/
│ └── tool.rs // additional binary, run with `cargo run --bin tool`
├── tests/ // integration tests
├── examples/ // example programs
└── benches/ // benchmarks
Cargo discovers all of this from convention. You do not configure it.
The commands you will use most (before agents):
cargo new <name>creates a new package, with a starterCargo.tomlandsrc/main.rs. Add--libto start withsrc/lib.rsinstead.cargo buildcompiles in debug mode (fast to build, slow at runtime, with overflow checks and other safety belts). Add--releasefor an optimized build.cargo runbuilds and runs the binary crate. Forward arguments after--:cargo run -- arg1 arg2.cargo checktypechecks without producing an executable. Many times faster thancargo buildbecause it skips code generation.cargo testcompiles and runs every test in the package: unit tests, integration tests, and doc tests.cargo doc --opengenerates HTML documentation from doc comments and opens it in your browser.docs.rsautomatically builds and hosts these for every crate published to crates.io.cargo updateupdatesCargo.lockto the newest versions allowed byCargo.toml.
Cargo parallelizes builds by default: independent crates in the dependency graph compile at the same time, and within a single crate rustc splits code generation across threads. Linking, the final step that stitches the compiled pieces into one binary, mostly runs single-threaded and is often the bottleneck on incremental rebuilds, where you recompile one crate but still relink everything. The full pipeline, and how to make linking faster, is covered later when we look at what cargo build actually does.
Two more tools usually live next to Cargo and feel built-in: cargo fmt (the formatter, rustfmt) and cargo clippy (the linter). They are technically separate components, but they ship with most toolchain installations.
Workspaces let you keep several related packages in one repository without each one fetching dependencies separately. A workspace Cargo.toml looks like:
[workspace]
members = ["core", "cli", "server"]
Each member has its own Cargo.toml and src/, but they share a single resolved dependency graph and a single target/ directory. This is how large projects (the Rust compiler itself, frameworks like axum or bevy) are organized.
Comparison:
- Java: Maven and Gradle handle builds and dependencies, but they are external to the language and configured in XML, Groovy, or Kotlin. Test runners are separate.
- Python: historically
pipfor packages,setuptoolsorpoetryfor builds,pytestfor tests, all loosely coupled.uv(2024 onward) is closing the gap fast: one Rust-built tool, explicitly modeled on Cargo, that handles packaging, virtual environments, and even Python versions. - Node.js:
npm(oryarn,pnpm) handles packages, scripts, and runs throughpackage.json.bun(itself rewritten from Zig to Rust in 2026, much of it AI-generated) goes further, folding runtime, package manager, bundler, and test runner into one binary, the closest in spirit to Cargo. - Go:
goitself is the build system, formatter, and tester, and a package manager since 1.11 (modules); the build, format, and test tooling predates that. Same one-tool philosophy as Cargo, slightly less feature-rich. - C and C++: pick one (or several!) from each column and pray they get along. A compiler: gcc, clang, msvc, or whatever your embedded vendor shipped. A build system: make, cmake, meson, bazel, or the shell script someone wrote in 2005. A package manager: vcpkg, conan, or the time-honored tradition of copying headers into your repo and never speaking of it again. There is no blessed default, so every project invents its own combination, and getting two of them to agree on where a header file lives is still treated as a personality test. Even now, with an AI on every shoulder, the workflow has not changed: have it generate a
CMakeLists.txtyou do not fully understand, have it explain the linker error that follows, and quietly vibe code your build system and package manager into existence one prompt at a time. Choose your fighter. - Rust: Cargo handles building, dependencies, tests, docs, benches, and publishing in one tool with one config file.
The takeaway: a crate is what the compiler builds, a package is what Cargo.toml describes, and a workspace groups packages. Cargo is the one tool that drives all of it, and most days you only need cargo build, cargo run, cargo test, and cargo check.
🔇 Cargo builds, tests, documents, and publishes, all from one tool. The link times are what give you time to read tips like this one.
Follow-up
🦀 Tip of the day 41 follow-up
Follow-up questions from the discussion on Tip 41: Cargo, Crates, and Packages.
What is the typical granularity of a crate? A single class, a few related types, or a whole library?
Start with the terminology, because in practice the two terms line up almost every time. A crate is what the compiler builds, a package is what one Cargo.toml versions and publishes, and since a library package normally wraps exactly one library crate, everyone (crates.io included) just says “crate” for both.
At that level a crate is a library in the C++ sense. Think libfoo covering one coherent topic: serialization, regular expressions, an HTTP client. It is not a single class, and it is not one translation unit. The convention is one package per piece of functionality, the same way you would ship one library per capability.
That single published crate can itself be several crates linked together. A common shape is a thin facade crate that re-exports a few internal sub-crates, much like a C++ library assembled from multiple static archives presented as one. The user depends on one name, the implementation is split underneath:
# Cargo.toml of the facade crate
[dependencies]
foo-core = { version = "1.0", path = "../foo-core" }
foo-derive = { version = "1.0", path = "../foo-derive" }
#![allow(unused)]
fn main() {
// src/lib.rs of the facade crate: present one surface, hide the split
pub use foo_core::{Engine, Config};
pub use foo_derive::Build;
}
Rust steers away from the one-function package, so the floor stays at “a real library” rather than “a couple of loose helpers.” If you are tempted to publish a single utility function, that is usually a sign it belongs inside a larger, topic-shaped crate instead.
Dependencies, Cargo.toml, and the Crates Ecosystem
🦀 Tip of the day 42: Dependencies, Cargo.toml, and the Crates Ecosystem
Tip 41 covered Cargo’s local commands. The other half of Cargo’s job is pulling in code other people wrote. The mechanism is straightforward: list the crates you want in Cargo.toml, run any Cargo command, and they appear in your project.
The basic shape:
[package]
name = "mypkg"
version = "0.1.0"
edition = "2024" # Rust edition, covered in a later tip
[dependencies]
serde = "1.0"
tokio = { version = "1.40", features = ["macros", "rt"] }
anyhow = "1"
The first time you build, Cargo resolves the version graph, downloads the crates from crates.io (the default registry), compiles them, and writes the resolved versions to Cargo.lock. From then on, Cargo.lock pins the exact versions every contributor and CI run will use, until someone runs cargo update.
Now the version specifiers, because they are the most-misread part of Cargo. The default form is ^, the caret:
serde = "1.0"is shorthand for^1.0. Allows>=1.0.0, <2.0.0. The rule is “the leftmost non-zero digit is the compatibility anchor.” Most crates land here.serde = "1.2.3"is shorthand for^1.2.3. Allows>=1.2.3, <2.0.0.- For
0.x.yversions, the caret behaves differently:^0.2.3allows>=0.2.3, <0.3.0. This is intentional, because crates pre-1.0 are expected to break minor-version boundaries.
Other specifiers:
~1.2.3: tilde. Allows>=1.2.3, <1.3.0. Patch-level updates only.=1.2.3: exact match. Use sparingly; it makes upgrading fragile.>=1.2, <2.0: ranges, useful when you need to express compatibility precisely.*: any version. Almost always the wrong answer.
The combination of ^ semver in Cargo.toml and exact pinning in Cargo.lock gives you the best of both worlds: room for compatible upgrades, plus reproducible builds.
Then there are features. A feature is an opt-in compile flag that crates expose to their users. The crate author defines them in their own Cargo.toml:
[features]
default = ["std"]
std = []
extra-utils = []
Users opt in or out:
[dependencies]
mycrate = { version = "1.0", features = ["extra-utils"] }
otherdep = { version = "1.0", default-features = false }
Features control which code compiles. They let one crate serve both a full async runtime and a tiny embedded build without bloating either case. tokio is the textbook example: features like rt, macros, and net pull in only the parts you use, and serde keeps its derive macros behind an opt-in derive feature for the same reason.
Two cousin sections to [dependencies]:
[dev-dependencies]: included only when running tests, examples, or benchmarks. Crates likecriterionormockallgo here so your library users do not have to download them.[build-dependencies]: included only when running abuild.rsbuild script (a Rust file that runs at build time, used for code generation, linking native libraries, etc.). Most projects do not need this.
You can also depend on a path or a Git repository directly, useful for local development and for crates not (yet) on crates.io:
[dependencies]
neighbor = { path = "../neighbor" }
unreleased = { git = "https://github.com/example/unreleased", branch = "main" }
How the resolver handles overlaps:
Cargo’s resolver walks your whole dependency graph, picks the highest versions that satisfy every requirement (backtracking when they conflict), and writes the result to Cargo.lock, so you rarely choose versions by hand. When two dependencies want the same crate, semver decides the outcome. Compatible requirements (1.2 and 1.5 both mean <2.0.0) unify to a single shared copy, which is what lets a type from one dependency pass cleanly into another. Incompatible ones (1.x and 2.x, or 0.6 and 0.7) are kept side by side rather than rejected, which can surprise you with an expected X (v1), found X (v2) error where the two meet; cargo tree --duplicates shows what got doubled. The error only shows up at the specific point where a value has to cross from one version’s world into the other’s.
Features are merged, not duplicated. If two dependencies pull the same shared crate with different features, the resolver builds one copy with the union of those features. That is exactly why features must be additive: everyone shares that single build, so enabling a feature must never break a dependent that did not ask for it. The lone exception is a crate with a links key to a native library, which may appear only once, so two incompatible -sys versions are a hard error rather than a duplicate.
The resolver itself is versioned, set by the resolver field and defaulted by your edition. Resolver 2 (the 2021 edition default) stopped merging features across normal, build, and dev dependencies, so a build script’s heavy features no longer leak into your library. Resolver 3 (the 2024 edition default used above) adds MSRV (minimum supported Rust version, covered in a later tip) awareness: it prefers dependency versions compatible with your declared rust-version rather than always taking the newest.
Finding a crate:
crates.io uses one flat namespace, so names are first-come, first-served with no @org/ scoping. That makes browsing easy and typosquatting easy too, so copy a crate’s name from a source you trust rather than typing it from memory. The usual places to look: crates.io itself (sort by recent downloads), lib.rs (an alternative index with sharper categories and maintenance signals), blessed.rs (a hand-curated list of the go-to crate per job), and docs.rs (auto-built docs for every release, and good docs are a quality signal in themselves). Before adding one, glance at its download count, last release date, and how many transitive dependencies it drags in.
Why Cargo is considered safer than npm:
- No install-time code execution.
npm installautomatically runs each package’spostinstalland similar hooks, which is how one compromised package can run malware the moment you add it. Cargo runs nothing oncargo addor fetch. A crate’s code runs only when you build it (through abuild.rsscript or a proc macro) or when you actually call it. - Immutable releases. Once a version is published to crates.io it cannot be overwritten or deleted, only yanked (hidden from new version resolution). The bytes you locked yesterday are the bytes you get today.
- Fewer, larger dependencies by culture. The ecosystem leans away from hundreds of one-line packages, so there are fewer authors to trust. This is a tendency, not a guarantee; plenty of crates still pull in dozens of transitive dependencies.
None of this makes Rust immune. A malicious build.rs or proc macro still runs arbitrary code at compile time, and the borrow checker does nothing about that. npm has also closed many gaps (lockfile integrity hashes, restricted unpublish, npm audit). The honest version is that Cargo’s defaults expose a smaller attack surface, not zero. To check what you already depend on, run cargo audit, which compares Cargo.lock against the RustSec advisory database; larger teams add cargo vet to share human review of each crate.
A few crates are effectively standard and safe to reach for first: serde, tokio, anyhow and thiserror, clap, regex, rayon.
The takeaway: declare crates in [dependencies], let caret semver allow compatible upgrades while Cargo.lock pins exact versions, and use features to compile only what you need. Find crates through crates.io, lib.rs, and blessed.rs, and lean on Cargo’s safer defaults (no install-time code, immutable releases, cargo audit) without ever assuming a dependency is free to trust.
🔇 One flat namespace, first come first served. The perfect name for your crate was claimed in 2015 by a project with three downloads and no commits since.
Follow-up
🦀 Tip of the day 42 follow-up
Follow-up questions from the discussion on Tip 42: Dependencies, Cargo.toml, and the Crates Ecosystem.
Are all dependencies handled as source code, or can you depend on closed-source, precompiled libraries?
By default, everything is source. Cargo downloads each dependency’s source and compiles it into your build. Depending on a crate is closer to rebuilding a library from source and statically linking it than to linking a prebuilt .lib or .so.
The reason is that Rust deliberately has no stable ABI. Where a C or C++ library prebuilt for your platform links as-is, a compiled Rust library is not guaranteed to link across compiler versions, so Rust-to-Rust is always built from source.
Closed-source dependencies are still supported, just not as Rust binaries. The usual path is to keep the source private behind a private registry or a private Git dependency. It stays proprietary to your organization, but it is still compiled from source by whoever has access:
[dependencies]
# private Git dependency: source stays in your org, Cargo still builds it
internal-lib = { git = "ssh://git@github.com/your-org/internal-lib", branch = "main" }
When you genuinely cannot ship any source, you fall back to the one ABI that Rust and C++ both speak: the C ABI. You ship a precompiled native library (.a, .so, .dll) that exposes a C interface, plus a thin Rust wrapper crate that declares the symbols and links against it. This is exactly how you would pull a closed-source C library into a C++ project.
The wrapper crate declares the foreign symbols with extern "C" and tells Cargo what to link:
#![allow(unused)]
fn main() {
// thin binding layer: this is the only part that ships as source
#[link(name = "proprietary")] // links libproprietary.a / .so / .dll
extern "C" {
fn compute_secret(input: i32) -> i32;
}
pub fn compute(input: i32) -> i32 {
// safe wrapper around the foreign call
unsafe { compute_secret(input) }
}
}
The proprietary logic lives in the compiled binary. Only the small extern "C" binding layer is source. The ecosystem also has crates like abi_stable for loading Rust libraries at runtime, but the C ABI route is the portable, language-agnostic fallback when you need to keep the real logic compiled.
The Standard Library, and What Is Not In It
🦀 Tip of the day 43: The Standard Library, and What Is Not In It
Most languages either ship a huge standard library that tries to cover everything (Python, Java) or a tiny one that leaves the rest to package managers (C, JavaScript). Rust takes the middle path with a deliberate twist: the standard library is small, the package ecosystem is large, and the boundary is drawn for a specific reason.
Rust’s standard library is actually three nested layers, each adding something the previous one cannot do.
core is the innermost layer. It has no dependency on an allocator and no dependency on an operating system. You get the primitive types (i32, bool, &str, slices), the foundational traits (Iterator, Default, Clone, Drop, From), and most of the language’s built-in operations. What you do not get is anything that needs to call malloc or read a file. core is what works on a microcontroller with no heap.
alloc adds heap-allocating types: Box, Vec, String, Rc, Arc, BTreeMap, the basic collections. It requires an allocator but still does not touch the operating system. This is the layer that lets embedded code with a heap (a board with some RAM but no OS) use the familiar containers.
std is the full standard library. It pulls in core and alloc, and adds everything that requires the OS: file I/O (std::fs), threads (std::thread), time (std::time), networking (std::net), environment variables, processes, and the synchronization primitives from tip 33. This is what you use on Linux, macOS, Windows, and other hosted targets.
When you write a regular Rust program, std is loaded for you. A small set of items is also auto-imported into every module so you never have to write use for them. This set is called the prelude: Option, Result, String, Vec, Box, the iterator traits, the conversion traits from tip 39 (From, Into), the common derivable traits (Clone, Copy, Default, PartialEq), and everyday macros like println! and vec!. The exact list depends on the edition (more on editions later): the 2021 edition added TryFrom and TryInto, and the 2024 edition added Future and IntoFuture. The prelude stays small; everything else needs an explicit use.
To opt out of std, you write #![no_std] at the crate root. This is how kernels, bootloaders, embedded firmware, and certain WebAssembly modules are written in Rust. Many crates pull off the trick of supporting both worlds with a std feature flag (tip 42).
Now the deliberate small-stdlib part. The following are not in the standard library:
serdefor serialization, with format crates likeserde_jsonandtomlbuilt on top of it.tokio,smol, or any other async runtime. The language hasasync/.awaitsyntax, but no built-in executor.reqwestor any HTTP client.regex.randfor random number generation. The standard library does not ship an RNG.- Any GUI framework, image format support, audio, or graphics.
These live on crates.io, maintained by individuals and teams across the ecosystem. The reasoning is twofold. First, the standard library follows Rust’s backwards-compatibility promise: once an item is stabilized, it can be added to or deprecated, but never removed or changed in a breaking way. A design mistake in std is permanent. A crate, by contrast, can ship a breaking 2.0 and start over. Second, the leading solutions in domains like async runtimes are still actively iterating. Putting one in std would freeze a particular winner, current flaws and all, before the field is settled.
Comparison:
- Python: famously batteries-included. The standard library has HTTP, JSON, regex, threading, multiprocessing, sockets, dates, and more. Trade-off: large surface area, slower evolution, occasional cruft (recent versions have started pruning long-dead modules).
- Java: large standard library (
java.util,java.io,java.nio,java.net,java.time). Similar trade-offs. - C: standard library is tiny. Strings, basic I/O, math. Every project reimplement basic functionalities, or pulls in third-party libraries for anything substantial.
- C++: a substantial standard library: containers and algorithms, strings, iostreams, threads and mutexes (since C++11),
<filesystem>(since C++17), plus<regex>,<random>, and<chrono>. Notably it bundles a regex engine and a random number generator, both of which Rust pushes out to crates. It still has no networking, JSON, or HTTP client; the long-proposed networking addition never landed. - Go: medium-sized, with an HTTP server, JSON, and crypto built in. Aggressive about adding what most projects need, and still growing (recent releases added structured logging and generic
slices/mapshelpers), so external packages stay rare. - JavaScript: the language itself has a small standard library with no file or network access; those come from the host. Browsers expose large Web APIs, and Node.js, Deno, and Bun each ship a sizable runtime stdlib.
- Rust: small, stable
stdplus a healthy crates ecosystem for everything else. The trade-off is that you reach forCargo.tomlmore often than in Python or Go.
The takeaway: core for no-allocator targets, alloc adds heap collections, std adds OS access. The prelude auto-imports the most-used types. The standard library is intentionally small; serialization, HTTP, async runtimes, and most other domains live on crates.io.
🔇 Rust: a small standard library and a great package manager to fill the gaps. Python: a huge standard library, and a couple of package managers for the gaps it left anyway. C++: gaps.
Editions, Releases, and Who Keeps the Promise
🦀 Tip of the day 44: Editions, Releases, and Who Keeps the Promise
Coming from most languages, you treat a version upgrade with a little suspicion: it might deprecate an API, change a behavior, or break your build. Rust splits that worry into two separate mechanisms, each with its own job and its own promise.
Releases are the frequent ones: a new stable version every six weeks, purely additive. Each release adds features, compiles faster, and improves diagnostics, but it does not break working code. That is the stability promise: stay on your edition, and every future stable release keeps compiling your crate unchanged. Code from Rust 1.0 in 2015 still builds today.
Editions are the rare ones: an opt-in version of the language, released about every three years (2015, 2018, 2021, 2024, with 2027 in preparation). An edition is the one place breaking changes are allowed to live: new keywords, adjusted syntax, a tightened corner case. Upgrading your compiler never moves you onto a new edition, so the stability promise still holds. You take on those breaking changes only when you choose to bump the edition yourself, one crate at a time, and crates on different editions still build together. That is the edition promise: the language can keep evolving without fragmenting the ecosystem.
The rest of this tip is how those two promises are kept, and who keeps them.
Three release channels:
- Stable is what you ship. Released every six weeks. Backward-compatible with every previous stable version.
- Beta is what stable will be in six weeks. Useful for testing your code against the upcoming release before it lands.
- Nightly is built every night and exposes unstable features marked with
#![feature(...)]. Required for experimental APIs and a few embedded targets.
rustup (the toolchain installer) makes switching between channels a single command: rustup install beta, rustup default nightly, etc.
All three are built in the open from one repository, rust-lang/rust: nightly is its main branch built each night, every six weeks that branch is forked to beta, and the previous beta is promoted to stable. It is one codebase riding a release train, not three separate products.
Editions are the mechanism behind that second promise. A crate declares its edition in Cargo.toml:
[package]
edition = "2024"
The compiler keeps supporting all editions forever. Your 2015-edition crate still compiles on the latest Rust. More importantly, crates of different editions can be used together in the same build with no friction. A library written in edition 2018 can be a dependency of a binary written in edition 2024, and vice versa. The ecosystem does not fragment.
What kinds of changes happen in editions?
- Edition 2018 reserved
asyncandawaitas keywords (so they could become syntax later) and reformed the module path system. - Edition 2021 added disjoint capture in closures (capturing only the fields a closure uses, rather than the whole struct), made arrays iterate by value (
IntoIteratorfor arrays), and reserved some syntax for future use. - Edition 2024 tightened lifetime capture rules in
impl Traitreturns, madeunsaferequirements more explicit in extern blocks, and adjusted a few stdlib defaults.
Every change is small and surgical. No edition has rewritten the language. cargo fix --edition walks the diff for you and rewrites the parts that need rewriting.
How do features arrive in stable Rust? Through the RFC process. Anyone can write an RFC, a markdown document proposing a new feature or change. The relevant team discusses it publicly on GitHub, refines or rejects it, and merges accepted ones. Implementation happens on nightly, gated behind a feature flag, where it bakes for as many releases as needed. When the feature is stabilized, it lands on the next stable release, available without any flag, with the stability promise attached.
This is unhurried by design. The async keyword was reserved in 2018, async/.await stabilized in 2019, the async ecosystem has been refining itself since. The trade-off is that some long-anticipated features (specialization, generators) have been on nightly for years. The compiler team errs on the side of “we will only stabilize this when we are sure we will not regret it.”
Who actually runs all this? No single person and no single company. The language is built by the Rust Project, a set of teams (compiler, language, library, infrastructure, and more), each owning its area and deciding by consensus rather than by decree. The language team signs off on language changes; there is no BDFL with a final veto. Since 2023, a Leadership Council with one representative per top-level team handles project-wide decisions, replacing the older core team.
Money is separate from design. The Rust Foundation, an independent nonprofit launched in 2021, is funded by corporate members (AWS, Google, Microsoft, Huawei, Meta, and, as of 2026, OpenAI). But it does not pay most of the people who build Rust. That work comes mostly from volunteers and from engineers whose employers pay them to contribute on company time. The Foundation funds the scaffolding around them: infrastructure like CI and crates.io, the trademarks, a small staff, grants, and (since 2026) a maintainers fund that pays people to look after critical pieces like the compiler and Cargo. It supports the work but does not direct the language, and paying members do not get to design features. That split is deliberate. Rust grew up inside Mozilla, and when Mozilla’s funding receded in 2020, the Foundation gave the language a vendor-neutral home so no single company owns its direction.
Comparison:
- Python: PEP process for language changes, with a similar discussion-then-implementation arc, now governed by an elected Steering Council (Guido van Rossum stepped down as BDFL in 2018). Python 2 to 3 is the famous counter-example of an edition-style transition that did not work as smoothly.
- Java: JEPs and the OpenJDK process, with Oracle as the dominant steward. Backward compatibility is a strong commitment. Six-month release cadence since Java 9.
- Go: very small language with rare changes, designed and governed largely by a team at Google. Generics took until 1.18 (over a decade after 1.0). Backward compatibility is treated as sacred.
- C++: standard library updates per ISO standard, every three years. Less of a “promise” model, more of a “do not break the world” model that mostly holds.
- Rust: six-week stable releases, edition-gated breaking changes that do not fragment the ecosystem, a public RFC process for new features, and governance by independent teams under a nonprofit foundation rather than a single vendor.
The takeaway: Rust ships every six weeks, takes backward compatibility seriously, and uses opt-in editions to introduce occasional breaking changes without splitting the ecosystem. The RFC process is how new features land, decided by the Project’s teams and funded, but not steered, by an independent foundation. The promise is that today’s working code will keep working, and the compiler upholds it.
🔇 Python 2 to Python 3: a decade-long migration. Rust 2018 to 2024: change one line in Cargo.toml, run cargo fix --edition, done.
Follow-up
🦀 Tip of the day 44 follow-up
Follow-up questions from the discussion on Tip 44: Editions, Releases, and Who Keeps the Promise.
Mixing editions looks clean on paper, but C++ tried something similar and it broke down. Instantiate a template designed in one language version with types from another, in a third translation context, and you get contradictions. How does Rust avoid that? Are the changes simply smaller in scope, or is the entanglement between code units lower?
Both, but the deeper answer is that Rust forbids the entire class of problem rather than trying to make it consistent.
An edition is deliberately narrow. It changes only how a crate’s own source is read, never what the compiled program means. Editions touch the front end alone: lexing, parsing, name resolution, and a few desugarings. By the time code reaches the type system, trait resolution, and codegen, it has been normalized into one edition-independent representation. At that level editions no longer exist, so two editions can never disagree about a type or a trait. The backend is the same for everyone.
That is not a happy accident, it is a hard rule. An edition change is only allowed if it is invisible across crate boundaries. Anything that would alter cross-crate semantics, such as trait resolution, type layout, or instantiation behavior, is not eligible to ship as an edition change in the first place. The rule has been there since editions were introduced, so the dangerous category of change you describe simply has nowhere to live.
This is the difference from the C++ situation. C++ tried to make instantiation-context-dependent semantics consistent across versions. Rust does not reconcile them, it rules them out.
Following from that: what happens to generics? If a generic is written in one edition, the type it is instantiated with comes from a second edition, and the instantiation site sits in a third, which edition wins?
None of them competes, because each edition governs only its own crate’s source.
A generic is type-checked once, in its home crate, under its home edition, and its meaning is frozen there. Monomorphization is just substitution into already-checked IR. The instantiation site never re-interprets the body of the generic.
So a generic from edition A, a type from edition B, and an instantiation site in edition C all coexist with no conflict. Each was read and checked under its own edition while it was still source, and they only ever meet later as edition-independent IR. There is no “winning” edition because by the point they interact, the concept of an edition is gone.
Does the promise still hold for something genuinely entangled, like one crate running macros over another crate’s code? Is that even a meaningful scenario?
It is meaningful, and it holds, because edition is tracked per span rather than per expansion.
Every token carries the edition of the crate it came from, and the compiler interprets each token under that edition. There is no single “expansion edition” that the compiler has to pick when a macro defined in one crate is invoked in another. The macro’s own tokens are read under the macro’s edition, and the caller’s tokens are read under the caller’s edition, even though they end up woven into the same expansion.
The classic illustration is await, which became a reserved keyword in edition 2018. A macro defined in a 2015 crate can use await as an ordinary identifier, and that macro keeps compiling when it is invoked from a 2018 or later crate where await is a keyword:
#![allow(unused)]
fn main() {
// in a 2015-edition crate: `await` is just an identifier here
#[macro_export]
macro_rules! poll_once {
($fut:expr) => {{
let await = $fut; // legal: 2015 tokens are read as 2015
await
}};
}
}
#![allow(unused)]
fn main() {
// in a 2024-edition crate: `await` is a keyword here, but the macro still works
let value = poll_once!(some_future);
}
The macro’s tokens stay 2015 and the call site’s tokens stay 2024. Neither edition has to bend to the other, so the promise survives even the entangled case.
What cargo build Actually Does
🦀 Tip of the day 45: What cargo build Actually Does
You typed cargo build. Some seconds later, an executable appeared. What happened?
Cargo is an orchestrator. It figures out which crates need compiling, in what order, and shells out to rustc (the actual compiler) for each one. After every crate has produced its object files, Cargo invokes the system linker to stitch them into a final binary.
rustc itself is a multi-stage pipeline. Source text is lexed and parsed into an AST. Macros are expanded, names are resolved, the AST is lowered into the High-level IR (HIR), and type checking runs. HIR is lowered again into the Mid-level IR (MIR), a simpler control-flow graph where the borrow checker runs. (The error messages from tips 18-22 come from the borrow checker on MIR.) Finally, MIR is lowered into LLVM IR, and LLVM does its own optimizations and emits object code.
That output, one or more .o files, gets passed to a linker. On large projects linking can dominate clean-build time, so it is worth knowing. Each platform picks a default, and you can override it:
- Linux: historically GNU
ld. Since Rust 1.90 (September 2025), the bundledrust-lldis the default onx86_64-unknown-linux-gnu, making some incremental rebuilds link up to 7x faster. Other Linux targets still default to GNUld. - macOS: Apple’s linker (the classic
ld64gained a faster replacement in Xcode 15). - Windows:
link.exe, the MSVC toolchain’s linker, since the default Windows target links against the MSVC runtime.
lld is LLVM’s linker, and it works on all three platforms under different driver names: ld64.lld on macOS, lld-link on Windows (a drop-in replacement for link.exe), and rust-lld (the bundled copy) on Linux. Outside x86_64-unknown-linux-gnu it stays opt-in. Faster does not make it the default: Rust switches one target at a time and started with Linux, where GNU ld is the slowest of the three, while Windows and macOS keep their native linkers (which, unlike lld, still do incremental linking) for now. mold is faster still, but Linux-only. Its macOS port sold is now archived: Apple’s own linker became fast enough in Xcode 15, so even its author recommends using that instead. To switch linker, for example to mold on Linux, set it in .cargo/config.toml:
[target.x86_64-unknown-linux-gnu]
linker = "clang"
rustflags = ["-C", "link-arg=-fuse-ld=mold"]
Are there alternative compilers? Practically, no. rustc with LLVM is the only production toolchain. The notable alternatives:
rustc_codegen_cranelift: a non-LLVM back-end aimed at faster debug builds (commonly 20-30% quicker). It ships with nightly through rustup and can be enabled per project, but it produces slower binaries and still lacks full debugger support.rustc_codegen_gcc: a GCC back-end forrustc. Experimental.gccrs: a separate Rust front-end inside GCC. It landed in GCC 15 (2025) but cannot yet compile the standard library, so it is not ready for serious work.mrustc: a minimal Rust compiler used for bootstrappingrustcitself. Not for users.
The reason rustc is essentially the only choice is that for most of Rust’s life there was no formal specification. The reference implementation was the spec, which makes an independent front-end a multi-year project. That is finally changing: in 2025 the Rust Project adopted the Ferrocene Language Specification and began folding it into an official spec. But a written spec does not conjure competing compilers overnight, so for now “the Rust compiler” still means rustc.
Comparison:
- C, C++: many compilers (GCC, Clang, MSVC, ICC). A standardized language definition lets them coexist.
- Java:
javacfor the front-end; HotSpot, OpenJ9, GraalVM for the runtime. Multiple back-ends in production use. - Go:
gc(the standard) andgccgo(alternative). One dominant. - Python: CPython, PyPy, MicroPython, Jython. Several production implementations.
- Rust:
rustcwith LLVM, period. Alternative back-ends and front-ends exist, but none are production-ready.
The takeaway: cargo build orchestrates rustc per crate, which runs through parse → HIR → type-check → MIR → borrow-check → LLVM → object files. The system linker then stitches the objects into an executable, and on x86_64 Linux that linker is now the fast rust-lld by default.
🔇 Rust shipped for a decade with one compiler and no spec. Turns out “the compiler is the specification” compiles just fine.
Build Profiles, or Why You Want --release Before Benchmarking
🦀 Tip of the day 46: Build Profiles, or Why You Want --release Before Benchmarking
The first time someone benchmarks a Rust program with cargo run and concludes “Rust is not actually fast,” they have learned something more useful than they realize. The default cargo build and cargo run use the dev profile: optimization level zero, full debug info, overflow checks turned on. The result builds in two seconds and runs an order of magnitude slower than it could.
cargo build --release switches to the release profile, which sets opt-level 3, drops most checks, runs LLVM’s full optimization passes, and produces a binary that is often several times faster on the same code, and dramatically faster on tight numerical loops where the optimizer can erase bounds and overflow checks. This is the difference between “feels sluggish” and “competitive with C++.”
Profiles are configured in Cargo.toml. The defaults look approximately like:
[profile.dev]
opt-level = 0
debug = true
overflow-checks = true
incremental = true
[profile.release]
opt-level = 3
debug = false
overflow-checks = false
lto = false
codegen-units = 16
The knobs worth knowing:
opt-level: 0 (dev default, no optimization), 1, 2, 3 (release default, full optimization)."s"optimizes for size,"z"optimizes harder for size at the cost of speed. Embedded developers reach for"s"or"z"often.debug: include DWARF debug information. Disabled in release because it triples binary size and slows linking. Set it to"line-tables-only"if you want backtraces but not full debug symbols.overflow-checksanddebug-assertions: both on in dev, off in release. Integer overflow panics in dev and wraps silently in release, anddebug_assert!checks only fire in dev. Bugs to catch early while developing, measurable costs to drop when you ship.lto(Link-Time Optimization):false(default),"thin"(modest cost, modest gain),trueor"fat"(slow build, more aggressive cross-crate inlining). Worth it for shipping; not worth it during development.codegen-units: how many parallel chunks LLVM splits the crate into. More units means faster compilation, less aggressive optimization. Set to1for max performance, leave at the default for build speed.panic:"unwind"(default, runs destructors during a panic, larger binary) or"abort"(panic kills the process immediately, smaller binary)."abort"is common for embedded and CLI tools.incremental: rebuilds only changed code. On by default in dev, off in release because it interferes with optimization.
For most projects, the defaults are fine. For a binary you ship, a common tuned release profile is:
[profile.release]
lto = "fat"
codegen-units = 1
panic = "abort"
strip = true
strip = true removes leftover symbols, sometimes shaving megabytes. The trade is longer build time for a smaller, cleaner, faster binary.
If you want to scrub absolute source paths out of the binary and panic messages (which helps reproducible builds and avoids leaking your home directory path to whoever runs the program), there is trim-paths = "all". It is still nightly-only, gated behind -Z trim-paths, so reach for it only on a nightly toolchain until it stabilizes.
You can also define custom profiles. A common one is production, which sets lto explicitly rather than relying on what release happens to be, and keeps line-table debug info for backtraces in crash reports:
[profile.production]
inherits = "release"
lto = "fat"
debug = "line-tables-only"
strip = "debuginfo"
Override at the call site with cargo build --profile production.
Two surprises worth knowing. cargo test builds with the test profile (which inherits dev) and cargo bench builds with the bench profile (which inherits release), so anything you measure through cargo bench is already optimized. And when dev builds drag because of one heavy dependency, you can optimize just your dependencies while keeping your own crate fast to compile:
[profile.dev.package."*"]
opt-level = 3
Comparison:
- C, C++:
-O0through-O3,-Os, plus dozens of flags. No profile abstraction; you wire them up in your build system. - Go:
-ldflags, build tags, sparse optimization controls. The compiler decides most things. - Rust: profiles in
Cargo.toml, named and inheritable, applied uniformly to every crate in the build.
The takeaway: dev builds optimize for compile speed, release builds optimize for run speed. cargo build --release flips the profile. Tune lto, codegen-units, and panic when you ship; ignore them otherwise. Always benchmark with --release.
🔇 Dev compiles in two seconds and runs forever; release flips it. Pick your forever. Python skips the question and runs forever anyway.
Cross-Compilation and Targets
🦀 Tip of the day 47: Cross-Compilation and Targets
rustc is a cross-compiler by design. The compiler that builds your code on a Linux laptop can produce a binary for a Windows desktop, a macOS server, an ARM phone, an embedded microcontroller, or a WebAssembly runtime, all from the same install. The mechanism is called a target.
A target is described by a triple: architecture, vendor, OS, and ABI, hyphen-separated. Some you will see often:
x86_64-unknown-linux-gnu: 64-bit Intel/AMD Linux with the GNU toolchain.aarch64-unknown-linux-gnu: 64-bit ARM Linux. What you build for an ARM server or a Raspberry Pi.x86_64-apple-darwin: macOS on Intel.aarch64-apple-darwin: macOS on Apple Silicon.x86_64-pc-windows-msvc: 64-bit Windows with the MSVC toolchain.x86_64-pc-windows-gnu: 64-bit Windows with the MinGW toolchain, the easier target to build from Linux.wasm32-unknown-unknown: WebAssembly for browsers and minimal runtimes.wasm32-wasip1: WebAssembly with the WASI 0.1 system interface (file I/O, environment). Renamed from the oldwasm32-wasi, which was removed from stable in Rust 1.84 (early 2025);wasm32-wasip2targets the newer WASI 0.2.thumbv7em-none-eabihf: a common ARM Cortex-M microcontroller target.
Adding a target to your toolchain:
rustup target add aarch64-unknown-linux-gnu
That downloads the precompiled std (or core/alloc for no_std targets) for the target. Then build for it:
cargo build --target=aarch64-unknown-linux-gnu --release
The output goes into target/aarch64-unknown-linux-gnu/release/.
Compiling is only half the job. rustup target add gives you the target’s precompiled std, but turning that into a runnable binary needs a linker for the target plus that platform’s system libraries. For a different architecture of the same OS (x86_64 Linux to aarch64 Linux), you just install the matching linker (gcc-aarch64-linux-gnu) or let cross supply one. Crossing to a different OS is where it gets real:
- To Windows, use the GNU target
x86_64-pc-windows-gnuwith the MinGW-w64 linker (apt install mingw-w64); it produces a working.exeand MinGW is free and open. Both Windows targets are tier 1, and-msvcis the default, most native one on Windows itself. The only reason to prefer-gnuwhen crossing from Linux is that its linker is freely available there, whereas-msvcwants Microsoft’s linker and the Windows SDK (cargo-xwindownloads them for you). - To macOS,
rustup target add aarch64-apple-darwininstallsstd, but linking a Mach-O binary needs Apple’s SDK (its frameworks andlibSystem). Tools likeosxcrossorcargo-zigbuildcan do it. The obstacle here is legal, not technical: Apple’s SDK license permits use only on Apple hardware, which is why almost everyone builds macOS binaries on an actual Mac (CI services rent them for exactly this).
That licensing point generalizes, mostly. rustc and std are MIT/Apache-2.0, so Rust itself never restricts where you build; the limits come from what you link against. The Windows SDK carries its own terms, and the macOS SDK is Apple-hardware-only. The Linux C library is the exception that is more technical than legal: glibc is not built for static linking (a statically linked glibc still loads NSS modules at runtime, so DNS and user lookups can break), so fully static Linux binaries target x86_64-unknown-linux-musl instead. musl is designed for static linking, and as a bonus it is MIT-licensed rather than LGPL.
What about C libraries that your crate (or its dependencies) link to? Pure-Rust crates cross-compile out of the box. Crates with native dependencies (openssl-sys, libsqlite3-sys) need the C toolchain for the target as well, which is a separate problem. The community solution is cross, a wrapper that runs the build inside a Docker container with the right toolchain pre-installed:
cargo install cross
cross build --target=aarch64-unknown-linux-gnu
For pure Rust, you do not need cross. For native dependencies, you almost always do.
Rust ranks targets in three tiers by how thoroughly the Rust project itself builds and tests them (its own compiler and standard library, not your code):
- Tier 1: guaranteed to work. Rust ships official builds and runs its own compiler and
stdtest suite on the target after every change; if the build or those tests fail, that release does not ship. The major desktop and server platforms. - Tier 2: guaranteed to build. It ships a prebuilt
stdand keeps the target compiling, but does not always run those tests on it, so a successful build is not guaranteed to actually work. Most other common targets sit here. - Tier 3: best-effort. The compiler supports the target, but the project neither builds nor tests it automatically and ships no prebuilt
std. Niche or experimental.
The full list of targets and their tiers lives in the Rust platform support documentation.
A subtle point about builds. Build scripts (build.rs, covered later) and procedural macros run during compilation, not in the final binary, so they always build for the host (your laptop’s target) regardless of --target. Cargo handles this automatically: build dependencies from tip 42 are compiled for the host, while normal dependencies are compiled for the target.
Comparison:
- C, C++: cross-compilation is possible but a project of its own. You assemble a cross-toolchain, set up sysroots, and point the build system at it. Days of work for a new target.
- Go: native cross-compilation since 1.5.
GOOS=linux GOARCH=arm64 go buildworks for pure Go, similar story to Rust for cgo dependencies. - Java: bytecode is target-independent. The JVM does the cross-target work at runtime.
- JavaScript, Python: interpreted, target-independent. The runtime handles platform differences.
- Rust:
rustup target addplus--target, withcrossfor native dependencies. Closer to Go than to C++.
The takeaway: rustc is a cross-compiler. rustup target add <triple> installs the target’s standard library and cargo build --target=<triple> produces a binary, as long as you also have a linker and system libraries for that target. Pure-Rust crates cross-compile cleanly; native dependencies usually need the cross tool; and a foreign-OS target can run into licensing limits, most notably macOS, whose SDK is licensed for Apple hardware only. Tier 1 targets are guaranteed to keep working; tier 3 are best-effort.
🔇 C++ cross-compilation: a weeklong yak shave. Rust cross-compilation: rustup target add plus cargo build --target=. Done, unless you depend on openssl, in which case see also: weeklong yak shave.
Follow-up
🦀 Tip of the day 47 follow-up
Follow-up questions from the discussion on Tip 47: Cross-Compilation and Targets.
Which types depend on the target, and which are the same everywhere?
Almost nothing about Rust’s primitive types depends on the target, which is a deliberate break from C. The fixed-width types are exactly their stated width on every platform: i8/u8 are 8 bits, i16/u16 are 16, i32/u32 are 32, i64/u64 are 64, i128/u128 are 128. bool is one byte, f32 is 32 bits, f64 is 64. So i32 is 32 bits no matter what you build for.
char is also fixed: it is always 4 bytes on every target. A Rust char is a Unicode scalar value, not a C character, so its width has nothing to do with the machine. In C, by contrast, int might be 16 or 32 bits, long is 32 on Windows but 64 on 64-bit Linux, and even char is not guaranteed to be 8 bits. Rust pinned all of that down.
What does vary with the target:
usizeandisize: pointer-sized integers. This is the main one. They are 64 bits on a 64-bit target, 32 on a 32-bit target, 16 on a 16-bit target. They are the types for indexing,.len(), and pointer offsets.- Raw pointers and references: for a thin pointer (
&Tor*const TwhereTisSized) the size follows the address width, the same asusize. Pointers to unsized types are fat, two words wide:&[T]and&strcarry a length, and&dyn Traitcarries a vtable pointer, so they are twice the size ofusize. - The C FFI types (
std::ffi::c_int,c_long, and friends): these mirror the platform C ABI on purpose, soc_longis 32 bits on Windows and 64 bits on 64-bit Linux. - Endianness: little-endian or big-endian byte order. Most targets are little-endian.
- Alignment, though not size: a fixed-width type keeps its size but can change alignment. Some 32-bit targets, for example, align
u64to 4 bytes even though it is still 8 bytes wide. - Availability of some features:
AtomicU64, for instance, does not exist on targets without 64-bit atomics.
What happens on a small target that only supports 16-bit integers natively?
Think of a microcontroller like MSP430, or AVR (an 8-bit core with 16-bit addresses). i32 is still exactly 32 bits there. The CPU cannot do 32-bit arithmetic in one instruction, so the compiler synthesizes it from several 16-bit or 8-bit operations. The result is correct, just larger and slower code. The same holds all the way up: i64 and i128 work too, emulated in software.
What actually changes is usize. It becomes 16 bits, matching the pointer width. The practical consequence is a ceiling on collection sizes. A single allocation is capped at isize::MAX bytes (pointer offsets go through a signed integer), and a slice or Vec is bounded by that. On a 16-bit target isize::MAX is 32767, so a Vec<u8> tops out at 32767 bytes, and a wider element type at isize::MAX / size_of::<T>() elements. usize::MAX itself is 65535, but only a Vec of zero-sized types can reach that. The width of usize follows how far the machine can address, not how wide its arithmetic unit is.
And the opposite: a target where the smallest addressable unit is 32 bits?
That describes a word-addressed machine, like some DSPs, where C’s CHAR_BIT is 32 and an 8-bit integer type does not exist. Rust does not run there. Its memory model hard-codes an 8-bit byte: u8 is defined as 8 bits and memory is byte-addressable, with no CHAR_BIT-style setting to change it. Rust simply has no target for hardware that cannot address 8-bit units.
This is the flip side of the earlier guarantees. Rust buys portability by refusing the exotic hardware that C stretches to accommodate. And note again that char being 32 bits in Rust is unrelated to any of this: it is a 4-byte Unicode scalar on every target, not a reflection of the machine word.
Is there a way to query the most efficient integer type for a platform?
There is no direct equivalent of C’s int_fast32_t family (a “fastest integer of at least N bits”) in the standard library. The idiomatic substitutes:
usizeandisizematch the pointer width, which lines up with the native register width on most mainstream targets (though not all: ILP32-on-64-bit ABIs like x86_64 x32 and arm64_32 have 32-bit pointers on a 64-bit CPU). They are the closest thing to “the efficient type,” and they are what you use for indexing anyway.- An unconstrained integer literal defaults to
i32, chosen as a good all-round default. On essentially every real target it lives in a register and is fast.
To branch on the target at compile time, use cfg! or #[cfg(...)]:
#![allow(unused)]
fn main() {
if cfg!(target_pointer_width = "16") {
// take the small-target path
}
#[cfg(target_endian = "big")]
fn to_wire(x: u32) -> u32 { x.swap_bytes() }
}
Useful keys include target_pointer_width (“16”, “32”, “64”), target_arch, target_endian, target_feature, and target_has_atomic. You can also read widths and sizes directly, all usable in const contexts:
#![allow(unused)]
fn main() {
const W: u32 = usize::BITS; // 16, 32, or 64
const SZ: usize = std::mem::size_of::<u64>(); // always 8
}
If you genuinely wanted a per-target “fast” alias, you would build it yourself with cfg:
#![allow(unused)]
fn main() {
#[cfg(target_pointer_width = "16")]
type Fast = u16;
#[cfg(not(target_pointer_width = "16"))]
type Fast = u32;
}
In practice, you reach for usize when indexing, a fixed-width type when the exact width matters (wire formats, hardware registers), and let the optimizer handle the rest.
Build Scripts, When Cargo.toml Cannot Do Enough
🦀 Tip of the day 48: Build Scripts, When Cargo.toml Cannot Do Enough
Tip 45 traced what cargo build does, and the tips since covered build profiles and cross-compilation; to finish that story of how Cargo builds your code, we need build scripts.
A typical Rust crate is just source code: declare your dependencies, write your .rs files, and let Cargo and rustc do the rest. Some crates need more. They might link to a C library, generate Rust source from a schema, or check the environment for a feature flag. For these, Cargo runs a build script before compiling the crate.
A build script is a file named build.rs at the package root. Cargo detects it automatically, compiles it as a small standalone Rust program, and runs it before the rest of the crate. It reads the script’s standard output and treats any line starting with cargo:: as an instruction. These instructions are a fixed set Cargo defines, one cargo::key=value per line: you pick from the list, you cannot invent your own, and an unknown key fails the build. The full set lives in the Cargo book; the ones below are the common ones. (The double-colon form is the current one, from Rust 1.77 on; the single-colon cargo: you will see in older crates still works.) Everything else the script prints is captured to a log file and hidden unless you build with -vv, so a stray println!("debugging") will not show up where you expect.
The most common job is linking a native library. If your crate wraps a C library, you tell the linker where to find it:
// build.rs
fn main() {
println!("cargo::rustc-link-lib=foo"); // link against libfoo
println!("cargo::rustc-link-search=/opt/foo"); // illustrative; real crates never hardcode this
}
Crates ending in -sys (openssl-sys, libsqlite3-sys) do exactly this. Such a crate also sets links = "foo" in the [package] table of Cargo.toml, which tells Cargo it owns linking libfoo. Cargo then forbids any two crates from claiming the same library, which prevents duplicate-symbol clashes. To find the real paths, -sys crates lean on the pkg-config crate (to locate an already-installed library) or the cc crate (to compile a bundled C source), never a hardcoded path.
The second common job is generating Rust source. The script writes a .rs file into a directory Cargo hands it through the OUT_DIR variable, and the crate pastes it in:
// build.rs
use std::{env, fs};
fn main() {
// OUT_DIR is the only directory a build script may write to.
let out_dir = env::var("OUT_DIR").unwrap();
fs::write(format!("{out_dir}/table.rs"), "pub const VALUE: u32 = 42;").unwrap();
}
#![allow(unused)]
fn main() {
// src/lib.rs
include!(concat!(env!("OUT_DIR"), "/table.rs"));
}
Here include! pastes another file’s contents in as if you had typed them, env! reads an environment variable at compile time, and concat! joins the pieces into one path. This is how prost (Protocol Buffers) and bindgen (C header bindings) work. Write only into OUT_DIR: writing into your own src/ tree breaks caching and can make the script re-run forever.
The third job is detecting the environment. Cargo sets variables like TARGET and HOST (compare them to spot cross-compilation), CARGO_CFG_TARGET_OS (platform decisions), and PROFILE (debug vs release). The script can then emit cargo::rustc-cfg=NAME, read back with #[cfg(...)] or cfg!(...) as a compile-time on/off switch, or cargo::rustc-env=KEY=VALUE, read back with env!(...) to bake a computed string such as a git hash into the binary.
Whichever of these your script does, Cargo needs to know when to re-run it:
#![allow(unused)]
fn main() {
println!("cargo::rerun-if-changed=src/schema.txt");
}
Without any rerun-if directive, Cargo plays it conservative: it re-runs the script whenever any file in the package changes (by file timestamp), not on every build. But emitting even one rerun-if line flips that off and tells Cargo to watch only what you list. Forget a file your script reads, and edits to it will not trigger a rebuild, leaving you with a silently stale binary. (There is also cargo::rerun-if-env-changed=VAR for scripts driven by an environment variable.)
A build script has its own dependencies, listed under [build-dependencies] in Cargo.toml (tip 42). These always compile for the host, regardless of --target, since the script runs on your machine at build time (the host-versus-target split from tip 47).
The downsides are real:
- They slow
cargo builddown: extra Rust code to compile and run. - They make builds harder to reproduce, especially when they read the environment.
- A forgotten
rerun-ifinput gives you the silently stale builds described above. - A dependency’s misbehaving build script poisons your build until the upstream fix lands.
Most projects do not need one. Reach for a build script when you have a real reason: native library linking, code generation from a schema, or environment-aware compilation. For everything else, Cargo.toml is enough.
Every ecosystem has its own version of this. In C and C++, build systems hook arbitrary commands through make or cmake. Maven runs plugins at lifecycle phases like generate-sources. Go’s go generate is a code generator you run yourself, separate from go build, closer to a manual step than Rust’s automatic build.rs. Rust’s twist is one build.rs per package, run for you, talking to Cargo through cargo:: lines on stdout.
The takeaway: build.rs is a small Rust program Cargo runs before compiling your crate to link native libraries, generate source, or read the build environment, and most crates never need one.
🔇 The first time you write a build.rs you feel powerful. The second time you write a build.rs you wonder if there is a way to avoid writing a build.rs. The third time you write a build.rs you give up and accept that this is your life now.
Follow-up
🦀 Tip of the day 48 follow-up
Follow-up questions from the discussion on Tip 48: Build Scripts, When Cargo.toml Cannot Do Enough.
Is there an accepted way to mix C&C++ builds with Rust? Does build.rs run CMake, does CMake run Cargo, or does some third tool run both?
It isn’t a mess once you ask one question: which build system produces the final artifact you ship? Whoever owns the final linked output is “on top,” and the other one is invoked as a component builder. All three of those options are real. They just correspond to three different answers to that question.
Case A: Cargo on top (your build.rs drives the C&C++ build)
You run cargo build and it produces the final binary; the C&C++ is a dependency. This is the common case, and it is what the -sys crates in the tip are doing. Two idiomatic, official (rust-lang org) build-dependencies:
ccfor a handful of C&C++ (or assembly) files you own. You list the files, it compiles them into a static archive and emits the link directives for you.
// build.rs
fn main() {
cc::Build::new().file("src/foo.c").compile("foo"); // linking handled automatically
}
cmakewhen the dependency already ships aCMakeLists.txt. Yourbuild.rsshells out to the systemcmake, gets back an install path, and you emit the link directives yourself.
#![allow(unused)]
fn main() {
// build.rs
let dst = cmake::Config::new("libfoo").build();
println!("cargo::rustc-link-search=native={}", dst.display());
println!("cargo::rustc-link-lib=static=foo");
}
So “have build.rs run CMake” is not a hack, it is the sanctioned path. Rule of thumb: cc for source files you control, cmake for an upstream project that already has a CMake build.
Case B: CMake on top (CMake pulls in Cargo)
Here the project is fundamentally a C&C++ CMake project and Rust is one library inside it. You do not want build.rs calling cmake, that is backwards. You want the tool built for this direction: Corrosion (formerly cmake-cargo, actively maintained). Its documented corrosion_import_crate runs cargo metadata, turns each crate into a CMake target, and lets your C++ targets link them normally.
corrosion_import_crate(MANIFEST_PATH rust/Cargo.toml)
target_link_libraries(my_cpp_app PRIVATE my_rust_crate)
The Rust crate declares crate-type = ["staticlib"] or ["cdylib"]. CMake’s own native enable_language(Rust) is still just a draft merge request as of late 2025, so do not reach for that yet; Corrosion is the practical answer.
Case C: A third tool orchestrates both
In large polyglot monorepos, neither Cargo nor CMake is on top. A meta build system owns everything and calls rustc directly, using Cargo only as a source of dependency metadata that gets translated ahead of time:
- Bazel with
rules_rust. Itscrate_universefeature reads yourCargo.tomland generates Bazel targets. Popular, used at scale, with first-class C&C++ interop viarules_cc. - Buck2 (Meta) with its Rust rules. A separate tool, Reindeer, buckifies your Cargo graph ahead of time. Its manual is blunt about it: “At build time, Cargo is not involved at all.”
- Meson. Has native Rust support, calls
rustcdirectly, and since 1.9 can even mix Rust and C sources in a single target.
The common thread in Case C: Cargo stops being the orchestrator and becomes a dependency-manifest reader.
One clarification worth making
None of the above is the same thing as language interop. Orchestration (who builds and links what) is separate from the FFI boundary (how C++ and Rust actually call each other). For the boundary you use bindgen (C headers to Rust), cbindgen (Rust to C headers), or cxx (safe C++ to Rust bridge). Real projects commonly pair one orchestrator (say Corrosion) with one interop tool (say cxx). They are complementary, not competing.
So it is not a mess. Orchestration comes down to one question, who produces the final artifact; wiring the two languages together is a separate concern, handled by the interop tools above.
Can a build.rs require external libraries, indicated in a “sub-build.rs”?
Two halves, and the short answer is “yes, but not through a nested script.”
Can a build.rs require external libraries? Yes. A build script is compiled as its own small program and has its own dependency list, declared under [build-dependencies] in Cargo.toml. That is exactly how it pulls in cc, cmake, bindgen, and so on. These are walled off from your normal [dependencies]; the build script cannot even see those, since they are not built yet when it runs.
[build-dependencies]
cc = "1"
cmake = "0.1"
Is there a “sub-build.rs”? No, not as a literal feature. A package has exactly one build script. The build key in [package] takes a single file path (or false to disable it), never a list. You cannot nest build scripts inside one package.
But the inception is real. It just lives in the dependency graph, not inside a single crate. A crate you list in [build-dependencies] is an ordinary crate, so it can have its own build.rs, and its own [build-dependencies], which can have theirs, all the way down. Cargo compiles and runs each one in topological order: a dependency’s build script finishes before the dependent that needs it. So you do get build scripts triggering build scripts, through the graph rather than through nesting.
There is even a sanctioned channel for one build script to hand data to another, which is the principled version of a “sub-build.rs” telling its parent what it needs. A crate with links = "foo" can emit cargo::metadata=KEY=VALUE, and Cargo exposes it to the immediate dependent’s build script as an environment variable DEP_FOO_KEY. Note it only reaches direct dependents, not transitively.
The takeaway for both questions: it is turtles all the way down, but each turtle is a separate package with exactly one build.rs, and Cargo runs them bottom-up.
Testing, the Topic We Postponed for 48 Tips
🦀 Tip of the day 49: Testing, the Topic We Postponed for 48 Tips
You have written a lot of Rust by now. None of it has been tested, at least not in this series. Time to fix that. Rust ships testing as a first-class feature: no test framework crate to install, no convention to debate, no separate binary to build. You write #[test] and cargo test does the rest.
The simplest test is a function with the #[test] attribute:
#![allow(unused)]
fn main() {
#[test]
fn it_adds() {
assert_eq!(2 + 2, 4);
}
}
cargo test discovers every #[test] function in the package, compiles a test binary, and runs them in parallel. The output reports passes, failures, and ignored tests in the familiar running 5 tests / test it_adds ... ok format.
The standard assertion macros:
assert!(cond): panic ifcondis false. Pass a custom message as a second argument.assert_eq!(a, b): panic ifa != b. The failure message shows both values, labeledleftandrightrather thanexpectedandactual, so there is no argument order to memorize. Easier to debug than a bareassert!(a == b).assert_ne!(a, b): the inverse.
assert_eq! and assert_ne! print both values on failure, so your types need #[derive(PartialEq, Debug)] (the comparison uses PartialEq, printing uses Debug; see derive macros in tip 26). The built-in numeric types already have them.
A few attributes for shaping tests:
#[should_panic]: the test passes only if the body panics. Addexpected = "..."to require a specific message.#[ignore]: skip the test by default. Run only the ignored tests withcargo test -- --ignored, or the whole suite including them withcargo test -- --include-ignored.
Rust has three places tests can live, with different scopes:
- Unit tests live right next to the code they test. Here is the surprise: in most languages tests sit in a separate file, often a separate directory or project. Rust usually keeps them in the same file as the code, at the bottom, inside a
#[cfg(test)]module. The tests are part of the module, so they reach its private items directly:
#![allow(unused)]
fn main() {
fn add(a: i32, b: i32) -> i32 { a + b }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn it_adds() {
assert_eq!(add(2, 2), 4);
}
}
}
#[cfg(test)] ensures the test code is compiled only during cargo test, never in your release binary. No runtime cost, no stripping needed.
You can leave them there, and plenty of projects do. If you would rather not mix tests into the source file, move the module to its own: declare #[cfg(test)] mod tests; and put the tests in a sibling tests.rs (or any file via #[path = "..."]). It is still a submodule, so it still reaches the private items and still compiles out of release builds.
- Integration tests live in
tests/next tosrc/, one file per test crate. They can only reach your code through a library target (src/lib.rs); a binary-only crate exposes nothing totests/, which is why binaries keepmain.rsthin and put the logic in a lib. They see only your public API, simulating how real users would call it:
tests/
└── integration.rs
Each file in tests/ becomes its own test binary, with cargo test running them all.
- Doc tests live inside
///comments and are extracted from the documentation:
#![allow(unused)]
fn main() {
/// Adds two numbers.
///
/// # Examples
///
/// ```
/// assert_eq!(mycrate::add(2, 2), 4);
/// ```
pub fn add(a: i32, b: i32) -> i32 { a + b }
}
cargo test runs each example, ensuring the docs stay correct as the code evolves. Python’s doctest and Go’s Example functions do something similar (Python’s version predates and inspired Rust’s), but Rust wires it into the default cargo test run with no setup. It is one of the more under-appreciated features.
A few flags worth knowing:
cargo test name_substringruns only tests whose names contain the substring.cargo test -- --nocaptureshowsprintln!output even for passing tests (cargo hides it by default to keep the output clean).cargo test -- --test-threads=1runs tests sequentially. By default the harness runs tests as threads in one process, so parallel tests share process-wide state (current directory, environment variables, global singletons); drop to a single thread when that shared state makes them flaky.cargo test --releaseruns tests with the release profile. Slower to compile, faster to run; useful for tests that are themselves performance-sensitive.
Alternative test runners exist. nextest is popular for being faster, presenting nicer output, and running each test in its own process rather than a shared-process thread, so process-wide state or even a crash in one test cannot corrupt another: cargo nextest run. One catch: it does not run doc tests, so you still need cargo test --doc alongside it.
Coming from JUnit or pytest, you will look for fixtures and parameterized tests. The built-in harness has neither: setup is a plain helper you call in each test, and cleanup rides on RAII, a guard value whose destructor runs when the test ends. The rstest crate adds both, where #[case] turns one function into many named test cases. Each case is an ordinary #[test], so it runs in parallel and reports like the rest.
Comparison:
- Java, C#: testing means picking a framework (JUnit, xUnit/NUnit), each with its own conventions. Python and JS ship options in the box (Python’s stdlib
unittestanddoctest, recent Node’s built-innode:test), though many still reach for pytest or jest. - Go:
testingis a standard library package;*_test.gofiles run withgo test. Same one-tool philosophy as Cargo. - C, C++: no built-in testing. Pick from a long list of frameworks (Google Test, Catch2, doctest) or roll your own.
- Rust:
#[test]andcargo test. No framework to choose, no convention to debate.
The takeaway: write #[test] fn whatever() { assert_eq!(...) }, run cargo test. Unit tests live in #[cfg(test)] modules, integration tests in tests/, doc tests in /// examples. The test runner is built in.
🔇 No framework to argue over, just #[test]. If you came from a more opinionated ecosystem and you miss proving a colleague wrong, relax: you can still debate whether the tests belong in the same file or their own.
async/await, or Why Your Future Just Sits There
🦀 Tip of the day 50: async/await, or Why Your Future Just Sits There
You wrote your first async fn. You called it. Nothing happens.
async fn fetch_thing() -> u32 {
println!("fetching...");
42
}
fn main() {
fetch_thing(); // looks silent, but the compiler is not happy
}
Welcome to Rust async. It compiles, but the compiler already warns you: unused implementer of Future that must be used, with the note futures do nothing unless you .await or poll them. Rust is telling you exactly what went wrong. Your function did not run. It returned a future: a value representing “the work I will do when somebody runs me.” Nothing has happened yet. To run a future, two things have to be true: somebody has to .await it (or otherwise drive it), and there has to be a runtime willing to do the driving.
The laziness is total. Not even the code before the first .await runs until the future is polled: calling fetch_thing() never prints fetching..., you just get a future back.
async fn desugars roughly into a function returning impl Future<Output = T>. So the example is closer to:
#![allow(unused)]
fn main() {
fn fetch_thing() -> impl Future<Output = u32> {
/* compiler-generated state machine */
}
}
A Future is a value that, when polled, either finishes and returns Poll::Ready(value), or returns Poll::Pending because it is not ready yet. On Poll::Pending the future is not re-polled in a busy loop; it arranges to be woken when it can make progress (say, when a socket becomes readable), and only then does whatever is driving it poll again. The compiler turns async fn bodies into a state machine that knows how to resume from each .await point.
You drive a future to completion by .await-ing it inside another async context:
async fn main_logic() {
let value = fetch_thing().await; // now fetch_thing actually runs
println!("got {}", value);
}
You can also build a future inline with an async block, no separate function needed:
#![allow(unused)]
fn main() {
let fut = async { fetch_thing().await + 1 }; // a future; nothing runs yet
let name = String::from("data");
let fut = async move { name.len() }; // async move takes ownership of `name`
}
An async fn and an async block are the two ways to produce a future, and both are inert until polled.
But calling main_logic() from a regular fn main() gets you nowhere: it just hands back another inert future. And you cannot .await it there either, because .await only works inside async (writing main_logic().await in a sync fn is a compile error). Even an async block is still inert until something polls it. You need a runtime to set the whole machine in motion, and the standard library does not provide one.
This is the most surprising thing about Rust async coming from other languages. Almost everywhere else, async work tends to start on its own once you create it. Rust chose the opposite: futures are lazy, the language gives you the syntax, and the ecosystem provides the runtime.
The trade-off the language is making:
- No runtime in
stdkeeps the standard library small and lets multiple runtimes compete. The right runtime for a server (multi-threaded, work-stealing) is wrong for an embedded device (single-threaded, no allocator). - Lazy futures mean you only pay for work you
.await. A future nobody polls never runs, and one you stop polling stops making progress. Cheap cancellation falls out of this, which we return to when we cover cancellation. - The cost is real friction: you need to install a runtime crate (almost always
tokio) to do anything useful.
Comparison:
- JavaScript: Promises are eager. The moment a Promise is constructed, the work begins. Cancellation requires extra machinery (
AbortController). - Python:
asynciofutures and tasks. Tasks created withcreate_taskrun immediately on the event loop; coroutines are lazy until awaited or scheduled. - C#: the
Taskfrom anasyncmethod orTask.Runis eager, already running.async/awaitis built into the language and the .NET runtime schedules them. - Go: no async/await. Goroutines are eagerly scheduled on a built-in runtime, with channels for coordination.
- Rust: futures are lazy and inert until polled. The runtime is chosen per project, by convention
tokio.
The takeaway: async fn returns a Future, which does nothing until polled. .await polls it inside another async context, but the chain must end at a runtime that drives the polling. We cover runtimes next.
🔇 Rust promises you a Future. It does not promise you a present.
Follow-up
🦀 Tip of the day 50 follow-up
Follow-up questions from the discussion on Tip 50: async/await, or Why Your Future Just Sits There.
Can you write an async block in a sync function?
Yes. An async {} block is an expression that evaluates to a future, and building a value never requires you to be in an async context. So you can write one inside an ordinary sync fn:
async fn fetch() -> u32 { 42 }
fn main() { // ordinary sync fn
let fut = async { fetch().await + 1 }; // async block built right here
// fut is a future; nothing has run yet
}
The part that trips people up is where .await is allowed. .await only works inside an async context, but the body of the block is an async context, so the fetch().await above is fine even though main is sync. What you cannot do is .await the block’s result back out in the sync function:
#![allow(unused)]
fn main() {
let fut = async { fetch().await + 1 }; // OK: .await is inside the block
let x = fut.await; // error[E0728]: `await` only allowed inside async
}
So the rule is narrower than “no async in sync functions.” You can build futures anywhere; you can only .await them from inside async. Building a future in sync code and then handing it to a runtime is the normal thing to do, that is exactly what block_on receives.
This also gives you a common pattern: a sync function that returns a future by ending in an async block.
#![allow(unused)]
fn main() {
fn load(id: u32) -> impl Future<Output = u32> { // sync fn, no async keyword
async move { fetch().await + id } // returns a future
}
}
load runs no async code itself. It just constructs the future and hands it back for someone else to drive.
When we talk about polling a future, is there a simple function call that does just that?
Not in the standard library. The low-level primitive is the Future::poll method, but you rarely call it by hand: it takes a Pin<&mut Self> and a &mut Context, and the Context has to carry a Waker so the future can signal when it is ready to make progress again. One call is not enough either, a future that returns Poll::Pending has to be polled again after each wake. That bookkeeping loop is what an executor is.
There is no std::future::block_on. The standard library gives you the Future trait plus Poll, Context, and Waker, and stops there.
In day-to-day code the two simple calls are:
.await, inside an async context. It desugars into the poll-and-wait loop for you.- a runtime’s
block_on(...), to drive a future to completion from sync code. It does not come from std, it comes from a crate:
#![allow(unused)]
fn main() {
let v = pollster::block_on(fetch()); // tiny, executor only
let v = futures::executor::block_on(fetch()); // from the futures crate
let v = tokio::runtime::Runtime::new().unwrap().block_on(fetch()); // full runtime
}
#[tokio::main] is just sugar for that last one. All of them do the same job: poll the future in a loop, parking the thread between wakeups, until it returns Poll::Ready.
If you want to see how little it takes, a complete block_on for futures that never truly block is about fifteen lines built on std::task::Wake (stable since 1.51). The hard part of a real runtime is not the polling loop, it is the reactor that knows when to call wake() for actual I/O.
Runtimes and Tokio
🦀 Tip of the day 51: Runtimes and Tokio
Tip 50 left you with a future that does not run. To drive futures to completion, you need a runtime. The standard library does not include one.
A runtime is a library that:
- Schedules futures (tasks) onto threads.
- Polls them when they have work to do, and parks them when they are waiting on I/O or timers.
- Drives I/O through a component called the reactor (or I/O driver): it integrates with the OS event API (epoll on Linux, kqueue on BSD/macOS, IOCP on Windows) and wakes tasks when their sockets are ready.
- Provides async-aware versions of operations that would otherwise block: timers, locks, channels.
The standard library deliberately stops at async/.await syntax and the Future trait. Everything else is on crates.io. Several runtimes exist, but the de facto choice for almost any Rust project doing async I/O is tokio.
First, add tokio to your project. Unlike most crates, tokio ships with no features enabled by default, so a bare tokio = "1" gives you almost nothing: #[tokio::main], timers, and spawn are all gated behind feature flags and will not compile. You get errors saying the item cannot be found in tokio, each with a note that it is gated behind a feature. In Cargo.toml:
[dependencies]
tokio = { version = "1", features = ["full"] }
"full" turns everything on, which is the right default while you are learning. Later you can trim to the features you use to cut compile time. For the programs below, ["rt-multi-thread", "macros", "time"] is enough.
A minimal tokio program:
use tokio::time::{sleep, Duration};
#[tokio::main]
async fn main() {
println!("starting");
sleep(Duration::from_secs(1)).await;
println!("done");
}
#[tokio::main] is an attribute macro that rewrites async fn main into a regular fn main that sets up a tokio runtime, runs the async body to completion, and shuts the runtime down. Without it, you would write:
fn main() {
tokio::runtime::Runtime::new().unwrap().block_on(async {
// ... the async body ...
});
}
block_on is the bridge between sync and async: it blocks the calling thread and drives the future to completion on that thread, parking only while the future is waiting. Runtime::new() builds the same multi-threaded runtime that #[tokio::main] gives you by default, which is the flavor described next. In this manual form the runtime is an ordinary value that stays alive only as long as you hold it: while it lives it keeps its worker threads and drives spawned tasks, and if you drop it those threads shut down, so any task still running stops. #[tokio::main] sidesteps this by keeping the runtime alive for the whole block_on. One rule goes with the bridge: block_on is for the outermost sync entry point. Calling it from inside async code panics with “Cannot start a runtime from within a runtime”, because the calling thread is already busy driving tasks. Once you are inside async, you use .await.
Tokio offers two runtime flavors:
- Multi-threaded (the default): a pool of worker threads with work-stealing, one per CPU core by default. Override the count with
#[tokio::main(worker_threads = 4)]or theBuilder::new_multi_thread().worker_threads(n)API. Tasks can move between threads, so a future youspawnonto the runtime must beSend. Best for I/O servers and CPU-throughput work. - Current-thread: a single thread runs the runtime. Tasks stay on it. Best for scripts, GUIs, and code that uses non-
Sendtypes. Activated with#[tokio::main(flavor = "current_thread")]. Note thattokio::spawnstill requiresSendeven here; to spawn a non-Sendtask (one capturing anRc, say) you usetokio::task::spawn_localinside aLocalSet.
A task is what you spawn onto the runtime, similar to a thread but far cheaper. A task is the compiler-generated state machine from tip 50, holding only the data live across its .await points, often bytes or a few kilobytes. An OS thread instead reserves a dedicated stack, commonly multiple megabytes, whether or not it uses it. That is why you can have hundreds of thousands of tasks at once while OS threads max out in the tens of thousands: the ceiling is mostly stack memory. The runtime then multiplexes all those tasks onto a small pool of threads.
use tokio::task;
#[tokio::main]
async fn main() {
let handle = task::spawn(async {
println!("running on a task");
7
});
let value = handle.await.unwrap(); // join the task, get its result
println!("{}", value);
}
Because tasks are so cheap, spawning a hundred thousand of them is routine, something you would never attempt with OS threads:
#[tokio::main]
async fn main() {
let handles: Vec<_> = (0u64..100_000)
.map(|i| tokio::spawn(async move { i * 2 }))
.collect();
let mut sum = 0u64;
for h in handles {
sum += h.await.unwrap();
}
println!("sum = {}", sum);
}
Tokio schedules tasks cooperatively, not preemptively: a task yields only at an .await. A tight async loop that never awaits can monopolize its worker thread, even without doing anything blocking. To keep tasks fair, tokio gives each one a budget: its I/O and channel operations count down that budget and yield once it runs out, even when data is ready, so an always-ready task still hands control back instead of starving its neighbors.
Other runtimes exist:
async-std: an older runtime that mirrored thestdAPI surface in async form. It was discontinued in 2025 (RUSTSEC-2025-0052), and its maintainers now point users tosmol. Do not start new projects on it.smol: small, modular, and actively maintained. It is the general-purpose runtime the async-std maintainers now point users to.embassy: the embedded async runtime, for microcontrollers withoutstd.monoio/glommio/compio: thread-per-core runtimes built onio_uring. They can outscale tokio on high-throughput Linux servers, but they are niche, mostly Linux-only, and several are still pre-1.0, so none is a general-purpose default.
Mixing runtimes in one program is hard. Most crates assume tokio. If you pick a different runtime, expect more friction with the ecosystem.
Comparison:
- Node.js: ships its own event loop (libuv-based), baked into the runtime. No separate async crate to choose.
- Python:
asynciois in the standard library.uvloopis a faster drop-in. The default works for almost everything. - C#: the .NET runtime schedules tasks. One runtime, baked in.
- Go: built-in runtime, scheduled by the Go runtime onto OS threads. Goroutines are the only “task” abstraction.
- Java: the JVM is the runtime, baked in. Since Java 21, virtual threads (Project Loom) multiplex many lightweight threads onto a few OS threads, like tokio tasks or goroutines, but without
async/.awaitcoloring. - Rust: no runtime in std. Tokio is the de facto choice, and the rest of the ecosystem is built around it. The decoupling is a feature when you need a different runtime (embedded, kernels) and a tax when you do not.
The takeaway: a runtime polls futures, drives I/O, and schedules tasks. Rust does not ship one. Use tokio with #[tokio::main] for almost everything. Other runtimes exist for specific niches (embedded, minimal binaries).
🔇 Rust has many async runtimes, the way there are many messaging apps: you end up on whichever one the people you need already use.
Async I/O, or Why You Suddenly Need tokio::fs
🦀 Tip of the day 52: Async I/O, or Why You Suddenly Need tokio::fs
Tip 43 called std::fs, std::net, and std::thread the parts of the standard library that need the OS. They are all blocking: when you call std::fs::read_to_string("foo"), the calling thread parks until the read completes. That is fine on a regular thread. It is a disaster inside an async task.
The reason is how runtimes work. A tokio worker thread runs many tasks, switching between them at .await points. A blocking call contains no .await, so the worker freezes and every other task queued on it stalls too. Your “hundreds of thousands of tasks” runtime quietly becomes “one task per worker thread, the rest are waiting.”
You can measure it. One worker thread, two tasks, one of them blocking:
use std::time::{Duration, Instant};
#[tokio::main(flavor = "multi_thread", worker_threads = 1)]
async fn main() {
let t0 = Instant::now();
let a = tokio::spawn(async { std::thread::sleep(Duration::from_millis(500)) });
let b = tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(50)).await;
println!("b woke after {:?}", t0.elapsed());
});
let _ = tokio::join!(a, b);
}
Task b asked for 50 ms and woke after about 555 ms: it could not be polled until a handed the thread back. Change a to tokio::time::sleep and b wakes after about 51 ms, on time.
The fix is to use async-aware replacements for the blocking parts of std:
tokio::fsfor file I/O.tokio::netfor TCP, UDP, and Unix sockets.tokio::iofor the asyncAsyncReadandAsyncWritetraits.tokio::timefor sleeping, timers, and timeouts.tokio::processfor subprocesses.
The shape mirrors std closely, with one wrinkle: the read and write methods live on extension traits, so stream.read(&mut buf).await compiles only with use tokio::io::AsyncReadExt; in scope. The compiler names the missing trait in the error, which is how most people meet AsyncReadExt.
tokio::fs deserves a footnote. Most operating systems have no async file API, so tokio does not either: every tokio::fs call runs the ordinary blocking std call on a separate thread pool and awaits the result. That keeps your workers free, but it is not free itself, so prefer one large call like fs::read_to_string over many small reads through File. (Linux io_uring support is landing in tokio, but it is experimental and behind unstable flags.)
For blocking work with no async equivalent, use that same pool directly:
#![allow(unused)]
fn main() {
let result = tokio::task::spawn_blocking(|| expensive_synchronous_work()).await?;
}
Blocking threads start on demand up to 512 by default, and later calls queue. That limit is generous because the pool is meant for blocking I/O, not for CPU-bound parallelism; for sustained computation, cap the concurrency yourself or use something like rayon. One catch: a spawn_blocking closure cannot be cancelled once it starts, and runtime shutdown waits for it.
Now the trap. Your editor will suggest std::sync::Mutex long before tokio::sync::Mutex. Both are Mutex<T>, and they behave differently here:
std::sync::Mutex::lock()blocks the thread until the lock is free.tokio::sync::Mutex::lock()is async: on contention it yields the task, freeing the worker.
Rust catches part of this for you. A std::sync::MutexGuard is not Send (tip 34), so a future holding one across an .await cannot go to tokio::spawn:
error: future cannot be sent between threads safely
note: future is not `Send` as this value is used across an await
That covers spawned tasks. It does not cover a guard held in code that is never spawned, like the body of #[tokio::main] itself: that compiles and stalls at runtime.
The counter-intuitive part is that tokio’s own docs recommend the std Mutex for async code. The only thing the async mutex adds is the ability to hold the lock across an .await, and paying for that on every lock is a poor trade when the critical section is three lines of data mutation. Use tokio::sync::Mutex when you genuinely must hold the lock across an .await, guarding a database connection for instance, and std’s otherwise.
The same split applies to RwLock and to channels (std::sync::mpsc versus tokio::sync::mpsc). Some tokio primitives have no std counterpart at all, like Semaphore and Notify.
Comparison:
- Node.js: async I/O is the default. The sync twins (
fs.readFileSync) exist but are treated as an anti-pattern outside startup code, andworker_threadsis the escape hatch for CPU work. - Python:
asynciohas exactly the same trap.openorrequestsinside async code blocks the event loop, andasyncio.to_threadis thespawn_blockingequivalent. - Go: blocking I/O is the only API. The runtime parks the goroutine and shuffles work between OS threads invisibly, so there is no split to get wrong.
- Java:
java.ioblocks,java.niodoes not, and picking was your problem. Virtual threads (Java 21) make ordinary blocking calls cheap again, and Java 24 removed the last big caveat, blocking insidesynchronized. - Rust: std blocks, tokio mirrors std asynchronously, and mixing the two compiles fine most of the time.
The takeaway: inside async code use tokio::fs, tokio::net, and tokio::time instead of their std counterparts, spawn_blocking for blocking work with no async version, and std::sync::Mutex unless you need to hold the lock across an .await.
🔇 Go: “All I/O is async.” Node: “All I/O is async.” Rust: “Most I/O is async, except the std version, which blocks, and the autocomplete offers that one first.”
Spawning, Joining, Selecting, and Channels
🦀 Tip of the day 53: Spawning, Joining, Selecting, and Channels
If you have used Promise.all in JS, asyncio.gather in Python, or Task.WhenAll in C#, you already know the shapes in this tip. Tokio has all of them, but it puts a decision in front of you first: does this work become its own task, or does it stay on the task you are already running?
Spawning makes it a task, an independently scheduled unit of work that the runtime polls, like a thread but far cheaper (tip 51):
#![allow(unused)]
fn main() {
let handle = tokio::spawn(async { do_work().await; 42 });
// ... the caller keeps going while the task runs ...
let value = handle.await.unwrap(); // the task's 42, unwrapping the JoinError
}
tokio::spawn returns a JoinHandle<T>, and awaiting it gives you the task’s return value wrapped in Result. The Result is there because a task can panic or be aborted, and a panicking task does not take the program down with it: the panic is caught at the task boundary and handed back as an Err, with is_panic() distinguishing the two cases. You still see the panic message on stderr, so it is not silent. Tasks are isolated the way threads are, and for the same reason.
The price of being a task is Send. The multi-threaded runtime can move a task between worker threads at any .await, so its future has to implement Send, the trait from tip 34. Capture an Rc or a RefCell and the compiler refuses to spawn it.
Past two or three handles, awaiting them one at a time gets awkward. JoinSet owns a group of tasks and hands you results in completion order rather than spawn order. Dropping the set aborts whatever is still running in it, which makes it a tidy way to scope a group of tasks to a block:
#![allow(unused)]
fn main() {
let mut set = tokio::task::JoinSet::new();
for i in 0..3 {
set.spawn(async move { fetch(i).await });
}
while let Some(res) = set.join_next().await {
println!("finished: {}", res.unwrap());
}
}
Now the other side of the decision. join! waits for several futures without spawning anything:
#![allow(unused)]
fn main() {
let (a, b) = tokio::join!(fetch_a(), fetch_b());
}
These futures are polled by the current task, and that has two consequences. There is no Send requirement, so you can join! futures holding an Rc. And there is no parallelism, which is where the two words in play here come apart: concurrency is several futures taking turns to make progress, parallelism is them running in the same instant. A task is polled by one thread at a time, so join! buys you the first and never the second. Two CPU-bound futures of about 100 ms each finish in 217 ms under join!, and in 98 ms as two spawned tasks on a multi-core machine. join! overlaps waiting, not computing.
try_join! is the variant for futures returning Result, and it short-circuits: the first Err comes back immediately and the futures still in flight are dropped where they stand.
select! waits for whichever finishes first:
#![allow(unused)]
fn main() {
tokio::select! {
a = fetch_a() => println!("a finished first: {:?}", a),
b = fetch_b() => println!("b finished first: {:?}", b),
}
}
When several branches are ready in the same poll, select! picks among them at random, so a consistently fast branch cannot starve the others. The losing futures are dropped, which in Rust async is how you cancel work. We will return to this in a later tip.
Both macros take a fixed list of futures, so neither accepts a Vec. For a dynamic number, reach for a JoinSet if the work can be tasks, or futures::future::join_all if it cannot.
For tasks that need to coordinate, channels are the standard tool. Their std counterparts block the thread, as tip 52 covered, so async code wants the tokio versions:
tokio::sync::mpsc: multiple-producer, single-consumer queue. The workhorse.tokio::sync::oneshot: a single value sent once. Used for “task A computes a result, task B awaits it.”tokio::sync::broadcast: multiple-producer, multiple-consumer. Every receiver gets every message.tokio::sync::watch: a single value that all receivers can read; senders overwrite.
#![allow(unused)]
fn main() {
let (tx, mut rx) = tokio::sync::mpsc::channel(32);
tokio::spawn(async move {
for i in 0..5 {
tx.send(i).await.unwrap();
}
});
while let Some(value) = rx.recv().await {
println!("got {}", value);
}
}
channel(32) is bounded: senders await once 32 messages are buffered, which gives you back-pressure for free. The loop ends on its own when the last sender is dropped. Prefer bounded channels; an unbounded channel that fills faster than it drains is a memory leak in slow motion.
Comparison:
- Go: channels are built into the language and
selectis a keyword. Tokio reaches the same shapes through library APIs. - C#:
Channel<T>inSystem.Threading.Channels, withTask.WhenAllandTask.WhenAnycovering join and select. - Python:
asyncio.Queue,asyncio.gather,asyncio.wait. Same shapes, different names, andTaskGroup(3.11+) is the closest thing to aJoinSet. - Rust: the split between “spawn a task” and “poll it right here” is explicit, and it decides both whether you need
Sendand whether your concurrency can become parallelism.
The takeaway: tokio::spawn turns work into an independent task that needs Send and can run in parallel, while join! and select! keep the work on the current task, concurrent but never parallel and with no Send requirement. JoinSet manages a group of tasks, and channels (mpsc, oneshot, broadcast, watch) are how they talk to each other.
🔇 Go: select is a keyword. Rust: select! is a macro because the language does not believe in keywords for things it can build with macros.