Rust Coding Questions and Answers
Welcome to this curated collection of Rust coding questions and answers. As your friendly librarian, I have gathered some of the most important concepts to help you master Rust!
You'll find questions separated into:
- Basic Concepts
- Advanced Concepts
Q: What is the difference between String and &str in Rust?
Answer:
In Rust, String and &str are both used to handle string data, but they have distinct differences in how they manage memory and handle ownership:
-
String:- It is an owned type.
- It is stored on the heap, meaning its size can grow or shrink at runtime.
- When a
Stringgoes out of scope, its memory is automatically freed (dropped). - You can mutate it (if it's declared
mut), e.g., by pushing new characters or strings to it.
-
&str(String Slice):- It is a borrowed type (a reference).
- It represents a view into a block of memory that contains a string (which could be on the heap, stack, or hardcoded in the binary as a static string).
- It does not have ownership of the data it points to; it merely looks at it temporarily.
- It is immutable by default and its size is fixed.
Example:
fn main() { // A String (heap-allocated, owned, growable) let mut my_string = String::from("Hello"); my_string.push_str(", world!"); // A &str (string slice, borrowed, fixed-size view) let my_slice: &str = &my_string[0..5]; // Borrows "Hello" // Hardcoded string literals are also of type &str (specifically &'static str) let static_str: &str = "I am stored in the binary"; }
Q: Explain Ownership and Borrowing in Rust.
Answer:
Ownership is Rust's most unique feature, which guarantees memory safety without needing a garbage collector. It operates on three main rules:
- Each value in Rust has a single variable that is its owner.
- There can only be one owner at a time.
- When the owner goes out of scope, the value is dropped (its memory is freed).
Borrowing is how Rust allows you to access a value without taking ownership of it, using references (& or &mut). It solves the problem of needing to pass values to functions without losing ownership.
Borrowing has two strict rules:
- At any given time, you can have either one mutable reference (
&mut T) or any number of immutable references (&T). - References must always be valid (Rust prevents dangling pointers).
Example of Ownership:
#![allow(unused)] fn main() { let s1 = String::from("hello"); let s2 = s1; // Ownership moves to s2 // println!("{}", s1); // Error! s1 is no longer valid }
Example of Borrowing:
#![allow(unused)] fn main() { fn calculate_length(s: &String) -> usize { // Takes an immutable reference s.len() } // s goes out of scope, but since it doesn't have ownership, nothing is dropped. let s1 = String::from("hello"); let len = calculate_length(&s1); // We borrow s1 instead of moving it }
Q: What are lifetimes in Rust, and when do you need to annotate them?
Answer:
A lifetime is a compile-time label that tells the borrow checker how long a reference is valid. They are not runtime data. They exist so the compiler can prove every reference outlives whatever it points at.
The Core Rule
A reference must never outlive the data it borrows.
#![allow(unused)] fn main() { fn dangling() -> &String { // ERROR: missing lifetime let s = String::from("hi"); &s // s dropped at end of fn — reference would dangle } }
The compiler doesn't trust you to track this; it requires every reference's lifetime to be derivable.
Annotating Lifetimes
#![allow(unused)] fn main() { fn longest<'a>(x: &'a str, y: &'a str) -> &'a str { if x.len() > y.len() { x } else { y } } }
'a is a generic lifetime parameter — like a generic type but for "how long this reference is alive." The signature says: the returned reference lives at least as long as the shorter of the two inputs.
You're not picking a specific duration. You're stating a relationship: "return outlives 'a, and both inputs outlive 'a."
Lifetime Elision Rules
The compiler infers lifetimes in common cases — you only annotate when ambiguous. The three rules:
- Each input reference gets its own lifetime:
fn f(x: &T, y: &U)→fn f<'a,'b>(x: &'a T, y: &'b U). - If there's exactly one input lifetime, it's assigned to all output references.
- If
&selfor&mut selfis present, its lifetime is assigned to all output references.
#![allow(unused)] fn main() { fn first(s: &str) -> &str { &s[..1] } // OK (rule 2) fn pick<'a>(a: &'a str, b: &str) -> &'a str { a } // must annotate; ambiguous (rule 1 only) impl Foo { fn name(&self) -> &str { &self.name } } // OK (rule 3) }
Lifetimes in Structs
A struct that holds a reference must declare its lifetime:
#![allow(unused)] fn main() { struct Parser<'src> { input: &'src str, pos: usize, } impl<'src> Parser<'src> { fn peek(&self) -> Option<&'src char> { ... } } }
The struct cannot outlive the string it points into. Same rule, just made explicit at the type level.
'static
#![allow(unused)] fn main() { let s: &'static str = "hello"; // string literal, lives forever }
'static means "for the entire program." Useful for:
- String literals in the binary's read-only data.
- Global constants.
- Trait objects
Box<dyn Error + 'static>(the default forSend + 'static).
Common mistake: T: 'static does not mean "the value lives forever." It means "the type contains no non-'static references" — i.e., it can be held arbitrarily long. An owned String is 'static because it borrows nothing.
Lifetime Subtyping & Variance
'static: 'a for any 'a — 'static is a subtype of every other lifetime. So &'static str can be passed where &'a str is expected.
Most references are covariant in their lifetime: &'long T can be used where &'short T is expected. &mut T is invariant — you can't shrink the lifetime, because it would let you write a short-lived reference into a long-lived slot.
Worked Example: Why Two Annotations Differ
#![allow(unused)] fn main() { struct Buffer<'a> { data: &'a [u8], } impl<'a> Buffer<'a> { // ALL valid for `&'a` fn full(&self) -> &'a [u8] { self.data } // Returns a slice tied to &self's lifetime, NOT 'a fn head(&self) -> &[u8] { &self.data[..4] } } }
full returns a slice as long-lived as the original data ('a). head is tied to the temporary &self reference. They behave differently for callers:
#![allow(unused)] fn main() { let b = Buffer { data: &owned }; let h = b.head(); drop(b); // ERROR: h still borrowed }
vs.
#![allow(unused)] fn main() { let h = b.full(); drop(b); // OK: h lives as long as `owned`, not b }
NLL (Non-Lexical Lifetimes)
Modern Rust shrinks borrows to actual usage, not lexical scope:
#![allow(unused)] fn main() { let mut v = vec![1, 2, 3]; let r = &v[0]; // immutable borrow starts println!("{}", r); // last use of r — borrow ends here v.push(4); // OK in NLL; would have been an error pre-2018 }
This eliminates most "obviously fine" errors that older Rust rejected.
Common Errors & Fixes
| Error | Cause | Fix |
|---|---|---|
borrowed value does not live long enough | Returning a reference to a local | Return owned (String), or accept input by reference |
cannot infer an appropriate lifetime | Two inputs, must say which the output ties to | Annotate explicitly |
requires that 'a outlive 'static | You're putting T: 'static somewhere (often a Box<dyn Trait>) | Either use 'static data or relax the bound |
cannot borrow as mutable...also borrowed as immutable | Overlapping borrows | Shrink scope; restructure to use split borrows |
When to Annotate
You must annotate when:
- A function returns a reference and elision rules don't disambiguate.
- A struct stores a reference.
- A trait method takes/returns multiple references and the relationship matters.
You shouldn't annotate when:
- Elision handles it (most simple cases).
- The signature reads more naturally with elision (avoid noise).
[!NOTE] Lifetimes don't make programs slower — they're erased at compile time. They make compilation harder (mostly for the writer); they make runtime memory safety free.
Interview Follow-ups
- "Why doesn't Java need this?" — Garbage collection. The price you pay is runtime overhead and non-deterministic destruction.
- "What's higher-ranked trait bound (
for<'a>)?" — A bound saying "this works for any lifetime," needed for closures that accept references with caller-chosen lifetime. - "What's the difference between
&'a TandT: 'a?" — First is a reference with lifetime'a. Second is the bound "all references inside T live at least as long as'a."
Q: When do you use Box, Rc, Arc, RefCell, and Cell?
Answer:
Rust ships with a family of smart pointers that each relax one specific rule of the standard ownership model in a controlled way. Picking the right one is the difference between idiomatic Rust and fighting the borrow checker.
The Cheat Sheet
| Type | Ownership | Mutability | Thread-safe | Cost |
|---|---|---|---|---|
Box<T> | Single owner, heap | Inherited | Yes (if T is) | Heap alloc only |
Rc<T> | Many owners, single-thread | Immutable | No | Non-atomic refcount |
Arc<T> | Many owners, cross-thread | Immutable | Yes | Atomic refcount |
Cell<T> | Single owner | Interior mutable (Copy values) | No (!Sync) | Zero |
RefCell<T> | Single owner | Interior mutable (any) | No (!Sync) | Runtime borrow checks |
Mutex<T> | Single owner (usually in Arc) | Interior mutable | Yes | Lock acquire |
RwLock<T> | Same | Many readers OR one writer | Yes | Lock acquire |
Box<T> — Heap Allocation
Use when:
- The value is too big for the stack (e.g., a large array).
- You need a known-size handle to an unknown-size value (recursive types).
- You want a trait object:
Box<dyn Trait>.
#![allow(unused)] fn main() { enum List { Cons(i32, Box<List>), // recursive — without Box, infinite size Nil, } let logger: Box<dyn Write> = Box::new(File::create("log")?); }
Box<T> is just a heap pointer + automatic drop. No refcount.
Rc<T> — Shared Single-Threaded Ownership
Use when:
- The compiler can't see that one of several borrowers will outlive the rest, so single-ownership doesn't fit.
- Single-threaded only (DOM, graphs, parent/child trees within one thread).
#![allow(unused)] fn main() { use std::rc::Rc; let shared = Rc::new(String::from("config")); let a = Rc::clone(&shared); let b = Rc::clone(&shared); // All three drop together; backing String freed when the last drops. }
Rc::clone only bumps a counter — it does not copy the data.
Arc<T> — Shared Cross-Thread Ownership
Same as Rc but uses atomic refcount operations. Send + Sync when T: Send + Sync.
#![allow(unused)] fn main() { use std::sync::Arc; use std::thread; let cfg = Arc::new(load_config()); for _ in 0..4 { let cfg = Arc::clone(&cfg); thread::spawn(move || serve(&cfg)); } }
Cost: ~2× a non-atomic counter on x86. Don't use Arc "just in case" if you'll never share across threads — use Rc.
Cell<T> — Interior Mutability for Copy Types
Lets you mutate through &Cell<T>. Only useful for T: Copy because the API is get()/set() (copies value in/out).
#![allow(unused)] fn main() { use std::cell::Cell; struct Counter { count: Cell<u32> } impl Counter { fn inc(&self) { self.count.set(self.count.get() + 1); } // takes &self! } }
No runtime cost. Best fit for small numeric state inside a struct you want to expose immutably.
RefCell<T> — Interior Mutability for Any Type
Defers borrow checking to runtime. borrow() returns Ref<T>, borrow_mut() returns RefMut<T>. Violations panic.
#![allow(unused)] fn main() { use std::cell::RefCell; let data = RefCell::new(vec![1, 2, 3]); data.borrow_mut().push(4); // Panic at runtime: let _r1 = data.borrow(); let _r2 = data.borrow_mut(); // BorrowMutError }
Use sparingly. It's a last-resort tool for cases where the borrow checker is right but unhelpful (e.g., observer patterns where ownership and mutability genuinely interleave).
Common Compositions
| Pattern | Means |
|---|---|
Box<dyn Trait> | Single-owner trait object on the heap |
Rc<RefCell<T>> | Multiple owners + mutable state (single-threaded) |
Arc<Mutex<T>> | Multiple owners + mutable state across threads |
Arc<RwLock<T>> | Same, optimized for many readers |
Arc<T> (no lock) | Read-only shared state |
Rc<Vec<T>> | Shared immutable list |
Decision Flowchart
Need multiple owners?
├── No → Box<T> (heap) or just T (stack)
└── Yes
├── Single thread?
│ ├── Read-only: Rc<T>
│ └── Need mutation: Rc<RefCell<T>>
└── Multi-thread?
├── Read-only: Arc<T>
├── Mutation, simple: Arc<Mutex<T>>
├── Many readers: Arc<RwLock<T>>
└── Atomics enough: Arc<AtomicX>
Reference Cycles & Weak
Rc/Arc use strong reference counts. A cycle (A → B → A) never reaches 0 → leak.
#![allow(unused)] fn main() { use std::rc::{Rc, Weak}; use std::cell::RefCell; struct Node { parent: RefCell<Weak<Node>>, // upward = Weak (no cycle) children: RefCell<Vec<Rc<Node>>>, } }
Weak<T> holds a non-owning pointer; you upgrade it to Rc<T> if the value still exists (weak.upgrade() returns Option<Rc<T>>).
Cost & Common Mistakes
| Mistake | Reality |
|---|---|
Using Arc where Rc would do | Atomic ops are cheap but not free; matters in tight loops |
Arc<Mutex<T>> held across .await | Deadlocks runtime — use tokio::sync::Mutex |
RefCell instead of restructuring code | Often a sign that the design needs to split ownership differently |
Box::leak to get &'static | Works for one-time init; never call it in a loop |
Treating Clone of Rc like deep clone | It's a refcount bump. Use (*rc).clone() for deep |
[!NOTE] A useful heuristic: if you're reaching for
Rc<RefCell<T>>, ask whether you can rewrite using a typed index into aVec(the "arena" pattern). Often cleaner and avoids interior mutability entirely.
Interview Follow-ups
- "Why isn't
RcSync?" — Its refcount uses non-atomic ops. Two threads cloning would race. - "How does
Boxdiffer from C++'sunique_ptr?" — Conceptually identical. Rust enforces move-only at the type level; C++ does at convention. - "What's
Pin<Box<T>>?" — ABoxwhose contents are guaranteed not to be moved. Required for self-referential types (async generators).
Q: When is unsafe necessary in Rust, and what does it actually let you do?
Answer:
unsafe doesn't disable the borrow checker. It unlocks five specific operations the compiler can't statically verify. The programmer takes on the proof obligation. Everything outside those five remains checked.
The Five Unsafe Superpowers
- Dereference a raw pointer (
*const T,*mut T). - Call an
unsafe fn(including FFI). - Access or modify a
mutstatic. - Implement an
unsafe trait(Send,Sync,GlobalAlloc). - Access fields of a
union.
Nothing else changes inside an unsafe block. let x = 1 + 1; is still checked.
Why It Exists
Safe Rust forbids legitimate operations:
- FFI calls (the foreign function is opaque to the borrow checker).
- Low-level data structures (
Vec,HashMapinternals). - Performance hacks (skip a bounds check the programmer just verified).
- Hardware access (memory-mapped registers, MMIO).
unsafe is the escape hatch with a contract: the writer guarantees the safety invariants the compiler can't.
Anatomy of an unsafe Block
#![allow(unused)] fn main() { fn split_at_mut<T>(v: &mut [T], mid: usize) -> (&mut [T], &mut [T]) { let len = v.len(); let ptr = v.as_mut_ptr(); assert!(mid <= len); unsafe { ( std::slice::from_raw_parts_mut(ptr, mid), std::slice::from_raw_parts_mut(ptr.add(mid), len - mid), ) } } }
Borrow checker would reject this — two &mut to the same slice. The programmer proves they're disjoint (mid <= len, then non-overlapping ranges) and uses unsafe to construct them.
The Soundness Boundary
A function is sound if no safe code can use it to cause undefined behavior. split_at_mut is sound: caller can't trigger UB no matter what mid and v they pass.
Sound unsafe is a building block. Unsound unsafe is a bug — even if it "works."
#![allow(unused)] fn main() { // UNSOUND — UB if i >= v.len() unsafe fn get(v: &Vec<i32>, i: usize) -> i32 { *v.get_unchecked(i) } // Sound wrapper fn get(v: &Vec<i32>, i: usize) -> i32 { assert!(i < v.len()); unsafe { *v.get_unchecked(i) } } }
Common UB to Avoid
| UB | Example | Result |
|---|---|---|
| Dereferencing dangling pointer | &*Box::into_raw(b) after b dropped | crash/garbage |
Aliasing &mut | Two &mut to same data via raw pointers | LLVM miscompiles |
| Reading uninitialized memory | MaybeUninit without write | nasal demons |
| Breaking type invariants | transmute::<u8, bool>(2) | bool with value 2 — UB on use |
| Calling FFI with wrong ABI | extern "C" mismatch | crash |
Raw Pointers vs References
#![allow(unused)] fn main() { let x = 5; let r: &i32 = &x; // reference: borrow-checked, non-null, aligned let p: *const i32 = &x; // raw pointer: nullable, no borrow tracking unsafe { println!("{}", *p) } // dereferencing requires unsafe }
Conversion &T → *const T is safe. The reverse and dereferencing are not.
MaybeUninit for Uninitialized Memory
Reading uninitialized memory is UB. MaybeUninit<T> lets you allocate without initializing, then promise it's been filled:
#![allow(unused)] fn main() { use std::mem::MaybeUninit; let mut buf: [MaybeUninit<u8>; 1024] = unsafe { MaybeUninit::uninit().assume_init() }; file.read_exact(unsafe { std::mem::transmute(&mut buf[..]) })?; let initialized: &[u8] = unsafe { std::mem::transmute(&buf[..]) }; }
Avoids zero-initializing a buffer you're about to overwrite.
FFI Pattern
#![allow(unused)] fn main() { extern "C" { fn strlen(s: *const i8) -> usize; } fn safe_strlen(s: &CStr) -> usize { unsafe { strlen(s.as_ptr()) } } }
Wrap the unsafe FFI call in a safe function with type-checked inputs. Inside, you uphold C's contract; outside, callers see only the safe API.
unsafe fn vs unsafe block
#![allow(unused)] fn main() { unsafe fn dangerous() { ... } // function's contract requires unsafe to call fn safe_wrapper() { // safe to caller — wrapper upholds invariants unsafe { dangerous() } } }
unsafe fn propagates the obligation to callers. Use only when the function's correct use cannot be enforced internally.
Tools to Help
| Tool | Catches |
|---|---|
cargo miri | Most UB at runtime — aliasing, OOB, uninit reads |
cargo +nightly fuzz | Crash inputs |
cargo asan (sanitizers) | Memory errors via LLVM ASan |
clippy::undocumented_unsafe_blocks | Forces // SAFETY: comments |
Documentation Convention
Every unsafe block gets a // SAFETY: ... comment explaining why it's sound:
#![allow(unused)] fn main() { // SAFETY: ptr is non-null and aligned because it came from `Box::into_raw`, // and we have exclusive access — no other reference exists. unsafe { Box::from_raw(ptr) } }
Standard library uses this religiously. Treat it as compiler-enforced docs.
Common Mistakes
| Mistake | Reality |
|---|---|
Wrapping random code in unsafe to silence errors | unsafe doesn't disable checks — you'll still get the same error |
Holding &mut T and &T simultaneously via pointers | Aliasing violation — UB even if "looks fine" |
transmute between types of different sizes | UB |
| Dereferencing pointer to dropped value | Use-after-free |
Assuming unsafe = "panic on misuse" | UB is silent; it might work today, miscompile tomorrow |
[!NOTE] Goal: keep
unsafeblocks small, rare, and wrapped in safe APIs. Bad pattern: 200-lineunsafeblock. Good pattern: 3-lineunsafewith surrounding safe code that establishes preconditions.
Interview Follow-ups
- "Why can't the compiler check FFI?" — Foreign function's invariants aren't expressed in Rust's type system. Compiler can't read its source.
- "Is
unsafeRust still memory-safe?" — Insideunsafe, no — the programmer takes the proof obligation. The rest of the program is safe assuming allunsafeblocks are sound. - "What's stacked borrows / tree borrows?" — Aliasing models Miri uses to detect UB. Stacked Borrows is older; Tree Borrows is the proposed successor.
Q: dyn Trait vs impl Trait — static vs dynamic dispatch in Rust.
Answer:
Both let a function work with "some type implementing this trait," but they're fundamentally different mechanisms with different cost, flexibility, and limitations.
impl Trait — Static Dispatch (Monomorphization)
#![allow(unused)] fn main() { fn make_greeter() -> impl Fn() -> String { || String::from("hi") } fn print_lines<W: Write>(w: &mut W, lines: &[&str]) -> io::Result<()> { for l in lines { writeln!(w, "{}", l)?; } Ok(()) } }
At compile time, the compiler generates a specialized copy for every concrete type used at a call site. The call is a direct, inlinable function call.
- Zero indirection. Often inlined.
- Aggressive optimization (the optimizer sees the concrete type).
- Binary grows with each instantiation (code bloat).
dyn Trait — Dynamic Dispatch (vtable)
#![allow(unused)] fn main() { fn print_lines(w: &mut dyn Write, lines: &[&str]) -> io::Result<()> { for l in lines { writeln!(w, "{}", l)?; } Ok(()) } let writers: Vec<Box<dyn Write>> = vec![ Box::new(File::create("a")?), Box::new(io::stdout()), ]; }
The compiler builds one function that takes a fat pointer = (data pointer, vtable pointer). Each method call indirects through the vtable.
- Single function in the binary (no monomorphization).
- One pointer indirection per virtual call (~no measurable cost in non-tight loops; non-inlinable).
- Lets you store heterogeneous types in one collection.
The Fat Pointer
&dyn Write = | data ptr | vtable ptr |
│ │
▼ ▼
actual struct [drop, size, align, write, flush, ...]
&dyn Trait is two words. &T is one word.
When to Use Which
| Need | Use |
|---|---|
| Pure performance, one type at a call site | impl Trait / generics |
| Heterogeneous collection | Vec<Box<dyn Trait>> |
| Plugin systems, return type chosen at runtime | Box<dyn Trait> |
| Trait objects through a function boundary | &dyn Trait |
| Reducing binary size in a large codebase | dyn (one body vs many) |
| Async trait methods | Box<dyn Future> (or async-trait) |
Where impl Trait Can Appear
- Argument position:
fn f(x: impl Trait)— equivalent tofn f<T: Trait>(x: T). - Return position:
fn make() -> impl Trait— caller can't name the type; useful for closures, iterators. - Type-position in lets (since 1.26): not allowed directly; you write
let x: Box<dyn Trait> = ...or use TAIT (type Alias = impl Trait;).
Object Safety
Not every trait can be used with dyn. The trait must be object-safe:
- No
Selfin method signatures (other than&self/&mut self). - No generic methods.
- No associated constants (mostly).
#![allow(unused)] fn main() { trait Bad { fn dup(&self) -> Self; // ❌ returns Self by value fn parse<T>(s: &str) -> T; // ❌ generic method } // Bad is not object-safe; `dyn Bad` won't compile. }
If you need both styles, split:
#![allow(unused)] fn main() { trait Reader { fn read(&mut self, buf: &mut [u8]) -> usize; // object-safe } trait ReaderExt: Reader { fn read_to_string(&mut self) -> String { ... } // non-object-safe extension via default } }
Mixing Them
You can take &mut dyn Read from inside a generic function:
#![allow(unused)] fn main() { fn read_all<R: Read>(mut r: R) { fn inner(r: &mut dyn Read) { /* one body */ } inner(&mut r); } }
Common pattern to keep the generic API but only one copy of the heavy body in the binary.
Closures: Same Choice
#![allow(unused)] fn main() { fn map_static<F: Fn(i32) -> i32>(v: Vec<i32>, f: F) -> Vec<i32> { ... } // static fn map_dyn(v: Vec<i32>, f: Box<dyn Fn(i32) -> i32>) -> Vec<i32> { ... } // dynamic }
Box<dyn Fn> is what you need to store a closure in a struct field of fixed type:
#![allow(unused)] fn main() { struct Handler { on_event: Box<dyn Fn(Event) + Send>, } }
Performance Reality
For a CPU-bound tight loop calling a tiny method:
impl Traitlets the compiler inline → can be 5–10× faster.
For business logic, network code, allocation-heavy work:
- The vtable lookup is dwarfed by the surrounding work; difference is unmeasurable.
Profile before optimizing.
Common Mistakes
| Mistake | Fix |
|---|---|
Vec<impl Trait> (compile error: each element must be same type) | Vec<Box<dyn Trait>> for heterogeneity |
Forgetting + 'static on Box<dyn Trait> when needed | The default is + 'static; for shorter lifetimes write Box<dyn Trait + 'a> |
Storing dyn Future directly (it's !Sized) | Use Pin<Box<dyn Future<Output=T>>> |
Using dyn everywhere "for flexibility" | Pay code-size cost without benefit — generics are cheap to add |
[!NOTE]
impl Traitis the right default. Reach fordynwhen you genuinely need erasure — heterogeneous storage, plugin boundaries, FFI shims, or to bound binary size.
Interview Follow-ups
- "What does
Box<dyn Error>give you?" — A single error type that hides specific source types. Useful formainand at API boundaries. - "
&dyn TraitvsBox<dyn Trait>?" — Reference doesn't own; Box does. Same vtable mechanics. - "Why
+ Sendondyn?" — Defaults to+ 'staticbut not+ Send. Add it when sharing across threads.
Q: Fn, FnMut, FnOnce — what's the difference?
Answer:
Closures in Rust implement one of three traits depending on how they capture their environment. The compiler picks the most permissive trait automatically. Understanding the hierarchy explains "cannot move out of captured variable" errors.
The Hierarchy
FnOnce (called at least once, may consume captures)
▲
│ super-trait
│
FnMut (called many times, may mutate captures)
▲
│ super-trait
│
Fn (called many times, only borrows immutably)
Every Fn is also FnMut and FnOnce. Every FnMut is also FnOnce. Not vice versa.
The Three Capture Modes
#![allow(unused)] fn main() { let s = String::from("hi"); // Fn — captures by & let print = || println!("{}", s); print(); print(); // OK, called many times // FnMut — captures by &mut let mut v = vec![1, 2, 3]; let mut push = |x| v.push(x); push(4); push(5); // OK, mutates // FnOnce — captures by value (move) let owner = move || drop(s); owner(); // OK // owner(); // ERROR: value moved }
Compiler infers the minimum required. move keyword forces by-value capture (turns a Fn into a Fn-that-owns).
When Each Is Required
Function signatures pick the looseness:
#![allow(unused)] fn main() { fn run_once<F: FnOnce()>(f: F) { f(); } fn run_many<F: FnMut()>(mut f: F) { f(); f(); } fn run_shared<F: Fn()>(f: F) { f(); f(); } }
FnOnceis the most permissive bound — accepts any closure, but you can only call it once.Fnis the most restrictive bound — but supports&F(shared) and calling from multiple places.
Rule of thumb: take the least restrictive bound your code needs.
Function Pointers fn
fn(T) -> U is a separate type — a pointer to a free function, no environment. Coerces to all three traits.
#![allow(unused)] fn main() { fn double(x: i32) -> i32 { x * 2 } let f: fn(i32) -> i32 = double; // function pointer let c: Box<dyn Fn(i32) -> i32> = Box::new(double); // also fine }
A closure with no captures also coerces to fn. Useful for callbacks across FFI:
#![allow(unused)] fn main() { extern "C" fn callback(x: i32) { ... } unsafe { c_register(callback as *const u8); } }
Moving Captures: move Closures
#![allow(unused)] fn main() { let s = String::from("hi"); let closure = move || println!("{}", s); // s no longer usable — moved into closure }
Used heavily with thread::spawn and async tasks, where the closure outlives the caller's scope.
Returning Closures
You cannot return a bare closure — its size is unknown.
#![allow(unused)] fn main() { fn make() -> impl Fn(i32) -> i32 { |x| x + 1 } fn make_boxed() -> Box<dyn Fn(i32) -> i32> { Box::new(|x| x + 1) } }
impl Fn— static dispatch, type known at compile time.Box<dyn Fn>— dynamic dispatch, can return different concrete closures from branches.
Returning Different Closures From Branches
#![allow(unused)] fn main() { // ❌ ERROR — two different closure types fn pick(neg: bool) -> impl Fn(i32) -> i32 { if neg { |x| -x } else { |x| x } } // ✅ works with dyn fn pick(neg: bool) -> Box<dyn Fn(i32) -> i32> { if neg { Box::new(|x| -x) } else { Box::new(|x| x) } } }
Two |...| literals are two distinct types even if signatures match. impl Trait requires one concrete type.
Capturing &self and Method Closures
#![allow(unused)] fn main() { struct Counter(u32); impl Counter { fn incrementer(&mut self) -> impl FnMut() + '_ { move || self.0 += 1 } } }
'_ lifetime ties the closure to &mut self. The closure can't outlive the borrow.
Common Errors
#![allow(unused)] fn main() { let mut v = vec![1]; let f = || v.push(2); f(); // ERROR: closure was inferred Fn but needs FnMut }
Fix: let mut f = || v.push(2); — FnMut closures need mut.
#![allow(unused)] fn main() { let s = String::from("hi"); let f = || drop(s); f(); f(); // ERROR: FnOnce can only be called once }
Fix: don't drop captured value, or rebuild it inside.
Async Closures
Returning async {} from a closure gives FnOnce -> impl Future. Stable async closures (Rust 1.85+) help:
#![allow(unused)] fn main() { let process = async |x: u32| { fetch(x).await }; }
Before stable async closures, the common workaround: a closure returning async move { ... } block.
Cheat Sheet
| Use case | Trait | Capture |
|---|---|---|
| Pure transform, reused | Fn | &T |
| Stateful iterator | FnMut | &mut T |
| Spawned task / one-shot | FnOnce | T (by value) |
| Callback list | Box<dyn Fn> or Box<dyn FnMut> | varies |
| Cross-thread | FnOnce + Send + 'static | move |
Common Mistakes
| Mistake | Fix |
|---|---|
F: Fn when you need state mutation | F: FnMut and accept mut f: F |
Returning closure without Box/impl | Use impl Fn for single concrete; Box<dyn> for runtime |
| Captures last too long, causing borrow errors | Add move, or restructure data ownership |
Using FnOnce in a loop | Compile error — call it again? Wrap with Option::take if you must |
[!NOTE] If a function takes a closure, prefer
FnOncefirst, relax toFnMut, thenFnonly if you call repeatedly with shared access. Lower friction for callers.
Interview Follow-ups
- "What's the size of a closure?" — Sum of its captured environment, padded. A no-capture closure is zero-sized.
- "How is a closure compiled?" — Anonymous struct holding captures +
impl Fn*method body. - "
fnvsFn— what's the distinction?" — Lowercasefnis a function pointer type. UppercaseFnis a trait. Function pointers implementFn.
Q: How do #[derive] macros and blanket impls actually work?
Answer:
Two of the most heavily used trait features in Rust — #[derive(Clone)] and impls like impl<T: Display> ToString for T — look like compiler magic. They're both regular Rust mechanisms once you know what they do.
#[derive(...)]
#[derive] is a procedural macro that expands at compile time, generating a trait impl for your type.
#![allow(unused)] fn main() { #[derive(Debug, Clone, PartialEq)] struct Point { x: i32, y: i32 } }
After macro expansion:
#![allow(unused)] fn main() { struct Point { x: i32, y: i32 } impl std::fmt::Debug for Point { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("Point").field("x", &self.x).field("y", &self.y).finish() } } impl Clone for Point { fn clone(&self) -> Self { Self { x: self.x.clone(), y: self.y.clone() } } } impl PartialEq for Point { fn eq(&self, other: &Self) -> bool { self.x == other.x && self.y == other.y } } }
You can inspect the expansion with cargo expand (third-party command).
The Standard Derives
| Trait | Generates |
|---|---|
Debug | Pretty-print with field names |
Clone | Deep copy by .clone()-ing each field |
Copy | Marker — requires all fields Copy |
PartialEq / Eq | Field-by-field equality |
PartialOrd / Ord | Lexicographic by field order |
Hash | Hash each field in order |
Default | Zero/empty value per field |
Constraints on Auto-Derive
A derive generates an impl whose bounds are the same trait on every field type:
#![allow(unused)] fn main() { #[derive(Clone)] struct Wrap<T> { inner: T } // generated: impl<T: Clone> Clone for Wrap<T> }
So Wrap<String> is Clone, but Wrap<File> is not (because File: !Clone). The bound is auto-added.
This causes occasional surprises with PhantomData:
#![allow(unused)] fn main() { struct Holder<T> { data: i32, _t: PhantomData<T> } // derive(Clone) generates: impl<T: Clone> Clone for Holder<T> // even though T is never stored — the bound is wrong but conservative }
Fix: implement manually or use derivative crate for finer control.
Custom Derive Macros
Many crates ship derive macros: thiserror, serde, clap, tracing, bevy_ecs. They expand to typed impls for their own traits.
#![allow(unused)] fn main() { #[derive(serde::Serialize, serde::Deserialize)] struct Order { id: u64, items: Vec<String> } }
Generated: impl Serialize for Order { ... } walking each field.
Build your own:
#![allow(unused)] fn main() { // my_derive/src/lib.rs use proc_macro::TokenStream; #[proc_macro_derive(Hello)] pub fn derive_hello(input: TokenStream) -> TokenStream { let ast: syn::DeriveInput = syn::parse(input).unwrap(); let name = &ast.ident; quote::quote! { impl #name { pub fn hello() { println!("hi from {}", stringify!(#name)); } } }.into() } }
Procedural macros live in a separate crate with proc-macro = true.
Blanket Impls
A blanket impl applies to every type matching a bound:
#![allow(unused)] fn main() { impl<T: Display> ToString for T { fn to_string(&self) -> String { format!("{}", self) } } }
That single impl makes 42.to_string() work for i32, &str, your custom types — anything implementing Display.
From/Into is the textbook example:
#![allow(unused)] fn main() { impl<T, U> Into<U> for T where U: From<T> { fn into(self) -> U { U::from(self) } } }
You implement From, you get Into for free.
The Orphan Rule
You can implement a trait for a type only if either the trait or the type is defined in your crate:
#![allow(unused)] fn main() { // In your crate: impl Display for MyType { ... } // ✅ your type impl MyTrait for String { ... } // ✅ your trait impl Display for String { ... } // ❌ both foreign }
This prevents two crates from implementing the same trait for the same type. Workaround: the newtype pattern.
#![allow(unused)] fn main() { struct MyString(String); impl Display for MyString { ... } // ✅ }
Trait Coherence
A consequence of the orphan rule: only one blanket impl can exist for any type/trait combination. If the standard library has impl<T: Display> ToString for T, you cannot also impl<T: Custom> ToString for T — conflicts everywhere they overlap.
This sometimes blocks "obviously fine" code. The compiler's error: conflicting implementations. Fix: trait-narrowed traits (MyDisplay) or newtypes.
Marker Traits
Some traits have no methods — their presence is the contract:
#![allow(unused)] fn main() { unsafe trait Send {} unsafe trait Sync {} trait Copy: Clone {} // also empty body trait Eq: PartialEq {} // empty body — promises reflexivity }
The compiler reads these as flags. Copy implies the type is bit-copyable. Send says "movable across threads."
Default Method Implementations
Traits can ship default bodies:
#![allow(unused)] fn main() { trait Greeter { fn name(&self) -> &str; fn greet(&self) -> String { format!("hi, {}", self.name()) } } }
Implementors can override greet. Often used to provide a method derivable from other trait methods.
Associated Types vs Generics
#![allow(unused)] fn main() { // Generic — implementor picks T per impl trait Container<T> { fn put(&mut self, x: T); } // Associated type — implementor has exactly one trait Container { type Item; fn put(&mut self, x: Self::Item); } }
Use associated types when one type makes sense per implementor (Iterator::Item); use generics when you want multiple impls for the same type with different parameters.
Super Traits
#![allow(unused)] fn main() { trait Serialize: Debug { ... } // Serialize requires Debug }
To impl Serialize for T, T must also impl Debug. Lets the trait's default methods rely on the supertrait.
Common Mistakes
| Mistake | Fix |
|---|---|
Deriving Copy for types containing String | Compile error — String isn't Copy. Use Clone. |
Deriving Default for fieldless enums | Doesn't work — Default needs a value; manually impl |
| Trying to impl foreign trait on foreign type | Orphan rule; use newtype |
#[derive(Clone)] on generic without proper bounds | The auto-bound T: Clone may be wrong; impl manually |
| Conflicting blanket impl with std | Use a narrower trait or newtype |
[!NOTE] Reach for derives reflexively for data types. Only hand-write when a derive would generate something wrong (PhantomData, custom
Debugfor redaction, etc.).
Interview Follow-ups
- "What's the difference between
deriveandproc_macro_attribute?" —deriveonly adds new impl items; attribute macros can rewrite the entire item. - "Can I derive a trait from a different crate?" — Only if that crate exports a
#[proc_macro_derive]. The trait itself doesn't need to be yours. - "How does
#[automatically_derived]show up in errors?" — Compiler marks derived impls so error messages attribute them to the derive, not to your code.
Q: What are Send and Sync, and how does Rust prevent data races at compile time?
Answer:
Send and Sync are auto-traits that tell the compiler which types are safe to move or share across threads. They are the foundation of Rust's "fearless concurrency" promise: a data race is a compile error, not a heisenbug.
Definitions
#![allow(unused)] fn main() { unsafe auto trait Send { } // value can be MOVED to another thread unsafe auto trait Sync { } // value can be SHARED (&T) between threads }
The exact relationship:
T: Sync ⇔ &T: Send
If you can send a &T to another thread, then T can be safely accessed from multiple threads simultaneously through shared references — that's what Sync means.
Auto Traits
Both are implemented automatically for types whose fields are all Send/Sync. You almost never impl Send or impl Sync directly — it's unsafe to do so.
#![allow(unused)] fn main() { struct Position { x: f64, y: f64 } // auto Send + Sync (only primitives) struct WithRc(Rc<i32>) // NOT Send, NOT Sync (because Rc isn't) }
What's Not Send or Sync
| Type | Send? | Sync? | Why |
|---|---|---|---|
Rc<T> | No | No | Non-atomic refcount; race on count = use-after-free |
Arc<T> (where T: Send+Sync) | Yes | Yes | Atomic refcount |
Cell<T> | depends on T | No | Interior mutability without synchronization |
RefCell<T> | depends on T | No | Same as Cell, plus runtime borrow checks aren't thread-safe |
*const T, *mut T | No | No | Raw pointers — you opt back in via unsafe |
MutexGuard<'a, T> | No (on most OSes) | Yes | Some OS mutexes require unlock on the same thread |
Mutex<T> (T: Send) | Yes | Yes | Mutex provides synchronization |
How the Compiler Uses Them
std::thread::spawn is declared roughly:
#![allow(unused)] fn main() { fn spawn<F, T>(f: F) -> JoinHandle<T> where F: FnOnce() -> T + Send + 'static, T: Send + 'static, }
So the closure (and everything it captures) must be Send. Try to send an Rc:
#![allow(unused)] fn main() { let rc = Rc::new(5); thread::spawn(move || println!("{}", rc)); // ERROR: `Rc<i32>` cannot be sent between threads safely // the trait `Send` is not implemented for `Rc<i32>` }
The fix: use Arc.
Sharing State: Arc<Mutex<T>>
#![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..10 { let c = Arc::clone(&counter); handles.push(thread::spawn(move || { *c.lock().unwrap() += 1; })); } for h in handles { h.join().unwrap(); } assert_eq!(*counter.lock().unwrap(), 10); }
Why both?
Arc<T>provides shared ownership across threads (atomic refcount →Send + Sync).Mutex<T>provides mutual exclusion for interior mutation (the lock makes&Mutex<T>enough to mutate).
Together: Arc<Mutex<T>> is the standard pattern for "multiple owners, exclusive access at a time."
Sync Without Mutation: Just Arc<T>
If T doesn't need mutation, you don't need a Mutex:
#![allow(unused)] fn main() { let config = Arc::new(load_config()); for _ in 0..4 { let c = Arc::clone(&config); thread::spawn(move || serve(&c)); } }
Read-only Arc<T> is Send + Sync (when T: Send + Sync).
Atomics: Sync Without a Lock
For primitive types:
#![allow(unused)] fn main() { use std::sync::atomic::{AtomicUsize, Ordering}; static COUNTER: AtomicUsize = AtomicUsize::new(0); COUNTER.fetch_add(1, Ordering::Relaxed); }
Atomics are Sync because their operations are atomic at the CPU level. Cheaper than Mutex<usize> for simple counters.
Common Compile Errors and Cures
#![allow(unused)] fn main() { let counter = Rc::new(RefCell::new(0)); thread::spawn(move || *counter.borrow_mut() += 1); // ERROR: `Rc<RefCell<i32>>` cannot be sent }
Cure: Arc<Mutex<i32>>.
#![allow(unused)] fn main() { let v = vec![Rc::new(1)]; v.par_iter().for_each(|r| println!("{}", r)); // ERROR: `Rc<i32>` is not Send }
Cure: change inner type to Arc<i32>, or restructure to avoid sharing across threads.
Manual Implementation (Rare, unsafe)
#![allow(unused)] fn main() { struct MyPtr(*mut u8); // Auto-derived as !Send and !Sync because of *mut u8. // Promise that our type is safe across threads (you'd better be right): unsafe impl Send for MyPtr {} unsafe impl Sync for MyPtr {} }
You promise the compiler that your invariants hold. Wrong = undefined behavior.
Send Without Sync — Real Case
MutexGuard is !Send on some platforms (because some pthread mutexes can only be unlocked by the locking thread). The lock itself (Mutex<T>) is Send + Sync, but the guard isn't.
Cell<T> is Send (if T: Send) — you can move ownership to another thread — but !Sync — you can't share a &Cell<T> because mutation has no synchronization.
Decision Table
| Need | Use |
|---|---|
| Read-only data shared across threads | Arc<T> |
| Mutable shared state, one writer at a time | Arc<Mutex<T>> |
| Many readers, few writers | Arc<RwLock<T>> |
| Single integer counter | AtomicUsize (static or in Arc) |
| Per-thread independent state | thread_local! |
| Async + shared mutation | Arc<tokio::sync::Mutex<T>> (do not hold std::sync::Mutex across .await) |
| Channel between threads | std::sync::mpsc / crossbeam / tokio::sync::mpsc |
[!NOTE] Compile-time data race prevention is the single feature that justifies Rust's ownership complexity for many teams. If you're convinced a
Send/Syncerror is wrong, you're almost always missing an invariant the compiler is right about.
Interview Follow-ups
- "What is
'staticdoing inspawn's bound?" — Threads outlive any caller's stack frame. The closure must not borrow short-lived data. - "Why is
Rcfaster thanArc?" — Non-atomic refcount ops. Single-threaded contexts pay no atomic cost. - "How do channels avoid the
Sendrequirement?" — They don't. Sending a value over a channel requiresT: Send. Channels are a mechanism;Sendis the policy.
Q: Channels in Rust — mpsc, oneshot, broadcast, watch. Which one when?
Answer:
Rust ecosystem ships several channel types. They look similar (send/recv) but have different semantics around senders, receivers, capacity, and replay. Picking the wrong one is the most common cause of "the message isn't being delivered" bugs.
The Cheat Sheet
| Channel | Senders | Receivers | Capacity | Semantics |
|---|---|---|---|---|
mpsc | many | one | bounded or unbounded | FIFO queue, latest-receiver wins |
spsc | one | one | bounded | Faster, single-producer |
oneshot | one | one | 1 | Send once, receive once |
broadcast | many | many | bounded ring | Each receiver gets every message |
watch | one | many | 1 (latest only) | New subscribers see latest |
bounded (crossbeam) | many | many | bounded | mpmc |
mpsc — Multi-Producer, Single-Consumer
Tokio's tokio::sync::mpsc is the workhorse for async pipelines.
#![allow(unused)] fn main() { use tokio::sync::mpsc; let (tx, mut rx) = mpsc::channel::<Event>(100); // bounded // Many producers for _ in 0..4 { let tx = tx.clone(); tokio::spawn(async move { tx.send(Event::new()).await.unwrap(); }); } // One consumer while let Some(event) = rx.recv().await { process(event).await; } }
Bounded channels apply backpressure — send() awaits until there's space. This is the whole point: a slow consumer slows producers, not OOMs the queue.
Unbounded variant:
#![allow(unused)] fn main() { let (tx, mut rx) = mpsc::unbounded_channel(); tx.send(event).unwrap(); // non-async send, no backpressure }
Avoid unbounded for production. They convert "consumer is slow" into "process runs out of memory and crashes."
oneshot — Single-Use Reply
For request/response patterns where one task asks another for one value:
#![allow(unused)] fn main() { use tokio::sync::oneshot; let (resp_tx, resp_rx) = oneshot::channel::<Reply>(); request_tx.send(Req { resp: resp_tx, ... }).await?; let reply = resp_rx.await?; // wait for one reply }
The handler holds resp_tx and calls resp_tx.send(reply) exactly once. After that, both ends are consumed.
Use cases:
- Per-request reply channels.
- "Done" signals from spawned tasks.
- One-shot cancellation.
broadcast — Pub/Sub
Every subscriber receives every message after they subscribe. Like a Linux ring buffer.
#![allow(unused)] fn main() { use tokio::sync::broadcast; let (tx, _) = broadcast::channel::<Event>(32); // Subscribers must call subscribe() and start receiving BEFORE messages send, // or they'll miss them. let mut rx1 = tx.subscribe(); let mut rx2 = tx.subscribe(); tokio::spawn(async move { tx.send(event).unwrap(); }); let _ = rx1.recv().await; let _ = rx2.recv().await; }
Important: capacity is per-subscriber. If a slow subscriber falls behind by more than the capacity, it gets a RecvError::Lagged(N) indicating it missed N messages — the channel doesn't block the producer for one slow consumer.
Use for: shutdown signals, config updates, event broadcasting.
watch — Latest-Value Subject
A single-slot value with version tracking. Receivers always see the latest value, not the history.
#![allow(unused)] fn main() { use tokio::sync::watch; let (tx, mut rx) = watch::channel(Config::default()); // Producer tx.send(new_config).unwrap(); // Consumers let cfg = rx.borrow().clone(); // current value, no wait rx.changed().await?; // wait until next change }
Use for: configuration updates, leader election state, "current value" semantics. Late subscribers see the current value, not the history.
Crossbeam Channels
Outside Tokio (or for sync code):
#![allow(unused)] fn main() { use crossbeam_channel::{bounded, unbounded, select}; let (s, r) = bounded::<i32>(100); // MPMC — many senders, many receivers let r2 = r.clone(); let s2 = s.clone(); }
Crossbeam supports select! macro over multiple channels (Go-style):
#![allow(unused)] fn main() { select! { recv(r1) -> msg => handle(msg.unwrap()), recv(r2) -> msg => handle(msg.unwrap()), default(Duration::from_secs(1)) => println!("timeout"), } }
std::sync::mpsc (Avoid)
The standard library's mpsc exists for historical reasons. It's slower than crossbeam, has fewer features, and isn't async-aware. Use tokio::sync::mpsc (async) or crossbeam_channel (sync).
Backpressure Patterns
Bounded channels are the easy 80%. For more shape:
1. Drop-when-full (lose messages instead of blocking producer):
#![allow(unused)] fn main() { match tx.try_send(event) { Ok(_) => {}, Err(TrySendError::Full(_)) => metrics.dropped.inc(), Err(TrySendError::Closed(_)) => break, } }
Used for telemetry where freshness > completeness.
2. Coalesce / batch (collect N before processing):
#![allow(unused)] fn main() { let mut batch = Vec::new(); loop { tokio::select! { Some(item) = rx.recv() => { batch.push(item); if batch.len() >= 100 { flush(&mut batch).await; } } _ = tokio::time::sleep(Duration::from_secs(1)) => { if !batch.is_empty() { flush(&mut batch).await; } } } } }
3. Semaphore for concurrency cap (not strictly a channel, but the same role):
#![allow(unused)] fn main() { let sem = Arc::new(Semaphore::new(10)); for url in urls { let permit = sem.clone().acquire_owned().await?; tokio::spawn(async move { fetch(url).await; drop(permit); }); } }
Closing Behavior
| Channel | Sender drops | Receiver drops |
|---|---|---|
mpsc | recv() returns None when all senders gone | send() returns SendError |
oneshot | Sender drops → receiver gets RecvError | Receiver drops → sender's send() returns Err(value) |
broadcast | All senders gone → recv returns Closed | Sender's send succeeds (no listeners) |
watch | Sender drops → changed() returns Err | Sender's send returns Err |
This is how channels signal cancellation: just drop one end.
Common Mistakes
| Mistake | Fix |
|---|---|
Unbounded mpsc "for safety" | Memory grows unboundedly under load |
broadcast subscribers created lazily after sends | New subscribers miss old messages |
Cloning oneshot::Sender (it doesn't impl Clone) | Use mpsc if you need multiple senders |
Holding a sender forever, expecting receiver to stop on None | Drop the sender when done so recv() returns None |
watch for streaming events | It's latest-value only, not a stream |
[!NOTE] Channel selection is a design question: what should happen when the receiver is slow? Block (mpsc bounded), drop (try_send), buffer-and-replay (broadcast), or "always show current" (watch). Pick the one that matches your domain semantics.
Interview Follow-ups
- "Why is
mpsconly single-consumer?" — Multi-consumer would need synchronization on every receive.crossbeam_channelis mpmc with that overhead; tokio's mpsc is faster by skipping it. - "How is async channel different from sync?" — Async sends/receives integrate with the executor — they
.awaitinstead of blocking the thread. - "What's
Notify?" — Tokio's signal primitive — like aCondvarfor async. Useful for "wake up the waiting task when something changes."
Q: How does async Rust work, and what does Tokio actually do?
Answer:
Rust's async is futures + an executor. The language gives you async/await and a Future trait; it does not give you a runtime. Tokio (or async-std, smol) supplies the executor and the async I/O primitives. Understanding the split is essential.
What async Compiles To
#![allow(unused)] fn main() { async fn fetch(url: &str) -> Result<String> { let body = client.get(url).send().await?.text().await?; Ok(body) } }
The compiler rewrites this into a state machine implementing Future:
struct FetchFuture { state: enum { Start, AwaitSend, AwaitText, Done } }
impl Future for FetchFuture {
fn poll(self: Pin<&mut Self>, cx: &mut Context) -> Poll<Output> {
loop {
match self.state {
Start => /* kick off send() */,
AwaitSend => match send_future.poll(cx) {
Pending => return Pending,
Ready(r) => /* advance to AwaitText */,
},
...
}
}
}
}
Each .await point becomes a state transition. The future is lazy — calling fetch(url) does nothing until something polls it.
What .await Actually Does
1. Poll the inner future.
2. If Ready(val): return val, continue.
3. If Pending: store the Waker (from Context), return Pending up the stack.
The Waker is the channel back to the executor: "wake me when there's progress." This is cooperative multitasking — futures only yield at .await points.
What Tokio Provides
#[tokio::main] async fn main() { let v = tokio::spawn(fetch("https://...")).await; }
Tokio supplies:
- A multi-threaded executor (work-stealing thread pool of ~N=CPU workers) that polls spawned futures.
- A reactor (
mio+ epoll/kqueue/IOCP) that wakes futures when sockets become ready. - Async I/O types:
TcpStream,File,tokio::sync::Mutex, channels, timers. tokio::spawn— submit a future to the executor's run queue.
┌──────────────────────────────┐
│ Tokio Executor │
│ (work-stealing thread pool)│
└──────────────┬───────────────┘
│ polls futures
▼
┌──────────────┐
│ Reactor │ <- mio / epoll / kqueue / IOCP
└──────────────┘
▲
│ wake when fd ready
OS kernel
Spawning vs Awaiting
#![allow(unused)] fn main() { // Sequential — total time = A + B let a = fetch("/a").await; let b = fetch("/b").await; // Concurrent — total time = max(A, B), within one task let (a, b) = tokio::join!(fetch("/a"), fetch("/b")); // Parallel across executor threads let ja = tokio::spawn(fetch("/a")); let jb = tokio::spawn(fetch("/b")); let (a, b) = (ja.await.unwrap(), jb.await.unwrap()); }
.awaiton a single future: sequential.join!/try_join!: concurrent within one task (no spawn).spawn: new task, runnable on any worker thread; truly parallel for CPU.
The Send Bound
#![allow(unused)] fn main() { tokio::spawn(async move { ... }); // ^^^^^^^^^ this future must be `Send` }
If you hold a !Send type (like Rc, RefCell) across an .await, the future is !Send and won't compile in spawn. Common cause: holding a std::sync::MutexGuard across an await:
#![allow(unused)] fn main() { let g = std_mutex.lock().unwrap(); something(*g).await; // ❌ guard held across await }
Fix: use tokio::sync::Mutex (its guard is Send), or scope the lock:
#![allow(unused)] fn main() { let v = { let g = std_mutex.lock().unwrap(); g.clone() }; something(v).await; }
Blocking the Runtime
A long synchronous call inside an async task blocks the entire worker thread. If you happen to take all worker threads, the runtime stalls.
#![allow(unused)] fn main() { // ❌ blocks a worker async fn handler() { std::thread::sleep(Duration::from_secs(5)); } // ✅ yield via async sleep async fn handler() { tokio::time::sleep(Duration::from_secs(5)).await; } // ✅ offload CPU-bound work let result = tokio::task::spawn_blocking(|| compute_heavy()).await?; }
spawn_blocking puts the work on a separate, bigger thread pool (default 512). Use for blocking I/O (sync DB drivers, file ops) and CPU-bound code.
Cancellation
Drop a future = cancel it. Code between .await points completes; the future then drops its state. No exceptions thrown.
#![allow(unused)] fn main() { let h = tokio::spawn(long_task()); h.abort(); // sends cancellation }
Implications:
- Use RAII (
Dropimpl) for cleanup, not "rescue blocks." - Be aware that holding a lock across an
.awaitand then being cancelled releases the lock when the guard drops — usually fine.
Pinning
Pin<&mut Self> in poll exists because async state machines may contain self-references (an & into your own state). Moving such a value would dangle the reference. Pin prevents the move.
99% of async code uses Box::pin or pin!() and never thinks about it. You meet Pin head-on only when implementing Future by hand.
Tokio Sync Primitives vs std
| std (sync) | tokio (async) | When |
|---|---|---|
std::sync::Mutex | tokio::sync::Mutex | Use tokio's if held across .await |
std::sync::mpsc | tokio::sync::mpsc | tokio is async-aware |
std::sync::RwLock | tokio::sync::RwLock | Same rule |
| - | tokio::sync::oneshot | Single-value send (e.g., reply channels) |
| - | tokio::sync::Notify | Like a condvar for async |
| - | tokio::sync::Semaphore | Bound concurrency |
Common Mistakes
| Mistake | Fix |
|---|---|
for url in urls { fetch(url).await; } when you wanted concurrency | join_all/buffer_unordered or spawn tasks |
Holding std::sync::Mutex guard across .await | Use tokio::sync::Mutex or scope the lock |
Heavy CPU work in async fn | spawn_blocking |
Reading bytes_read from sync Read::read | Use AsyncRead::read |
Using tokio::spawn for fire-and-forget without .await join handle | Errors silently dropped |
[!NOTE] Rust async is cooperative. If you don't
.await, you never yield. A CPU-bound async function is just a slow synchronous function.
Interview Follow-ups
- "Why doesn't Rust ship an executor?" — Different programs want different schedulers: single-thread embedded, work-stealing servers, etc. Decoupling let runtimes (Tokio, embassy, monoio) compete.
- "Difference between
async fnand returningimpl Future?" — Functionally equivalent. The first is sugar; the second lets you express things like+ Sendbounds withoutasync-trait. - "What is
select!?" — Race multiple futures, run the branch corresponding to the first to complete. Cancellation-aware.
Q: How do you write async methods in traits (and why was it hard)?
Answer:
For years, Rust's biggest async footgun was: you can't put async fn in a trait. Java's interface Handler { CompletableFuture<R> handle(...); } was simply not expressible in stable Rust. The async_trait crate filled the gap; native support arrived in Rust 1.75 (2023) and matured through 2024–2025.
Why It Was Hard
#![allow(unused)] fn main() { trait Handler { async fn handle(&self, req: Request) -> Response; } }
The problem: async fn desugars to "returns impl Future<Output = T>". For a trait method, that means the return type is anonymous and depends on the implementor. Rust couldn't express that through a dyn boundary or even a generic trait until specific compiler work was done.
async fn handle(...) -> Response
│
▼ desugars
fn handle(...) -> impl Future<Output = Response>
│
▼ what's the size? what's the concrete type per impl?
?
Pre-1.75: async_trait Macro
#![allow(unused)] fn main() { use async_trait::async_trait; #[async_trait] trait Handler { async fn handle(&self, req: Request) -> Response; } struct MyHandler; #[async_trait] impl Handler for MyHandler { async fn handle(&self, req: Request) -> Response { ... } } }
What the macro does: rewrites methods to return Pin<Box<dyn Future<Output = T> + Send + 'async_trait>>. Heap-allocates the future, type-erases it. Works on dyn-traits, but has runtime cost (Box allocation per call).
Variants for non-Send: #[async_trait(?Send)].
Native Async Trait Methods (Rust 1.75+)
#![allow(unused)] fn main() { trait Handler { async fn handle(&self, req: Request) -> Response; } impl Handler for MyHandler { async fn handle(&self, req: Request) -> Response { ... } } }
No macro. No Box allocation. The compiler generates an associated type for the future. Static dispatch works fully.
The dyn Problem
Native async traits work great for generic dispatch:
#![allow(unused)] fn main() { fn run<H: Handler>(h: H) { ... } // ✅ static dispatch, fast }
But not (yet, easily) for dyn:
#![allow(unused)] fn main() { fn run(h: &dyn Handler) { ... } // ❌ in many cases }
async fn in a trait creates an unnameable associated future type, which dyn needs to know about. Workarounds:
1. Return an explicit impl Future and box at the call site:
#![allow(unused)] fn main() { trait Handler { fn handle(&self, req: Request) -> impl Future<Output = Response> + Send; } let boxed: Box<dyn Handler> = ...; }
2. Use trait-variant crate to generate both versions:
#![allow(unused)] fn main() { #[trait_variant::make(SendHandler: Send)] trait Handler { async fn handle(&self, req: Request) -> Response; } }
Generates Handler (any Future) and SendHandler (with Send bound).
3. Keep using async_trait for dyn:
#![allow(unused)] fn main() { #[async_trait] trait DynHandler { async fn handle(&self, req: Request) -> Response; } }
Until native dynamic-dispatch async-trait stabilizes, async_trait remains the right call for plugin systems and trait objects.
Send / Sync Bounds
Async functions return futures. For tokio::spawn, the future must be Send. Native async traits don't auto-bound Send — you must declare it where you need it.
#![allow(unused)] fn main() { trait Handler { fn handle(&self, req: Request) -> impl Future<Output = Response> + Send; } }
Or via trait-variant:
#![allow(unused)] fn main() { #[trait_variant::make(Send)] trait Handler { async fn handle(&self, req: Request) -> Response; } }
Lifetime Captures
A common bug:
#![allow(unused)] fn main() { trait Handler { async fn handle(&self, req: &Request) -> Response; // ^ captured in the future } }
The returned future implicitly captures &self and &req. The future's lifetime must be ≤ the shorter of the two. Compiler does this for you, but it bites at use sites:
#![allow(unused)] fn main() { fn run(h: impl Handler) { let req = Request::new(); let fut = h.handle(&req); drop(req); // ❌ fut borrows req block_on(fut); } }
Common Patterns
1. Service trait (Tower-style):
#![allow(unused)] fn main() { trait Service<Req> { type Resp; type Err; async fn call(&mut self, req: Req) -> Result<Self::Resp, Self::Err>; } }
2. Repository trait with dyn:
#![allow(unused)] fn main() { #[async_trait] trait UserRepo: Send + Sync { async fn find(&self, id: u64) -> Result<Option<User>>; async fn save(&self, u: &User) -> Result<()>; } let repo: Arc<dyn UserRepo> = Arc::new(PgUserRepo::new(pool)); }
3. Plugin / extension trait with native async:
#![allow(unused)] fn main() { trait Middleware { async fn before(&self, req: &mut Request); async fn after(&self, resp: &mut Response); } }
Async Closures (Rust 2024 / 1.85+)
Closure-equivalent of native async traits:
#![allow(unused)] fn main() { let fetch = async |url: String| -> Result<String> { let r = reqwest::get(&url).await?; Ok(r.text().await?) }; let body = fetch("https://...".into()).await?; }
Implements AsyncFn, AsyncFnMut, AsyncFnOnce — mirrors Fn, FnMut, FnOnce for async.
Cost Comparison
| Style | Per-call cost | dyn-friendly | When |
|---|---|---|---|
Native async fn in trait | Zero | Limited | Generic-bounded code |
async_trait macro | Box alloc + virtual call | Yes | dyn traits, plugin systems |
Manual fn -> impl Future | Zero | Manual boxing needed | Library APIs |
trait-variant generated | Zero | Yes (Send variant) | Public APIs needing both |
Common Mistakes
| Mistake | Fix |
|---|---|
Forgetting + Send on returned future when calling tokio::spawn | Use trait-variant::make(Send) or explicit impl Future + Send |
Mixing async_trait and native — not the same signature | Pick one per trait |
Holding non-Send across .await in a method that needs Send | Same rule as any async — refactor |
Using async_trait for hot-path code | Box allocation per call; use native trait |
[!NOTE] For new code: native async traits everywhere,
async_traitonly when you genuinely needdyn. For libraries with a public trait,trait-variantis worth the setup — gives consumers bothSendand non-Sendflavors.
Interview Follow-ups
- "Why is Tower's Service trait not async fn?" — Predates native async traits.
Service::callreturnsSelf::Futureexplicitly; gives Tower flexibility for hand-rolled state machines and zero-alloc futures. - "Can you have
asyncin adyn-compatible trait yet?" — Limited; you can declare it butdyn Handleritself has restrictions. UseBoxFuturereturn types for dyn-friendly traits today. - "What about RPITIT (return position impl trait in trait)?" — That's exactly what native
async fn in traitbuilds on. The same feature stabilized them.
Q: How do Rust iterators work, and why are they zero-cost?
Answer:
An iterator in Rust is any type implementing the Iterator trait:
#![allow(unused)] fn main() { pub trait Iterator { type Item; fn next(&mut self) -> Option<Self::Item>; // dozens of default methods: map, filter, fold, collect, ... } }
Each call to next produces Some(item) or None when exhausted. All combinators (map, filter, take, ...) are built on top of next.
Lazy by design
Adapters do nothing until consumed. The following allocates nothing and does no work:
#![allow(unused)] fn main() { let it = (1..).map(|x| x * 2).filter(|x| x % 3 == 0); // nothing happens }
Consumers like collect, sum, for, fold drive the chain.
Why they are zero-cost
Each adapter is a small struct. Chained adapters compose into one big nested struct, and next on the outermost struct inlines all inner next calls. After optimization, the generated assembly is typically identical to a hand-written loop.
v.iter().map(f).filter(g).sum::<i32>()
inlines to roughly:
let mut acc = 0;
for &x in &v {
let y = f(x);
if g(&y) { acc += y; }
}
Three iteration flavors
| Method | Yields | Borrowing |
|---|---|---|
iter() | &T | shared borrow |
iter_mut() | &mut T | exclusive borrow |
into_iter() | T | takes ownership |
for x in &v desugars to for x in v.iter(); for x in v desugars to for x in v.into_iter().
collect and turbofish
#![allow(unused)] fn main() { let v: Vec<i32> = (0..5).collect(); let v = (0..5).collect::<Vec<i32>>(); // turbofish }
collect is generic over any FromIterator target: Vec, HashMap, String, Result<Vec<_>, _>, etc. The last is especially useful:
#![allow(unused)] fn main() { let parsed: Result<Vec<i32>, _> = ["1", "2", "bad"].iter().map(|s| s.parse()).collect(); }
collect short-circuits on the first Err.
Custom iterators
Implementing one is straightforward:
#![allow(unused)] fn main() { struct Counter(u32); impl Iterator for Counter { type Item = u32; fn next(&mut self) -> Option<u32> { self.0 += 1; if self.0 <= 5 { Some(self.0) } else { None } } } }
You instantly get .map, .filter, .sum, etc.
Useful adapters
enumerate,zip,chain,take_while,skip_while,peekable,windows(on slices),chunks,scan,flat_map.
[!NOTE] Reach for iterators before manual loops. They are typically just as fast, far more compositional, and the compiler catches off-by-one errors that manual indexing introduces.
Q: What is the difference between declarative macros (macro_rules!) and procedural macros?
Answer:
Rust has two macro systems. Both run at compile time and emit token streams, but they differ in power, complexity, and where they live.
Declarative macros (macro_rules!)
Pattern-matching over token trees. Lives in the same crate; no extra build setup.
#![allow(unused)] fn main() { macro_rules! square { ($x:expr) => { { let v = $x; v * v } }; } let n = square!(3 + 1); // expands to a block }
Common fragment specifiers:
| Specifier | Matches |
|---|---|
expr | an expression |
ident | an identifier |
ty | a type |
pat | a pattern |
stmt | a statement |
block | a { ... } block |
tt | any single token tree |
path | a path |
literal | a literal |
Repetition uses $( ... ),* / $( ... );* / $( ... )? etc.
#![allow(unused)] fn main() { macro_rules! vec_of { ($($x:expr),* $(,)?) => {{ let mut v = Vec::new(); $( v.push($x); )* v }}; } }
Procedural macros
Compile-time Rust functions that take a TokenStream and return a TokenStream. Must live in their own crate of type proc-macro = true.
Three flavors:
- Function-like:
my_macro!(...). - Derive:
#[derive(MyTrait)]on a struct/enum. - Attribute:
#[my_attr] fn foo() {}or on items.
Typical skeleton:
#![allow(unused)] fn main() { use proc_macro::TokenStream; #[proc_macro_derive(Hello)] pub fn derive_hello(input: TokenStream) -> TokenStream { let ast: syn::DeriveInput = syn::parse(input).unwrap(); let name = &ast.ident; quote::quote! { impl Hello for #name { fn hello() { println!("hi from {}", stringify!(#name)); } } } .into() } }
Real-world examples: serde::Serialize, tokio::main, thiserror::Error.
Trade-offs
| Aspect | macro_rules! | proc macros |
|---|---|---|
| Build cost | None | Extra crate, longer build |
| Power | Pattern matching only | Full Rust at compile time |
| Debuggability | cargo expand | cargo expand + log output |
| Hygiene | Mostly hygienic | Manual; Span matters |
| IDE support | Good | Improving |
When to use which
- Use
macro_rules!for syntactic sugar: builders, DSLs, repeated boilerplate that can be expressed by pattern matching. - Use proc macros when you need to inspect types or generics (e.g., generate trait impls from struct fields) or implement custom derives/attributes.
Pitfalls
- Macros that re-parse user expressions repeatedly can blow up compile times.
- Error spans in macros can be confusing; use
#[track_caller]and proc-macroSpancarefully. - Overusing macros hurts readability. Prefer regular functions when generics suffice.
[!NOTE] Reach for macros only after generics, traits, and plain functions cannot express the abstraction. They are powerful but they are also the easiest way to make a codebase impenetrable.
Q: How do you handle errors in Rust?
Answer:
Rust groups errors into two major categories: Recoverable and Unrecoverable errors.
1. Recoverable Errors (Result<T, E>)
For errors that can be handled gracefully (like a file not being found), Rust uses the Result enum:
#![allow(unused)] fn main() { enum Result<T, E> { Ok(T), Err(E), } }
You can handle Result using match or the ? operator.
Using match:
#![allow(unused)] fn main() { use std::fs::File; let f = File::open("hello.txt"); let f = match f { Ok(file) => file, Err(error) => panic!("Problem opening the file: {:?}", error), }; }
Using the ? Operator:
The ? operator is a shorthand that unwraps Ok values or immediately returns the Err from the current function.
#![allow(unused)] fn main() { use std::fs::File; use std::io::{self, Read}; fn read_username_from_file() -> Result<String, io::Error> { let mut f = File::open("hello.txt")?; let mut s = String::new(); f.read_to_string(&mut s)?; Ok(s) } }
2. Unrecoverable Errors (panic!)
For situations where the program reaches a state that it cannot recover from (like accessing an array out of bounds), Rust provides the panic! macro. When a panic occurs, the program will print a failure message, unwind and clean up the stack, and then quit.
fn main() { panic!("crash and burn"); }
Q: How does Pattern Matching work in Rust?
Answer:
Pattern matching in Rust is extremely powerful and allows you to compare a value against a series of patterns and execute code based on which pattern matches. This is primarily done using the match and if let constructs.
1. The match Operator
match takes a value and routes control to branches (arms) based on matching patterns. It is exhaustive, meaning every possible case must be covered.
#![allow(unused)] fn main() { enum Coin { Penny, Nickel, Dime, Quarter(String), // Can hold data } fn value_in_cents(coin: Coin) -> u8 { match coin { Coin::Penny => { println!("Lucky penny!"); 1 } Coin::Nickel => 5, Coin::Dime => 10, Coin::Quarter(state) => { println!("State quarter from {}!", state); 25 } } } }
2. if let Syntax
When you only care about matching one specific pattern while ignoring the rest, match can be overly verbose. In these cases, you can use if let.
#![allow(unused)] fn main() { let some_u8_value = Some(0u8); // Using match: match some_u8_value { Some(3) => println!("three"), _ => (), // Does nothing for other values } // Using if let (more concise): if let Some(3) = some_u8_value { println!("three"); } }
Q: How do you idiomatically work with Option and Result combinators?
Answer:
Pattern-matching every Option/Result quickly becomes noisy. Rust provides a rich set of combinators that let you express transformations as a pipeline. Knowing them is one of the biggest readability wins in real Rust code.
Option essentials
| Combinator | Behavior |
|---|---|
map(f) | Some(x) -> Some(f(x)), None -> None |
and_then(f) | flatMap: f returns Option<U> |
or(other) | falls back to other (eagerly evaluated) |
or_else(f) | falls back, lazily |
unwrap_or(d) | unwrap or default |
unwrap_or_else(f) | unwrap or lazily computed default |
filter(p) | Some(x) if p(&x) else None |
ok_or(e) | Some(x) -> Ok(x), None -> Err(e) |
take() | replaces with None, returns the previous value |
replace(v) | replaces with Some(v), returns the previous value |
as_ref() | &Option<T> -> Option<&T> |
#![allow(unused)] fn main() { let user: Option<String> = Some("alice".into()); let greeting: Option<String> = user.as_ref().map(|n| format!("hi, {n}")); }
Result essentials
| Combinator | Behavior |
|---|---|
map(f) | maps Ok |
map_err(f) | maps Err |
and_then(f) | chain another Result-returning step |
ok() | Result<T,E> -> Option<T> |
err() | Result<T,E> -> Option<E> |
unwrap_or_default() | T::default() on Err |
The ? operator
The single most common combinator alternative:
#![allow(unused)] fn main() { fn read_int(path: &str) -> Result<i32, Box<dyn std::error::Error>> { let s = std::fs::read_to_string(path)?; let n: i32 = s.trim().parse()?; Ok(n) } }
? calls From::from on the error so different error types compose. With libraries like anyhow or thiserror, error conversion is essentially free.
let ... else for early return
#![allow(unused)] fn main() { let Some(user) = lookup(id) else { return Err("not found".into()); }; }
Collecting into Result<Vec<_>, _>
#![allow(unused)] fn main() { let nums: Result<Vec<i32>, _> = ["1","2","3"].iter().map(|s| s.parse()).collect(); }
Short-circuits at the first Err.
Anti-patterns
.unwrap()in production code without a clear panic reason. Prefer.expect("why this can't fail")at minimum, or proper propagation.- Chains of
match opt { Some(x) => match foo(x) { ... } }— flatten withand_then. if let Some(x) = opt { x } else { return default; }— useunwrap_or/unwrap_or_else.
Visual: Option pipeline
Some(s) --map(parse)--> Some(Ok(n)) --transpose--> Ok(Some(n))
|
v
use with `?`
[!NOTE] The fluent combinator style usually reads better than nested
match. Memorizemap,and_then,unwrap_or_else,ok_or, and?; they cover 90% of real-world Option/Result code.
Q: How do Cargo workspaces, features, and dependency resolution work?
Answer:
Cargo is more than a build tool — it's a dependency manager, build orchestrator, and test runner. Three concepts unlock 90% of multi-crate projects: workspaces, features, and the resolver.
Workspaces
A workspace is multiple related crates sharing one Cargo.lock, target/, and dependency resolution pass.
my-app/
├── Cargo.toml <- workspace manifest
├── Cargo.lock
├── target/
├── api/
│ ├── Cargo.toml
│ └── src/lib.rs
├── worker/
│ ├── Cargo.toml
│ └── src/main.rs
└── shared/
├── Cargo.toml
└── src/lib.rs
Root Cargo.toml:
[workspace]
resolver = "2"
members = ["api", "worker", "shared"]
[workspace.dependencies]
serde = { version = "1", features = ["derive"] }
tokio = { version = "1", features = ["full"] }
shared = { path = "shared" }
[workspace.package]
edition = "2021"
license = "MIT"
Member Cargo.toml:
[package]
name = "api"
edition.workspace = true
license.workspace = true
[dependencies]
serde.workspace = true
shared.workspace = true
Benefits:
- One
cargo buildbuilds everything. - One
Cargo.lock= guaranteed identical dep versions across crates. - Shared
target/= no duplicated compiles. cargo test --workspaceruns all member tests.
Features
Features are compile-time flags that enable optional code and dependencies.
[features]
default = ["json"]
json = ["dep:serde_json"]
mysql = ["dep:sqlx", "sqlx/mysql"]
postgres = ["dep:sqlx", "sqlx/postgres"]
[dependencies]
serde_json = { version = "1", optional = true }
sqlx = { version = "0.7", optional = true }
Then in code:
#![allow(unused)] fn main() { #[cfg(feature = "json")] pub mod json; pub fn parse(input: &str) -> Value { #[cfg(feature = "json")] return serde_json::from_str(input).unwrap(); #[cfg(not(feature = "json"))] todo!() } }
Enable from CLI:
cargo build --no-default-features --features postgres
Feature Unification (The Big Footgun)
Cargo unifies features across a build graph. If crate A enables serde/derive and crate B enables serde/rc, Cargo builds serde with derive + rc.
Implication: a feature you enable in dev-dependencies can leak into production builds if the same dependency is used both places.
Mitigation: resolver v2 (workspace setting resolver = "2") treats dev-dependencies, build-dependencies, and target-specific deps as separate feature graphs.
The Resolver
Cargo picks the highest semver-compatible version for each dependency. Conflicts within compatibility resolve to one version; cross-major-version requirements result in two compiled copies of the same crate (e.g., serde 1.0.x and serde 2.0.x coexisting).
cargo tree # full dep tree
cargo tree -d # only duplicated crates
cargo tree -e features # show enabled features
Cargo.lock:
- Library crates: don't commit (lets downstream resolve).
- Binary crates / workspaces with binaries: commit (reproducible builds).
Dependency Sources
[dependencies]
local = { path = "../local" }
git-dep = { git = "https://github.com/...", tag = "v1.2.0" }
registry-dep = "1.5" # crates.io
private = { registry = "my-corp" } # alternate registry
[patch.crates-io] replaces a transitive dep across the whole graph — useful for hotfixes:
[patch.crates-io]
some-crate = { git = "https://github.com/me/fork", branch = "fix" }
Profiles
Tune compilation per build mode:
[profile.dev]
opt-level = 0
debug = true
[profile.release]
opt-level = 3
lto = "fat" # link-time optimization
codegen-units = 1
strip = true
panic = "abort"
[profile.release-with-debug]
inherits = "release"
debug = true
cargo build --release
cargo build --profile release-with-debug
Useful Cargo Subcommands
| Command | Purpose |
|---|---|
cargo check | Type-check without codegen — fast feedback |
cargo clippy -- -D warnings | Linter, deny warnings |
cargo fmt | rustfmt |
cargo test --workspace --all-features | Test everything |
cargo nextest run | Faster test runner (third-party) |
cargo deny check | Audit licenses, advisories, banned crates |
cargo udeps | Find unused dependencies |
cargo bloat --release | What's making the binary big |
cargo machete | Even simpler unused-dep finder |
Build Scripts
build.rs runs before compile:
fn main() { println!("cargo:rustc-env=GIT_HASH={}", git_hash()); println!("cargo:rerun-if-changed=schema.sql"); }
Common uses: codegen from .proto/.sql, compile-time constants, native lib linking.
Common Mistakes
| Mistake | Fix |
|---|---|
Forgetting resolver = "2" in workspace | Use it — v1 has the dev-dep feature leak |
Committing Cargo.lock in a library | Don't; downstream needs version flexibility |
Renaming features without --features plumbing | All downstream consumers break silently |
Heavy crate enabled via default features | Make heavy parts opt-in |
| Duplicate versions of a crate | cargo tree -d, use [patch] or update transitive deps |
Cross Compilation
rustup target add x86_64-unknown-linux-musl
cargo build --release --target x86_64-unknown-linux-musl
For glibc/Linux distroless deploys, musl static binaries are common. Use cross (containerized) for non-trivial targets.
[!NOTE] Cargo's most underrated feature is workspaces. Even a "small" project benefits from splitting into a
lib+bincrate — keeps test compilation fast and forces you to design library boundaries.
Interview Follow-ups
- "What's the difference between
rustupandcargo?" —rustupmanages toolchains (compiler versions, components, targets).cargois the project tool that uses one of those toolchains. - "Why are LTO and codegen-units=1 slow?" — LTO inlines across crates; cu=1 disables parallel codegen. Together: best perf, worst build time. Use for releases only.
- "Can you use git deps in published crates?" — No — crates.io requires all transitive deps from crates.io.
Q: anyhow vs thiserror — how do you design errors in real Rust applications?
Answer:
Idiomatic Rust error handling splits responsibility cleanly:
- Libraries use
thiserrorto define specific, typed errors consumers can match on. - Applications use
anyhowto box and propagate any error, attaching context as it bubbles up.
Both crates work with the standard std::error::Error trait. Neither replaces Result.
The Library Side: thiserror
thiserror is a derive macro that generates Display, Error, and conversion impls for an error enum:
#![allow(unused)] fn main() { use thiserror::Error; #[derive(Debug, Error)] pub enum ApiError { #[error("invalid request: {0}")] BadRequest(String), #[error("user {id} not found")] NotFound { id: u64 }, #[error("database error")] Database(#[from] sqlx::Error), #[error("io error")] Io(#[from] std::io::Error), } }
What you get:
Displayfor each variant from the#[error("...")]format string.From<sqlx::Error> for ApiErrorvia#[from]—?operator works.source()returns the wrapped error for the call chain.- Zero runtime cost — it's all macro-expanded code.
The result: callers can match err { ApiError::NotFound { id } => ... }. Errors are part of your API.
The Application Side: anyhow
anyhow::Error is a type-erased boxed error. You don't care which kind it is — you just want to propagate it up to main or an HTTP handler.
use anyhow::{Context, Result}; fn run() -> Result<()> { let cfg = load_config("./config.toml") .context("loading config")?; let db = connect(&cfg.db_url) .context("opening database")?; let user = db.fetch_user(42) .with_context(|| format!("fetching user {}", 42))?; Ok(()) } fn main() -> Result<()> { run().context("application startup failed")?; Ok(()) }
Failure output:
Error: application startup failed
Caused by:
0: fetching user 42
1: connection refused
2: io error: ECONNREFUSED
Each .context() adds a layer to the chain. The original error is preserved in source(). No unwrap, no expect.
When to Reach for Which
| Situation | Use |
|---|---|
| Public library, callers might match on errors | thiserror |
| Internal app code, errors only flow up to logs/handlers | anyhow |
Binary's main | anyhow::Result<()> |
| Web handler returning HTTP status | Typed error → mapped to status |
| Quick scripts, prototypes | anyhow (or just Result<T, Box<dyn Error>>) |
You can mix freely. A library that returns thiserror-derived errors can be called from an anyhow-using app: ? propagates because anyhow::Error: From<E: Error>.
Why Not Just Box<dyn Error>?
anyhow::Error is essentially a typed-better Box<dyn Error + Send + Sync + 'static>. Differences:
- Smaller (single word in some configurations).
- Always carries a backtrace if
RUST_BACKTRACE=1. contextextension method for adding layers.- Better error messages by default.
You can use Box<dyn Error> — anyhow is a strict upgrade for ergonomics.
Backtraces
Both crates capture backtraces when RUST_BACKTRACE=1 is set:
$ RUST_BACKTRACE=1 ./app
Error: failed to load config
Caused by:
0: io error
1: No such file or directory (os error 2)
Stack backtrace:
0: anyhow::error::<impl anyhow::Error>::msg
1: app::load_config
...
In production, capture with RUST_BACKTRACE=1 and ship stack traces to your error tracker (Sentry, Rollbar). Cheap relative to value.
Mapping to HTTP Status in Web Handlers
Define a typed app error, map at the boundary:
#![allow(unused)] fn main() { #[derive(Debug, Error)] pub enum AppError { #[error("not found")] NotFound, #[error("validation: {0}")] Validation(String), #[error(transparent)] Internal(#[from] anyhow::Error), } impl IntoResponse for AppError { fn into_response(self) -> Response { let (status, msg) = match &self { AppError::NotFound => (StatusCode::NOT_FOUND, "not found".into()), AppError::Validation(m) => (StatusCode::BAD_REQUEST, m.clone()), AppError::Internal(e) => { tracing::error!("{:#}", e); // alternate format prints chain (StatusCode::INTERNAL_SERVER_ERROR, "internal error".into()) } }; (status, msg).into_response() } } }
Inside the handler:
#![allow(unused)] fn main() { async fn get_user(Path(id): Path<u64>) -> Result<Json<User>, AppError> { let user = db.find_user(id).await? .ok_or(AppError::NotFound)?; Ok(Json(user)) } }
thiserror for the boundary, anyhow internally via Internal(#[from] anyhow::Error).
Adding Context — with_context vs context
#![allow(unused)] fn main() { do_thing().context("static description")?; // string is alloc'd lazily do_thing().with_context(|| format!("id {}", id))?; // closure only runs on Err }
Use with_context for messages with formatting — closure avoids the allocation on the happy path.
Common Mistakes
| Mistake | Fix |
|---|---|
unwrap() in production code | Use ? everywhere; propagate to main |
String errors (Err("bad".into())) | Anonymous strings lose structure — wrap in a typed error |
Box<dyn Error> in a public lib | Callers can't match on it; use thiserror enum |
Manually implementing Display and Error | Use thiserror — it's literally fewer lines |
| Multiple wraps without context | Add .context(...) at each layer for better chains |
Converting away the source via to_string() | Lose the chain; just use ? and #[from] |
Anyhow Macros
#![allow(unused)] fn main() { // Equivalent to Err(anyhow!("...")) anyhow::bail!("user {} not found", id); // Build an Err without returning let e: anyhow::Error = anyhow!("oops"); // Assert a precondition anyhow::ensure!(qty > 0, "qty must be positive"); }
Printing Errors
#![allow(unused)] fn main() { println!("{}", err); // top message only println!("{:#}", err); // top + chain println!("{:?}", err); // Debug — full chain + backtrace }
For log output prefer {:#} or Debug; for user-facing output prefer {}.
Migration Tip
Already using Box<dyn Error>? Migrating to anyhow is mostly:
#![allow(unused)] fn main() { - fn foo() -> Result<(), Box<dyn Error>> + fn foo() -> anyhow::Result<()> }
Conversions via ? already work — anyhow::Error implements From<E: Error>.
[!NOTE] The error story is one of Rust's biggest UX wins over older systems languages. Use it: define typed errors at library boundaries, propagate freely with
?, attach context as you go, and letmainprint the chain.
Interview Follow-ups
- "How do you propagate errors across async tasks?" — Same as sync, plus
JoinErrorfrom the runtime. Either map it inside the task or surface viaJoinSet::join_next. - "What's
eyre/color-eyre?" —eyreis a fork ofanyhowwith pluggable reporters;color-eyreformats nicely for terminals. Same shape. - "How does
?work?" — CallsTry::branch(stable:FromonErrvariant). Lets you propagate as long as the error types implementFrom.
Q: How do you test Rust code — unit, integration, doctests, property tests?
Answer:
Cargo treats tests as first-class. There are five distinct kinds, each with its own location and runner.
1. Unit Tests (Inside the Module)
#![allow(unused)] fn main() { // src/math.rs pub fn add(a: i32, b: i32) -> i32 { a + b } #[cfg(test)] mod tests { use super::*; #[test] fn adds_positive() { assert_eq!(add(2, 3), 5); } #[test] #[should_panic(expected = "overflow")] fn panics_on_overflow() { add(i32::MAX, 1); } } }
#[cfg(test)]means the module is compiled only forcargo test.- Can test private items because they're in the same module.
- Run with
cargo test.
2. Integration Tests (Black-Box, Outside the Crate)
my-crate/
├── src/lib.rs
└── tests/
├── http_api.rs
└── db.rs
#![allow(unused)] fn main() { // tests/http_api.rs use my_crate::Server; #[tokio::test] async fn handles_get() { let s = Server::start().await; let r = reqwest::get(s.url("/health")).await.unwrap(); assert_eq!(r.status(), 200); } }
- Each
.rsfile undertests/is a separate binary. - Only public API is reachable — these are consumer tests.
- Share helpers via
tests/common/mod.rs.
3. Doctests (Examples in Documentation)
#![allow(unused)] fn main() { /// Adds two numbers. /// /// # Examples /// /// ``` /// use my_crate::math::add; /// assert_eq!(add(2, 3), 5); /// ``` pub fn add(a: i32, b: i32) -> i32 { a + b } }
- Run as part of
cargo test. - Verify your docs stay accurate as code changes.
- For non-runnable examples:
```no_runor```ignore. - For examples that should fail to compile:
```compile_fail.
4. Benchmarks (Criterion)
[dev-dependencies]
criterion = "0.5"
[[bench]]
name = "math_bench"
harness = false
#![allow(unused)] fn main() { // benches/math_bench.rs use criterion::{black_box, criterion_group, criterion_main, Criterion}; fn bench_add(c: &mut Criterion) { c.bench_function("add 2,3", |b| b.iter(|| add(black_box(2), black_box(3)))); } criterion_group!(benches, bench_add); criterion_main!(benches); }
cargo bench. black_box prevents the optimizer from constant-folding the call away.
5. Property-Based Tests (proptest / quickcheck)
Generate random inputs and check invariants.
#![allow(unused)] fn main() { use proptest::prelude::*; proptest! { #[test] fn add_commutative(a in any::<i32>(), b in any::<i32>()) { prop_assume!(a.checked_add(b).is_some()); assert_eq!(a.wrapping_add(b), b.wrapping_add(a)); } } }
Proptest shrinks failing inputs to the minimal counterexample — much better than quickcheck for diagnosis.
Cargo Test Mechanics
cargo test # run all tests
cargo test --lib # unit tests only
cargo test --test http_api # one integration file
cargo test add_ # filter by name substring
cargo test -- --nocapture # show println! output
cargo test -- --test-threads=1 # serialize tests
cargo test --release # optimized build (for slow tests)
cargo nextest run (third-party) is significantly faster — process-per-test isolation, retries, JUnit XML output.
Setup / Teardown
No JUnit @Before — use RAII guards or helper functions:
#![allow(unused)] fn main() { struct TestCtx { db: TempDir, _server: ServerHandle } impl TestCtx { fn new() -> Self { ... } } impl Drop for TestCtx { fn drop(&mut self) { /* cleanup happens automatically */ } } #[test] fn it_works() { let ctx = TestCtx::new(); // ... // ctx dropped → cleanup at end } }
For one-time setup: std::sync::OnceLock or the ctor crate (#[ctor]).
Async Tests
#![allow(unused)] fn main() { #[tokio::test] async fn it_works() { fetch().await; } #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn parallel() { ... } }
For other runtimes: #[async_std::test], #[smol_potat::test].
Mocking and Test Doubles
Rust doesn't have reflection — mocks are explicit.
#![allow(unused)] fn main() { // trait, impl in lib for prod, impl in tests for fake pub trait Clock { fn now(&self) -> Instant; } pub struct SystemClock; impl Clock for SystemClock { fn now(&self) -> Instant { Instant::now() } } #[cfg(test)] struct FakeClock { t: Instant } #[cfg(test)] impl Clock for FakeClock { fn now(&self) -> Instant { self.t } } }
Or use crates: mockall (most popular), faux, mockito for HTTP.
Test Containers
For real-dependency integration tests:
#![allow(unused)] fn main() { use testcontainers::{clients::Cli, images::postgres::Postgres}; #[tokio::test] async fn db_roundtrip() { let docker = Cli::default(); let pg = docker.run(Postgres::default()); let conn_str = format!("postgres://postgres@localhost:{}/postgres", pg.get_host_port_ipv4(5432)); // ... use conn_str } }
Spins up real Postgres per test (or shared via OnceLock if you trust isolation).
Code Coverage
cargo install cargo-tarpaulin
cargo tarpaulin --out html --output-dir coverage
Or cargo llvm-cov — faster, uses LLVM's source-based coverage.
Fuzz Testing
cargo install cargo-fuzz
cargo fuzz init
# Writes fuzz/fuzz_targets/fuzz_target_1.rs
cargo fuzz run fuzz_target_1
Uses libFuzzer / AFL via the nightly compiler. Crashes are saved as inputs you can reproduce.
Snapshot Tests
#![allow(unused)] fn main() { use insta::assert_yaml_snapshot; #[test] fn renders() { let out = render(&data); assert_yaml_snapshot!(out); } }
First run: stores .snap file. Subsequent runs: diff against it. cargo insta review for interactive updates. Great for parser/AST tests.
Conventions
- Unit tests in same file: small, fast, test private logic.
- Integration tests under
tests/: exercise public API, real I/O if needed. - Doctests: as live examples — readers see they work.
- Properties / fuzzing: invariants under random input.
Test Output and CI
cargo test --no-fail-fast # don't stop on first failure
cargo test 2>&1 | tee test.log
JUnit XML (for CI): use cargo nextest run --message-format=junit > junit.xml.
Common Mistakes
| Mistake | Fix |
|---|---|
| Big setup in every test | Use helper functions or RAII context |
| Tests sharing global state (env vars, current_dir) | --test-threads=1 or use named env scopes |
Forgetting #[tokio::test] for async | Compiler error: "async fn outside trait" / unused future |
| Doctests that compile but don't run | They run by default; use no_run only when needed |
| Slow tests in unit layer | Move to integration or mark #[ignore] and run separately |
[!NOTE] Cargo's test ergonomics are a feature. The friction of "writing a test" should be near-zero — small file edits, no XML, no harness. Use it.
Interview Follow-ups
- "How do you test code that calls
std::process::exit?" — Refactor to return a value; test the value. Or shell out and check exit code. - "Difference between
unitandintegrationtests in Rust vs Java?" — Rust's distinction is purely about visibility/location, not technique. JUnit's idea of "unit" is closer to Rust's#[test]insidemod tests. - "How do you parallelize tests safely with shared resources?" — Use
serial_testcrate, or design tests to share viaOnceLock+ locks.