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:

  1. String:

    • It is an owned type.
    • It is stored on the heap, meaning its size can grow or shrink at runtime.
    • When a String goes 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.
  2. &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:

  1. Each value in Rust has a single variable that is its owner.
  2. There can only be one owner at a time.
  3. 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:

  1. At any given time, you can have either one mutable reference (&mut T) or any number of immutable references (&T).
  2. 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:

  1. Each input reference gets its own lifetime: fn f(x: &T, y: &U)fn f<'a,'b>(x: &'a T, y: &'b U).
  2. If there's exactly one input lifetime, it's assigned to all output references.
  3. If &self or &mut self is 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 for Send + '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

ErrorCauseFix
borrowed value does not live long enoughReturning a reference to a localReturn owned (String), or accept input by reference
cannot infer an appropriate lifetimeTwo inputs, must say which the output ties toAnnotate explicitly
requires that 'a outlive 'staticYou'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 immutableOverlapping borrowsShrink 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 T and T: '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

TypeOwnershipMutabilityThread-safeCost
Box<T>Single owner, heapInheritedYes (if T is)Heap alloc only
Rc<T>Many owners, single-threadImmutableNoNon-atomic refcount
Arc<T>Many owners, cross-threadImmutableYesAtomic refcount
Cell<T>Single ownerInterior mutable (Copy values)No (!Sync)Zero
RefCell<T>Single ownerInterior mutable (any)No (!Sync)Runtime borrow checks
Mutex<T>Single owner (usually in Arc)Interior mutableYesLock acquire
RwLock<T>SameMany readers OR one writerYesLock 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

PatternMeans
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

MistakeReality
Using Arc where Rc would doAtomic ops are cheap but not free; matters in tight loops
Arc<Mutex<T>> held across .awaitDeadlocks runtime — use tokio::sync::Mutex
RefCell instead of restructuring codeOften a sign that the design needs to split ownership differently
Box::leak to get &'staticWorks for one-time init; never call it in a loop
Treating Clone of Rc like deep cloneIt'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 a Vec (the "arena" pattern). Often cleaner and avoids interior mutability entirely.

Interview Follow-ups

  • "Why isn't Rc Sync?" — Its refcount uses non-atomic ops. Two threads cloning would race.
  • "How does Box differ from C++'s unique_ptr?" — Conceptually identical. Rust enforces move-only at the type level; C++ does at convention.
  • "What's Pin<Box<T>>?" — A Box whose 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

  1. Dereference a raw pointer (*const T, *mut T).
  2. Call an unsafe fn (including FFI).
  3. Access or modify a mut static.
  4. Implement an unsafe trait (Send, Sync, GlobalAlloc).
  5. 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, HashMap internals).
  • 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

UBExampleResult
Dereferencing dangling pointer&*Box::into_raw(b) after b droppedcrash/garbage
Aliasing &mutTwo &mut to same data via raw pointersLLVM miscompiles
Reading uninitialized memoryMaybeUninit without writenasal demons
Breaking type invariantstransmute::<u8, bool>(2)bool with value 2 — UB on use
Calling FFI with wrong ABIextern "C" mismatchcrash

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

ToolCatches
cargo miriMost UB at runtime — aliasing, OOB, uninit reads
cargo +nightly fuzzCrash inputs
cargo asan (sanitizers)Memory errors via LLVM ASan
clippy::undocumented_unsafe_blocksForces // 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

MistakeReality
Wrapping random code in unsafe to silence errorsunsafe doesn't disable checks — you'll still get the same error
Holding &mut T and &T simultaneously via pointersAliasing violation — UB even if "looks fine"
transmute between types of different sizesUB
Dereferencing pointer to dropped valueUse-after-free
Assuming unsafe = "panic on misuse"UB is silent; it might work today, miscompile tomorrow

[!NOTE] Goal: keep unsafe blocks small, rare, and wrapped in safe APIs. Bad pattern: 200-line unsafe block. Good pattern: 3-line unsafe with 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 unsafe Rust still memory-safe?" — Inside unsafe, no — the programmer takes the proof obligation. The rest of the program is safe assuming all unsafe blocks 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

NeedUse
Pure performance, one type at a call siteimpl Trait / generics
Heterogeneous collectionVec<Box<dyn Trait>>
Plugin systems, return type chosen at runtimeBox<dyn Trait>
Trait objects through a function boundary&dyn Trait
Reducing binary size in a large codebasedyn (one body vs many)
Async trait methodsBox<dyn Future> (or async-trait)

Where impl Trait Can Appear

  • Argument position: fn f(x: impl Trait) — equivalent to fn 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 Self in 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 Trait lets 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

MistakeFix
Vec<impl Trait> (compile error: each element must be same type)Vec<Box<dyn Trait>> for heterogeneity
Forgetting + 'static on Box<dyn Trait> when neededThe 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 Trait is the right default. Reach for dyn when 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 for main and at API boundaries.
  • "&dyn Trait vs Box<dyn Trait>?" — Reference doesn't own; Box does. Same vtable mechanics.
  • "Why + Send on dyn?" — Defaults to + 'static but 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(); }
}
  • FnOnce is the most permissive bound — accepts any closure, but you can only call it once.
  • Fn is 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 caseTraitCapture
Pure transform, reusedFn&T
Stateful iteratorFnMut&mut T
Spawned task / one-shotFnOnceT (by value)
Callback listBox<dyn Fn> or Box<dyn FnMut>varies
Cross-threadFnOnce + Send + 'staticmove

Common Mistakes

MistakeFix
F: Fn when you need state mutationF: FnMut and accept mut f: F
Returning closure without Box/implUse impl Fn for single concrete; Box<dyn> for runtime
Captures last too long, causing borrow errorsAdd move, or restructure data ownership
Using FnOnce in a loopCompile error — call it again? Wrap with Option::take if you must

[!NOTE] If a function takes a closure, prefer FnOnce first, relax to FnMut, then Fn only 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.
  • "fn vs Fn — what's the distinction?" — Lowercase fn is a function pointer type. Uppercase Fn is a trait. Function pointers implement Fn.

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

TraitGenerates
DebugPretty-print with field names
CloneDeep copy by .clone()-ing each field
CopyMarker — requires all fields Copy
PartialEq / EqField-by-field equality
PartialOrd / OrdLexicographic by field order
HashHash each field in order
DefaultZero/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

MistakeFix
Deriving Copy for types containing StringCompile error — String isn't Copy. Use Clone.
Deriving Default for fieldless enumsDoesn't work — Default needs a value; manually impl
Trying to impl foreign trait on foreign typeOrphan rule; use newtype
#[derive(Clone)] on generic without proper boundsThe auto-bound T: Clone may be wrong; impl manually
Conflicting blanket impl with stdUse 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 Debug for redaction, etc.).

Interview Follow-ups

  • "What's the difference between derive and proc_macro_attribute?"derive only 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

TypeSend?Sync?Why
Rc<T>NoNoNon-atomic refcount; race on count = use-after-free
Arc<T> (where T: Send+Sync)YesYesAtomic refcount
Cell<T>depends on TNoInterior mutability without synchronization
RefCell<T>depends on TNoSame as Cell, plus runtime borrow checks aren't thread-safe
*const T, *mut TNoNoRaw pointers — you opt back in via unsafe
MutexGuard<'a, T>No (on most OSes)YesSome OS mutexes require unlock on the same thread
Mutex<T> (T: Send)YesYesMutex 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

NeedUse
Read-only data shared across threadsArc<T>
Mutable shared state, one writer at a timeArc<Mutex<T>>
Many readers, few writersArc<RwLock<T>>
Single integer counterAtomicUsize (static or in Arc)
Per-thread independent statethread_local!
Async + shared mutationArc<tokio::sync::Mutex<T>> (do not hold std::sync::Mutex across .await)
Channel between threadsstd::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/Sync error is wrong, you're almost always missing an invariant the compiler is right about.

Interview Follow-ups

  • "What is 'static doing in spawn's bound?" — Threads outlive any caller's stack frame. The closure must not borrow short-lived data.
  • "Why is Rc faster than Arc?" — Non-atomic refcount ops. Single-threaded contexts pay no atomic cost.
  • "How do channels avoid the Send requirement?" — They don't. Sending a value over a channel requires T: Send. Channels are a mechanism; Send is 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

ChannelSendersReceiversCapacitySemantics
mpscmanyonebounded or unboundedFIFO queue, latest-receiver wins
spsconeoneboundedFaster, single-producer
oneshotoneone1Send once, receive once
broadcastmanymanybounded ringEach receiver gets every message
watchonemany1 (latest only)New subscribers see latest
bounded (crossbeam)manymanyboundedmpmc

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 backpressuresend() 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

ChannelSender dropsReceiver drops
mpscrecv() returns None when all senders gonesend() returns SendError
oneshotSender drops → receiver gets RecvErrorReceiver drops → sender's send() returns Err(value)
broadcastAll senders gone → recv returns ClosedSender's send succeeds (no listeners)
watchSender drops → changed() returns ErrSender's send returns Err

This is how channels signal cancellation: just drop one end.

Common Mistakes

MistakeFix
Unbounded mpsc "for safety"Memory grows unboundedly under load
broadcast subscribers created lazily after sendsNew 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 NoneDrop the sender when done so recv() returns None
watch for streaming eventsIt'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 mpsc only single-consumer?" — Multi-consumer would need synchronization on every receive. crossbeam_channel is 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 .await instead of blocking the thread.
  • "What's Notify?" — Tokio's signal primitive — like a Condvar for 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:

  1. A multi-threaded executor (work-stealing thread pool of ~N=CPU workers) that polls spawned futures.
  2. A reactor (mio + epoll/kqueue/IOCP) that wakes futures when sockets become ready.
  3. Async I/O types: TcpStream, File, tokio::sync::Mutex, channels, timers.
  4. 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());
}
  • .await on 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 (Drop impl) for cleanup, not "rescue blocks."
  • Be aware that holding a lock across an .await and 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::Mutextokio::sync::MutexUse tokio's if held across .await
std::sync::mpsctokio::sync::mpsctokio is async-aware
std::sync::RwLocktokio::sync::RwLockSame rule
-tokio::sync::oneshotSingle-value send (e.g., reply channels)
-tokio::sync::NotifyLike a condvar for async
-tokio::sync::SemaphoreBound concurrency

Common Mistakes

MistakeFix
for url in urls { fetch(url).await; } when you wanted concurrencyjoin_all/buffer_unordered or spawn tasks
Holding std::sync::Mutex guard across .awaitUse tokio::sync::Mutex or scope the lock
Heavy CPU work in async fnspawn_blocking
Reading bytes_read from sync Read::readUse AsyncRead::read
Using tokio::spawn for fire-and-forget without .await join handleErrors 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 fn and returning impl Future?" — Functionally equivalent. The first is sugar; the second lets you express things like + Send bounds without async-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

StylePer-call costdyn-friendlyWhen
Native async fn in traitZeroLimitedGeneric-bounded code
async_trait macroBox alloc + virtual callYesdyn traits, plugin systems
Manual fn -> impl FutureZeroManual boxing neededLibrary APIs
trait-variant generatedZeroYes (Send variant)Public APIs needing both

Common Mistakes

MistakeFix
Forgetting + Send on returned future when calling tokio::spawnUse trait-variant::make(Send) or explicit impl Future + Send
Mixing async_trait and native — not the same signaturePick one per trait
Holding non-Send across .await in a method that needs SendSame rule as any async — refactor
Using async_trait for hot-path codeBox allocation per call; use native trait

[!NOTE] For new code: native async traits everywhere, async_trait only when you genuinely need dyn. For libraries with a public trait, trait-variant is worth the setup — gives consumers both Send and non-Send flavors.

Interview Follow-ups

  • "Why is Tower's Service trait not async fn?" — Predates native async traits. Service::call returns Self::Future explicitly; gives Tower flexibility for hand-rolled state machines and zero-alloc futures.
  • "Can you have async in a dyn-compatible trait yet?" — Limited; you can declare it but dyn Handler itself has restrictions. Use BoxFuture return types for dyn-friendly traits today.
  • "What about RPITIT (return position impl trait in trait)?" — That's exactly what native async fn in trait builds 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

MethodYieldsBorrowing
iter()&Tshared borrow
iter_mut()&mut Texclusive borrow
into_iter()Ttakes 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:

SpecifierMatches
expran expression
identan identifier
tya type
pata pattern
stmta statement
blocka { ... } block
ttany single token tree
patha path
literala 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:

  1. Function-like: my_macro!(...).
  2. Derive: #[derive(MyTrait)] on a struct/enum.
  3. 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

Aspectmacro_rules!proc macros
Build costNoneExtra crate, longer build
PowerPattern matching onlyFull Rust at compile time
Debuggabilitycargo expandcargo expand + log output
HygieneMostly hygienicManual; Span matters
IDE supportGoodImproving

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-macro Span carefully.
  • 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

CombinatorBehavior
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

CombinatorBehavior
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 with and_then.
  • if let Some(x) = opt { x } else { return default; } — use unwrap_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. Memorize map, 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 build builds everything.
  • One Cargo.lock = guaranteed identical dep versions across crates.
  • Shared target/ = no duplicated compiles.
  • cargo test --workspace runs 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

CommandPurpose
cargo checkType-check without codegen — fast feedback
cargo clippy -- -D warningsLinter, deny warnings
cargo fmtrustfmt
cargo test --workspace --all-featuresTest everything
cargo nextest runFaster test runner (third-party)
cargo deny checkAudit licenses, advisories, banned crates
cargo udepsFind unused dependencies
cargo bloat --releaseWhat's making the binary big
cargo macheteEven 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

MistakeFix
Forgetting resolver = "2" in workspaceUse it — v1 has the dev-dep feature leak
Committing Cargo.lock in a libraryDon't; downstream needs version flexibility
Renaming features without --features plumbingAll downstream consumers break silently
Heavy crate enabled via default featuresMake heavy parts opt-in
Duplicate versions of a cratecargo 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 + bin crate — keeps test compilation fast and forces you to design library boundaries.

Interview Follow-ups

  • "What's the difference between rustup and cargo?"rustup manages toolchains (compiler versions, components, targets). cargo is 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 thiserror to define specific, typed errors consumers can match on.
  • Applications use anyhow to 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:

  • Display for each variant from the #[error("...")] format string.
  • From<sqlx::Error> for ApiError via #[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

SituationUse
Public library, callers might match on errorsthiserror
Internal app code, errors only flow up to logs/handlersanyhow
Binary's mainanyhow::Result<()>
Web handler returning HTTP statusTyped error → mapped to status
Quick scripts, prototypesanyhow (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.
  • context extension 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

MistakeFix
unwrap() in production codeUse ? everywhere; propagate to main
String errors (Err("bad".into()))Anonymous strings lose structure — wrap in a typed error
Box<dyn Error> in a public libCallers can't match on it; use thiserror enum
Manually implementing Display and ErrorUse thiserror — it's literally fewer lines
Multiple wraps without contextAdd .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 let main print the chain.

Interview Follow-ups

  • "How do you propagate errors across async tasks?" — Same as sync, plus JoinError from the runtime. Either map it inside the task or surface via JoinSet::join_next.
  • "What's eyre / color-eyre?"eyre is a fork of anyhow with pluggable reporters; color-eyre formats nicely for terminals. Same shape.
  • "How does ? work?" — Calls Try::branch (stable: From on Err variant). Lets you propagate as long as the error types implement From.

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 for cargo 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 .rs file under tests/ 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_run or ```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

MistakeFix
Big setup in every testUse 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 asyncCompiler error: "async fn outside trait" / unused future
Doctests that compile but don't runThey run by default; use no_run only when needed
Slow tests in unit layerMove 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 unit and integration tests 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] inside mod tests.
  • "How do you parallelize tests safely with shared resources?" — Use serial_test crate, or design tests to share via OnceLock + locks.