Rust Interview Mastery · Chapter 20 20

Advanced Features

Unsafe Rust, advanced traits, advanced types, fn pointers, and macros — the compiler internals layer.

17 questions 4 topics Expert

Unsafe Rust

What are unsafe Rust's five superpowers?

Inside unsafe {} (or an unsafe fn), you may:

  1. Dereference raw pointers — const T, mut T
  2. Call unsafe functions/methods — including extern FFI
  3. Access or modify mutable statics — static mut
  4. Implement unsafe traits — like Send/Sync manually
  5. 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.