The Interview Book · Careers
23Rust
The code looks harmless: take a reference to the best bid in a vector of price levels, push a new level, then print the reference. The compiler refuses it with error E0502, and the candidate is asked two questions: what bug in the equivalent C++ the refusal just prevented, and how to write the function so that it compiles without cloning the book. Rust interviews at trading and crypto firms test whether the candidate thinks in the language’s rules (ownership, borrowing, lifetimes, the traits that mark what may cross threads) rather than fighting them, and whether the candidate knows where the guarantees stop: at unsafe and at logic errors. The performance side is One Quant Book 13, chapter 9; every snippet here is compiled by the chapter’s tests with the pinned toolchain and its error code or output asserted.
23.1 Ownership and borrowing, and the errors that teach them
Every value has one owner; assignment of a non-Copy value moves it and the old name can no longer be used (E0382). A value may be borrowed either by any number of shared references or by exactly one mutable reference at a time (E0499, E0502), and no reference may outlive what it refers to (E0515, E0597). The rules are the static version of the defects C++ finds with sanitizers (Chapter 22): a shared reference into a vector that is then pushed to is exactly the dangling reference of a reallocated buffer.
fn main() {
let mut levels: Vec<i64> = vec![101, 100];
let best = &levels[0];
levels.push(99);
println!("{best}");
}
Method 23.1 (Fixing a borrow error without cloning)
- Shorten the borrow: copy the value you need out of the reference before mutating (a
Copytype costs nothing). - Split the borrow:
split_at_mut, destructuring a struct into its fields, or iterating withiter_mut. - Use indices instead of references when the collection must change underneath.
- Use the entry API for “look up, then insert or update” on maps.
- Restructure ownership (move the data into the function that mutates it) before reaching for
RefCell.
/// Update two different elements of one slice mutably at once by splitting the borrow.
pub fn move_qty(qty: &mut [i64], from: usize, to: usize, amount: i64) {
assert!(from != to, "from and to must differ");
let (lo, hi) = qty.split_at_mut(from.max(to));
let (a, b) = if from < to {
(&mut lo[from], &mut hi[0])
} else {
(&mut hi[0], &mut lo[to])
};
*a -= amount;
*b += amount;
}
23.2 Lifetimes in signatures and structs
A lifetime parameter names the region a reference is valid for, and the compiler checks that callers respect it. When a function takes two references and returns one, the compiler cannot guess which input the output borrows from (E0106), and the signature must say. A struct that holds a reference carries a lifetime too, which is how a zero-copy parser returns fields that borrow from the input buffer instead of allocating.
/// A zero-copy parser of '|'-separated fields that borrow from the buffer.
pub struct Fields<'a> {
buf: &'a [u8],
pos: usize,
}
impl<'a> Fields<'a> {
pub fn new(buf: &'a [u8]) -> Self {
Fields { buf, pos: 0 }
}
}
impl<'a> Iterator for Fields<'a> {
type Item = &'a [u8];
fn next(&mut self) -> Option<&'a [u8]> {
if self.pos > self.buf.len() {
return None;
}
let rest = &self.buf[self.pos..];
let end = rest.iter().position(|&c| c == b'|').unwrap_or(rest.len());
self.pos += end + 1;
Some(&rest[..end])
}
}
’a. code/interviews/23-rust/rust/src/lib.rs23.3 Traits, generics and dynamic dispatch
A generic function bounded by a trait is monomorphised: one copy per concrete type, calls resolved and inlined at compile time. A trait object &dyn Trait dispatches through a vtable: one copy of the code, an indirect call, and a restriction that the trait be object-safe (no generic methods, no Self by value in signatures). Drop order is part of the language: local variables are dropped in reverse order of declaration and struct fields in declaration order, the opposite of C++ members (Interview question 23.7).
23.4 Unsafe, Send and Sync: what the guarantees are and where they stop
Safe Rust guarantees memory safety and the absence of data races (One Quant Book 13, chapter 9). Send marks a type whose values may be moved to another thread, Sync one whose shared references may be; the compiler derives them, and a type such as Rc (a non-atomic reference count) is neither, so sending it to a thread is rejected (E0277). unsafe blocks let the programmer assert what the compiler cannot check (an index in bounds, a pointer valid); a sound abstraction keeps every such assertion justified by checks the safe interface enforces. The guarantees stop at logic: deadlocks, wrong memory orderings in lock-free code that is otherwise safe, and integer overflow in release builds (which wraps) remain the programmer’s.
/// Sum of the first n prices without per-element bounds checks: n is checked once.
pub fn sum_first(prices: &[i64], n: usize) -> i64 {
assert!(n <= prices.len());
let mut s = 0;
for i in 0..n {
// SAFETY: i < n <= prices.len(), checked by the assert above.
s += unsafe { *prices.get_unchecked(i) };
}
s
}
23.5 Question bank
Interview question 23.1 ★ developer • crypto firm
Why does Listing 23.1 not compile, what C++ bug does the refusal prevent, and how do you fix it without cloning the vector?
Solution
Solution of Interview question 23.1.
best borrows the vector immutably and is used after push, which needs a mutable borrow: E0502. In C++ the push could reallocate and leave best dangling (Interview question 22.9). Fix by ending the borrow before mutating: copy the i64 out (let best = levels[0];), or read it after the push by index.
/// Push a new level and return the old best: copy the value out, then mutate.
pub fn push_level(levels: &mut Vec<i64>, new_level: i64) -> Option<i64> {
let best = levels.first().copied();
levels.push(new_level);
best
}
What the interviewer is looking for: the aliasing rule, the C++ bug it prevents, and a zero-cost fix.
fn main() {
let fills = vec![1, 2, 3];
let archived = fills;
println!("{} {}", fills.len(), archived.len());
}
Interview question 23.2 ★ developer • proprietary firm
Why does Listing 23.5 not compile? Give two fixes and say what each costs.
Solution
Solution of Interview question 23.2.
let archived = fills; moves the vector; fills is no longer valid (E0382). Fixes: borrow instead (let archived = &fills;), which costs nothing but ties archived’s life to fills; or clone (fills.clone()), which copies the heap buffer. Use fills.len() before the move if only the length is needed.
What the interviewer is looking for: move semantics, and choosing between borrowing and cloning by cost.
fn main() {
let px = 100;
let px = px * 2;
{
let px = px + 1;
println!("{px}");
}
println!("{px}");
}
Interview question 23.3 ★ developer • any
What does Listing 23.6 print? Is px mutable?
Solution
Solution of Interview question 23.3.
201 then 200. Each let creates a new binding that shadows the previous one; the inner block’s shadow ends with the block. No binding is mutable: shadowing is not mutation, and the values are not overwritten.
What the interviewer is looking for: shadowing versus mutability and scope.
fn main() {
let mut qty = vec![10, 20];
let a = &mut qty[0];
let b = &mut qty[1];
*a += *b;
}
Interview question 23.4 ★ developer • market maker
Why is Listing 23.7 rejected although the two elements differ, and how do you write it legally?
Solution
Solution of Interview question 23.4.
The borrow checker reasons about the vector as a whole: two &mut into it at once are two mutable borrows of the same value (E0499), even for different indices. Split the borrow with split_at_mut, which returns two disjoint mutable slices (Listing 23.2), or use indices and do the update in two statements.
What the interviewer is looking for: why disjointness is not visible to the checker, and split_at_mut.
fn best(a: &str, b: &str) -> &str {
if a > b { a } else { b }
}
fn main() {
println!("{}", best("x", "y"));
}
Interview question 23.5 ★★ developer • crypto firm
Fix Listing 23.8. What does the lifetime you add promise the caller?
Solution
Solution of Interview question 23.5.
fn best<’a>(a: &’a str, b: &’a str) -> &’a str. The lifetime promises that the result is valid as long as both inputs are, so the caller may not use it after either input is gone; the compiler needs it because the result may borrow from either (E0106).
What the interviewer is looking for: the lifetime annotation and its meaning for callers.
fn best_level(levels: &[i64]) -> &i64 {
let top = levels[0] + 1;
&top
}
fn main() {
println!("{}", best_level(&[100]));
}
Interview question 23.6 ★★ developer • proprietary firm
Why is Listing 23.9 rejected, and what should the function return instead?
Solution
Solution of Interview question 23.6.
top is a local that is dropped when the function returns, so a reference to it would dangle: E0515. Return the value, -> i64, which for an integer costs nothing; for a large value return it by value (moved) or a reference into the input.
What the interviewer is looking for: the lifetime of locals and returning by value.
struct Noisy(&'static str);
impl Drop for Noisy {
fn drop(&mut self) {
println!("drop {}", self.0);
}
}
struct Pair {
_first: Noisy,
_second: Noisy,
}
fn main() {
let _a = Noisy("a");
let _p = Pair { _first: Noisy("first"), _second: Noisy("second") };
let _b = Noisy("b");
let _ = Noisy("ignored");
println!("end of main");
}
Interview question 23.7 ★★ developer • market maker
What does Listing 23.10 print? How does the order of the struct’s fields compare with C++?
Solution
Solution of Interview question 23.7.
drop ignored, end of main, drop b, drop first, drop second, drop a. let _ = binds nothing, so the value is dropped at once; locals are dropped in reverse declaration order (_b, _p, _a); a struct’s fields are dropped in declaration order (first then second), whereas C++ destroys members in reverse declaration order (Interview question 22.2).
What the interviewer is looking for: the three drop rules, including the let _ trap and the contrast with C++.
Interview question 23.8 ★★ developer • crypto firm
Write an iterator over the |-separated fields of a message in a byte buffer that allocates nothing. What lifetime does each field have?
Solution
Solution of Interview question 23.8.
The iterator holds &’a [u8] and a position and returns &’a [u8] slices of the buffer (Listing 23.3): each field lives as long as the buffer, not as long as the iterator, so fields can be kept after the iterator is gone. The crate’s tests check the fields of a sample message and empty fields.
What the interviewer is looking for: a struct with a lifetime and returned slices tied to the buffer.
Interview question 23.9 ★★ developer, mle • any
Count total volume per symbol from a slice of (symbol, volume) pairs with one map lookup per trade.
Solution
Solution of Interview question 23.9.
*map.entry(sym).or_insert(0) += v;: the entry API finds or creates the slot once and returns a mutable reference to it, where a get followed by an insert would hash twice. The crate’s volume_by_symbol does this.
What the interviewer is looking for: the entry API and why it avoids a second lookup.
Interview question 23.10 ★★★ developer • proprietary firm
A strategy interface is called once per market-data message. Compare a generic function bounded by the trait with a &dyn trait object: code size, speed, flexibility. What makes a trait unusable as a trait object?
Solution
Solution of Interview question 23.10.
Generic: one copy per strategy type, calls resolved at compile time and inlinable, larger code, the strategy fixed at compile time. Trait object: one copy, an indirect call through a vtable per message (a few nanoseconds and no inlining), strategies chosen at run time from configuration. On a hot path with few strategy types, generics; for a plug-in architecture, dyn. A trait is not object-safe if a method is generic, returns Self, or takes self by value without a Sized bound. The crate checks that both dispatches give the same result.
What the interviewer is looking for: monomorphisation against vtables, the trade-off, and object safety.
use std::rc::Rc;
use std::thread;
fn main() {
let book = Rc::new(vec![100, 101]);
let h = thread::spawn(move || book.len());
println!("{}", h.join().unwrap());
}
Interview question 23.11 ★★★ developer • crypto firm
Why is Listing 23.11 rejected? Fix it, and explain the difference between Send and Sync.
Solution
Solution of Interview question 23.11.
Rc counts references non-atomically, so two threads cloning or dropping it could corrupt the count: it is not Send, and thread::spawn requires its closure to be Send (E0277). Use Arc, whose count is atomic. Send: a value may be moved to another thread. Sync: a shared reference may be used from several threads at once (a type is Sync if &T is Send); Cell is Send but not Sync, Mutex<T> makes a Send T usable as Sync.
What the interviewer is looking for: atomic against non-atomic counting, and the precise definitions of the two traits.
Interview question 23.12 ★★★ developer • market maker
Justify the unsafe block of Listing 23.4. What would make it unsound, and how would you check it mechanically?
Solution
Solution of Interview question 23.12.
The assertion that does not exceed the slice’s length, checked once before the loop, guarantees that every index is in bounds, so the unchecked read never goes out of bounds; the safety comment states the invariant. It would be unsound if the check were removed, weakened to a debug_assert! (absent in release builds), or placed after the loop, or if the slice could shrink during the loop (it cannot, being borrowed immutably). Check mechanically with Miri, which interprets the code and detects out-of-bounds reads, and with a test that the function panics on a bad n (the crate has one). Often the compiler removes the bounds checks of the safe version anyway (One Quant Book 13, chapter 9).
What the interviewer is looking for: the invariant that makes unsafe sound, where it would break, and Miri.
Interview question 23.13 ★★★ developer • market maker
Several threads increment a shared counter. Write it without a lock. Which memory ordering do you use, and when would a weaker or stronger one be wrong?
Solution
Solution of Interview question 23.13.
/// A counter shared by threads: fetch_add loses no increment; Relaxed suffices for a count.
pub fn count_in_threads(threads: usize, per_thread: u64) -> u64 {
let c = Arc::new(AtomicU64::new(0));
let handles: Vec<_> = (0..threads)
.map(|_| {
let c = Arc::clone(&c);
thread::spawn(move || {
for _ in 0..per_thread {
c.fetch_add(1, Ordering::Relaxed);
}
})
})
.collect();
for h in handles {
h.join().expect("thread panicked");
}
c.load(Ordering::Relaxed)
}
fetch_add. code/interviews/23-rust/rust/src/lib.rsfetch_add is a single atomic read-modify-write, so no increment is lost whatever the ordering; Relaxed is enough for a pure count read after the threads are joined (joining synchronises). A stronger ordering is needed when the counter publishes other data: a producer that writes a message and then increments a sequence number needs Release on the increment and the consumer Acquire on the read (One Quant Book 13, chapter 11). The crate’s test counts 400 000 increments from four threads exactly.
What the interviewer is looking for: atomic read-modify-write, and when ordering matters beyond the counter itself.
Sources and further reading
- The Rust Reference and the Rustonomicon (for the toolchain pinned by the series, 1.97.1), and the rustc error index (E0106, E0277, E0382, E0499, E0502, E0515).
- One Quant Book 13, chapters 9 and 11 (Rust for low latency; lock-free programming).