Advanced Features
Unsafe Rust, advanced traits, advanced types, fn pointers, and macros — the compiler internals layer.
Part of Rust Interview Mastery · Based on The Rust Programming Language
Unsafe Rust
What are unsafe Rust's five superpowers?
Inside unsafe {} (or an unsafe fn), you may:
- Dereference raw pointers —
const T,mut T - Call unsafe functions/methods — including
externFFI - Access or modify mutable statics —
static mut - Implement unsafe traits — like
Send/Syncmanually - Access union fields — C-interop holdover
Crucially: the borrow checker still runs inside unsafe. Unsafe doesn't disable safety — it moves some proofs from the compiler to you.
How are raw pointers different from references — and what survives as a guarantee?
let mut n = 5;
let r1 = &n as *const i32;
let r2 = &mut n as *mut i32;
unsafe { println!("{}", *r1); } // deref requires unsafe
Raw pointers const T/mut T can be null, unaligned, aliased freely, outlive their referent — all borrow rules suspended. Creating them is safe; dereferencing is the unsafe act. What survives: immutability of *const is still a lint-level expectation, types still exist, and the compiler still type-checks — but aliasing+mutation rules are yours to honor (the optimizer still assumes them via provenance/noalias reasoning).
Why does the unsafe block exist as a keyword instead of just permitting dangerous code?
Because unsafe is an audit boundary, not a permission slip. The contract: inside the block, the programmer manually proves the invariants the compiler usually proves — then exposes a safe API outward.
Good unsafe is small and wrapped: Vec::get_unchecked style internals with a safe exterior that checks bounds once. Bad unsafe leaks into public API signatures forcing callers to reason unsafely. In code review you grep unsafe blocks — they're the blast radius of memory bugs. That's the design goal: concentrate the unsafety so 95% of the codebase stays provably safe.
When is unsafe legitimately necessary? Name the real cases.
- FFI — calling C (extern "C" functions are all unsafe)
- Custom collections — Vec, Rc, HashMap internals need raw pointers for layout/perf the borrow checker can't verify
- Implementing Send/Sync for types the compiler can't auto-derive
- OS/hardware-level code — syscalls, memory-mapped registers, embedded
- Proven-correct-but-unprovable invariants — get_unchecked after a check the borrow checker can't see
Rust's std is itself a large curated unsafe usage. The standard answer: reach for it at boundaries — where Rust's model can't express something the machine can do safely.
Why is static mut unsafe when a Mutex<T> static is fine?
static mut X: i32 is a shared mutable global — any thread could read while another writes, an instant data race; the compiler can't prove coordination, so access is unsafe. static X: Mutex<i32> moves the coordination into the type — locking is checked at runtime, so reads/writes stay safe.
Same story for AtomicI32 statics — safe because the type provides ordering guarantees. The pattern: globals are fine when the type itself owns the synchronization; static mut provides none.
Advanced Traits
Associated types vs generic parameters on a trait — when do you pick which?
trait Iterator { type Item; fn next(&mut self) -> Option<Self::Item>; } // associated
trait Add<RHS> { type Output; fn add(self, rhs: RHS) -> Self::Output; } // generic
Associated type = one choice per implementation: an Iterator yields one Item type. Generic param = many impls allowed: Point can impl Add<Point> AND impl Add<Meters>.
Heuristic: if logically there's one sensible type per impl (container→item, graph→edge), use associated — signatures stay clean. If callers should pick among impls (like Add<Rhs> overloading), use a generic parameter. Iterator<Item=T> bounds read fn f(i: impl Iterator<Item = u8>).
How does operator overloading work — the Add example?
Operators are trait methods: a + b desugars to a.add(b) where Add is in std::ops:
use std::ops::Add;
#[derive(Debug)] struct Point { x: i32, y: i32 }
impl Add for Point {
type Output = Point;
fn add(self, rhs: Point) -> Point {
Point { x: self.x + rhs.x, y: self.y + rhs.y }
}
}
Add<Rhs = Self, Output = ...> is generic — you can impl Add<Meters> for Millimeters mixing types. Same pattern for - * / == < [] etc. Restriction: coherence — you can't overload + between two foreign types.
What is fully qualified syntax, and when is it mandatory?
<Dog as Animal>::baby_name() // "Spot" — the Animal impl Dog::baby_name() // "puppy" — Dog's inherent method
When a type has an inherent method AND trait methods with the same name, method call syntax resolves to the inherent one; trait impls need disambiguation <Type as Trait>::method().
It's mandatory for trait methods that don't take self (associated functions): x.baby_name() has no self to infer the trait from — so <Dog as Animal>::baby_name() is the only spelling. This shows up in real code disambiguating ToString/Display collisions and in macro-generated code that must be explicit.
What are supertraits — how does trait composition work?
use std::fmt::Display;
trait OutlinePrint: Display { // requires Display to impl
fn outline_print(&self) {
println!("* {} *", self.to_string()); // Display's method available
}
}
trait A: B means "to implement A you must also implement B" — the compiler can then use B's methods inside A's default bodies. OutlinePrint: Display lets outline_print call to_string blindly.
Chain them: trait C: A + B. It's the trait system's inheritance-shaped tool — but it's capability requirement, not class inheritance: no fields, no method resolution order drama.
The newtype pattern — what two distinct jobs does it do?
Job 1 — orphan rule workaround: to impl Display for Vec<String> (both foreign) you'd violate coherence; struct Wrapper(Vec<String>) is local, so impl Display for Wrapper is legal.
Job 2 — type safety via nominal typing: struct Meters(u32); struct Seconds(u32); — a fn taking Meters rejects bare u32 and Seconds at compile time even though they're layout-identical. Zero runtime cost (newtype erases to the inner type) — you get domain types for free. Used everywhere for units, validated strings (NonEmptyString), IDs (UserId(u64)).
Advanced Types
Type alias vs newtype — what's the crucial difference?
type Meters = u32; creates a synonym — Meters and u32 are the same type; a fn taking u32 accepts Meters silently. Aliases document intent and shorten monsters: type Thunk = Box<dyn Fn() + Send>.
struct Meters(u32) is a new, distinct type — the compile-time wall the previous answer covered. Rule: alias for readability, newtype for safety.
What is the never type ! and where do you see it?
! means "this computation never returns":
fn die() -> ! { panic!("dead") }
let x: i32 = match opt { Some(v) => v, None => continue }; // continue -> !
Diverging expressions — panic!, continue, loop {} without break, process::exit — evaluate to !, which coerces to any type. That's why match arms can mix return/continue/panic with real values and still type-check. Rust 2024 stabilized explicit ! in return positions. (Additional context: it matters for correctness proofs — the type system can say "unreachable."
Why does Rust need the Sized trait and ?Sized bound — the DST story?
Rust must know a value's size at compile time — stack frames, return values, struct fields all need it. str, [T], and dyn Trait are dynamically sized — size only known at runtime — so they can only live behind a pointer carrying metadata (&str = ptr+len, &dyn = ptr+vtable).
Sized is the auto-implemented trait marking known-size types; generic bounds are implicitly T: Sized. ?Sized relaxes that: fn f<T: ?Sized>(x: &T) accepts str/slices/dyn. That's why struct Foo<T: ?Sized> { x: T } must put the DST field last and users hold Box<Foo<dyn Trait>>.
Function Pointers & Macros
fn pointers vs closures — how do they relate?
fn(i32) -> i32 is a type — a plain function pointer, Copy, 'static, no captures. Closures are anonymous struct types implementing Fn*. The bridge: non-capturing closures coerce to fn pointers:
fn call(f: fn(i32) -> i32) -> i32 { f(1) }
fn twice(x: i32) -> i32 { x * 2 }
call(twice); // fn item
let c = |x| x * 2; // non-capturing closure
call(c); // coerces to fn pointer
fn is best when you want a plain callback (C FFI especially — C knows pointers, not traits); impl Fn/dyn Fn when captures are needed.
Macros vs functions — what can macros do that functions can't?
Macros run at compile time on syntax: they take token trees and emit code. That enables: variable argument counts (println! with any arity), generating impls/functions (#[derive]), embedding logic before type-checking (vec![1; n]), and things impossible to express as calls (compile-time format! checking of literal format strings).
Cost: harder to write, worse error messages, order-of-definition rules. Function for runtime behavior; macro for syntax-level or compile-time code generation.
Walk through the three kinds of procedural macros vs declarative macro_rules!.
- Declarative (macro_rules!) — pattern-match token shapes, emit code. vec![] is the flagship. Defined inline/crate-local; hygiene handled automatically.
- Custom derive (#[derive(MyTrait)]) — take the annotated item's AST, emit an impl. Serde's Serialize is the canonical example.
- Attribute-like (#[route(GET, "/")]) — rewrite any item. Axum/wasm-bindgen attributes.
- Function-like (sqlx::query!("SELECT ...")) — invoked like macros but arbitrary token input; can hit the DB at compile time.
Procedural macros get the real TokenStream and can parse/inspect it (via syn) — they're tiny compiler plugins in a separate proc-macro crate.
Why is #[derive(Debug)] so much simpler than writing a macro_rules! for it?
Because derive operates on the parsed AST of your type — fields, names, generics — while macro_rules! pattern-matches raw tokens and can't see "this is a struct with these fields" (it doesn't get the item's semantic info).
Writing a derive means a proc-macro crate that receives TokenStream, parses it into a DeriveInput (usually via syn), and emits the impl code. macro_rules! is the right tool for repetitive call-site syntax (hashmap!{k => v}); derive for type-driven code generation. They're different tools that happen to share the !/attribute surface.