Answer from a language model, not from memorized syntax: name the mechanism, show a small example, and explain the production consequence.
Question set
10 detailed answers
01When do you use Option<T> vs Result<T, E>?
junior
Short answer: Option<T> — when a value may simply be absent and the reason doesn't matter (Some/None). Result<T, E> — when an operation can fail and the caller needs the cause (Ok/Err).
In depth:
- Option — models "the value may not be there":
HashMap::getmissed the key,Vec::firston an empty vector.Nonecarries no reason. - Result — models "the operation failed":
File::opendidn't find the file,str::parsecouldn't parse.Err(e)explains what went wrong. - Conversion — they cross over easily:
option.ok_or(err)turnsNoneintoErr, andresult.ok()drops the error and yields anOption.
| Type | Meaning | Typical API |
|---|---|---|
| Option |
value may be absent | HashMap::get, Vec::first |
| Result<T, E> | operation may fail | File::open, str::parse |
⚠️ Common mistake: returning Result where absence is normal (or Option where the caller needs the cause). "Key not found" is an Option, not an error.
02How does the ? operator work, and what does the From trait have to do with it?
junior
Short answer: ? is an early return: on Ok(v) it unwraps the value, on Err(e) it does return Err(From::from(e)). That implicit From conversion is exactly what lets ? "just work" across layers with different error types.
In depth:
- Desugaring —
let v = expr?;expands into amatchwhoseErrarm returns from the function immediately. - From::from — it converts the error from the inner call into the current function's error type. If an
impl From<SubError>exists, the conversion happens automatically. - Why it's handy — a custom error enum with a set of
Fromimpls makes?seamless across every layer. Those impls are exactly what#[derive(thiserror::Error)]generates via#[from].
// expr? expands roughly into this:
match expr {
Ok(v) => v,
Err(e) => return Err(From::from(e)),
}
⚠️ Common mistake: thinking ? is an exception and stack unwinding. It's a plain return: no throw/catch, just an early exit carrying an Err.
03When is panic! appropriate vs returning Result? What happens on panic — unwind or abort?
middle
Short answer: panic! is for broken invariants and bugs you can't meaningfully handle; Result is for expected errors the caller handles. By default a panic unwinds the stack, running Drop on every value; panic = "abort" in Cargo.toml instead kills the process immediately.
In depth:
- panic! — "this should never happen": out-of-bounds index,
unwrap()onNone, a violated invariant. No recovery is intended. - Result — "this can go wrong, and that's fine": file not found, input didn't parse. The caller decides what to do.
- unwind vs abort — the default
unwindunrolls the stack and runs destructors (catchable viacatch_unwind).abortgives a smaller binary and is needed forno_std/embedded, but cleans up nothing.
| panic! | Result | |
|---|---|---|
| When | bug, broken invariant | expected failure |
| Recovery | none | yes, by the caller |
| Example | arr[99], unwrap |
File::open, parse |
⚠️ Common mistake: unwinding a panic across an FFI boundary (extern "C"). It used to be UB; since Rust 1.81 the process simply aborts. To propagate a panic you need extern "C-unwind".
04How does String differ from &str, and which should a function accept?
junior
Short answer: String is an owned, growable UTF-8 buffer on the heap (ptr + len + cap, 24 bytes on a 64-bit platform). &str is a borrowed immutable view (a fat pointer ptr + len, 16 bytes) into a String's buffer, a string literal, or any valid UTF-8 range. In signatures prefer &str — it accepts both via deref coercion.
In depth:
- String — owns its data, can grow (
push_str), lives on the heap. Aptr/len/captriple. - &str — only reads someone else's bytes, owns nothing. A fat pointer
ptr/len, nocap. - Arguments — take
&str:&Stringcoerces to&strautomatically, so the function works with both literals and owned strings. ReturnStringwhen you need to hand over ownership.
String "hi" &str to literal "hi"
┌──────┬─────┬─────┐ ┌──────┬─────┐
│ ptr │len=2│cap=8│ │ ptr │len=2│ (16 bytes, no cap)
└──┬───┴─────┴─────┘ └──┬───┴─────┘
▼ heap ▼ .rodata (in the binary)
[h][i][ ][ ]... [h][i]
⚠️ Common mistake: thinking &str always borrows a String. String literals ("...") live in the binary's static memory ('static), not on the heap.
05How is Vec<T> laid out internally, and what happens on push when len == capacity?
middle
Short answer: Vec<T> is a (ptr, len, cap) triple: a pointer to a heap buffer, the number of used elements, and the capacity. When push hits len == cap, the vector allocates a new buffer (usually twice as large), moves every element over, and frees the old one — old pointers and slices are invalidated.
In depth:
- Layout — 24 bytes on the stack (on a 64-bit platform):
ptrto the heap,len,cap. The elements themselves live on the heap. - Growth — on overflow of
cap, a reallocation with amortized doubling:O(1)average perpush, even though an individualpushmay copy the whole buffer. - Invalidation — after a reallocation the old address is dead. That's exactly why the borrow checker forbids holding
&vec[0]across apush: the reference could point into freed memory.
push when len == cap = 4:
before: ptr ─► [a][b][c][d] (cap=4, len=4)
╲ realloc + move
after: ptr ─► [a][b][c][d][e][_][_][_] (cap=8, len=5)
old buffer [a..d] freed ✗
⚠️ Common mistake: holding a reference/slice into the vector and calling push — the compiler rejects it not out of spite but because the reallocation would leave the reference dangling. Reserve up front with Vec::with_capacity.
06How do macro_rules! and procedural macros differ, and when do you pick each?
middle
Short answer: macro_rules! are declarative macros: pattern matching over token trees, hygienic by default, great for repetitive patterns and small DSLs. Procedural macros are functions that run arbitrary Rust code over a TokenStream at compile time; they're required for derive, attribute, and function-like macros with real parsing.
In depth:
- macro_rules! — you write a set of
(pattern) => (expansion)rules. Hygienic by default: identifiers the macro introduces don't clash with the call site's code. - Procedural — a separate crate (
proc-macro = true); they receive aTokenStream, parse it (usually viasyn) and generate a new one (quote). Three kinds:#[derive(...)], attribute#[route(...)], function-likesql!(...). - Choosing — plain code duplication →
macro_rules!; you need to parse a type's structure and generate code (likeserde) → proc-macro.
| macro_rules! | proc-macro | |
|---|---|---|
| Model | token matching | arbitrary code over TokenStream |
| Hygiene | by default | manual (Span) |
| Lives | inline | separate crate |
| For | DSLs, templates | derive, attributes, parsing |
⚠️ Common mistake: reaching for a proc-macro where macro_rules! would do. Mentioning hygiene in an interview is a strong-candidate signal.
07Rust has no reflection — so how does serde serialize structs?
middle
Short answer: #[derive(Serialize)] is a procedural macro: at compile time it generates a per-type impl Serialize that calls the methods of a format-specific Serializer (from serde_json, bincode, etc.). It's all static — no runtime introspection.
In depth:
- Codegen —
deriveinspects the struct's fields at compile time and writes theserializebody for you: one call per field. - data/format split —
serdedefines the data model and theSerialize/Serializertraits; a concrete format (JSON, YAML, MessagePack) is a separateSerializerimplementation. Onederiveworks with every format. - Zero runtime cost — no type tags, no field dictionary, no reflection. The generated code is straight-line and inlines.
#[derive(Serialize)] compile serde_json::to_string
struct User{ id, name } ─────────────► impl Serialize for User ──► Serializer
│ (proc-macro) serialize_field("id") builds JSON
└── static code, no reflection at runtime
⚠️ Common mistake: assuming serde walks fields at runtime like reflection in Java/Python. The whole traversal is baked into code at compile time.
08What are Cargo workspaces and feature flags for? What is feature unification and why is it dangerous?
middle
Short answer: A workspace is several crates sharing one Cargo.lock and target/, which speeds up builds and keeps versions consistent. Feature flags are additive, compile-time options. Feature unification: Cargo unions the enabled features of each dependency across the whole build graph — if one crate turns a feature on, it's on for every consumer of that dependency.
In depth:
- Workspace — a shared lock file and build directory; crates build together and dependency versions are unified.
- Features — conditional compilation (
#[cfg(feature = "...")]), optional dependencies. They must be additive: enabling a feature only adds, never breaks the API. - Unification — Cargo compiles each dependency once with the union of all requested features. The danger: a third-party crate can enable a feature (say
std) that breaks yourno_stdbuild, or silently pulls in extra code.
┌─ crate A ─ needs serde/derive ─┐
graph ─┤ ├─► serde builds ONCE
└─ crate B ─ needs serde/std ───┘ with features { derive, std }
(union, not intersection)
⚠️ Common mistake: assuming features are isolated per crate. Resolver v2 separates build/dev dependencies, but in the normal graph features still unify — which is why they must be kept strictly additive.
09What does #[non_exhaustive] do, and why would a library put it on a public enum?
middle
Short answer: #[non_exhaustive] warns consumer crates that the type may still grow. On an enum it forces a wildcard _ arm in every match; on a struct (or an individual variant) it forbids constructing the value with a literal. That lets the library author add new variants and fields later without breaking semver compatibility.
In depth:
- What it forbids — on an enum: outside the crate you can't match exhaustively without
_(the variants themselves can still be constructed —ErrorKind::NotFoundis valid). On a struct or an enum variant: outside the crate you can't create a value with aStruct { .. }literal, and patterns must include... - Why — adding a variant to an ordinary public enum is a breaking change: users'
matchstops compiling. With#[non_exhaustive]the_is already there, so a new variant is safe. - Where it's used — error and config enums in libraries (
std::io::ErrorKindis the classic example).
// in a library consumer the _ arm is mandatory:
match err.kind() {
ErrorKind::NotFound => ...,
ErrorKind::PermissionDenied => ...,
_ => ..., // without it the code won't compile
}
⚠️ Common mistake: expecting the attribute to apply inside the defining crate too. Every restriction targets external consumers only: your own code still matches exhaustively and constructs literals, so on internal types the attribute is dead weight.
10Why is Rust's enum called an algebraic data type? How do Option and Result fall out of that?
concept
Short answer: an enum is a sum type: a value is exactly one of N variants, and each variant can carry its own data. A struct, by contrast, is a product type. Exhaustive match makes illegal states unrepresentable. Option and Result are ordinary two-variant enums in the standard library.
In depth:
- Sum vs product —
struct { a, b }holdsaANDb(a product of value sets); anenumholds one of its variants (a sum). Hence the term "algebraic". - Illegal states unrepresentable — the compiler forces you to handle every variant in
match; a forgotten case is a compile error, not a runtime bug. - Option and Result — not language magic, but two two-variant enums: this is how Rust buries
null(ordinary types have no "empty" state) and exceptions (an error is a value) with a single mechanism.
enum Option<T> { Some(T), None } // either a value or nothing
enum Result<T, E> { Ok(T), Err(E) } // either success or an error
⚠️ Common mistake: treating Option/Result as built-in special constructs. They're ordinary enums — you could declare the same yourself; only ? and syntactic sugar make them "special".
Source notes
References and review policy
RecallDeck’s interview answers are editorial material, reviewed against maintained official documentation where a primary reference is available. Tool selections use direct provider links and contain no affiliate placements. Features can change after the review date.
From reading to recall
Practice the full interview loop.
RecallDeck schedules the concepts you miss and keeps coding, design, and behavioral fundamentals available when the interviewer changes direction.