本书仅提供英文版。
Interviews III: Code · 职业发展
9Reading, Debugging and Software Design
Reading a short listing and saying what it really does, then naming the bug, the tool that would prove it and the smallest fix. The first questions are the ones you are expected to spot in seconds. The later ones ask how you would hunt a fault you cannot see, how you would test a piece of trading code, and how you would shape it so the same logic runs live and in a backtest. A few stems are in Java or C#, because banks ask them there.
Question 9.1 ★ QD, Dev, LLE • any • junior
Built with g++ 11.4 on x86-64 Linux, what does Listing 9.1 print? Is anything undefined here? Give the smallest fix for the comparison.
int main() {
std::vector<int> bids{10, 20};
std::vector<int> asks{10, 20, 30};
if (bids.size() - asks.size() > 0) {
std::puts("more bids than asks");
} else {
std::puts("no more bids than asks");
}
std::printf("%zu\n", bids.size() - asks.size());
}
Answer
Answer to Question 9.1.
It prints more bids than asks, then 18446744073709551615. Nothing is undefined. The fix is to compare the sizes directly: bids.size() > asks.size().
size() returns std::size_t, an unsigned type of 64 bits here. The standard defines unsigned arithmetic modulo , so gives , which is the second line of output. An unsigned value is never negative, so > 0 is true for every pair of sizes that differ, whichever is larger. Under -Wall -Wextra -Werror the listing compiles without a word, because > 0 on an unsigned value is a sensible test in general.
If you do need the signed difference, C++20 has std::ssize. Subtracting two of those and testing > 0 also gives the right branch.
Classic wrong answer. “Overflow is undefined behaviour, so anything can happen.” That is true of signed types. Unsigned arithmetic wraps by definition, so this program is perfectly defined and perfectly wrong.
Follow-up. What does for (std::size_t i = v.size() - 1; i >= 0; --i) do? It never ends, because i >= 0 is always true: after 0 it wraps to the largest value and indexes far out of bounds. Here -Wextra does catch it, as -Wtype-limits. An empty v goes wrong even earlier, on the first iteration.
Why it matters. Quantity and level arithmetic is full of sizes. This bug passes every test where the first container happens to be the larger one.
Question 9.2 ★ QR, QD, Dev, DS • any • junior
What does Listing 9.2 print, and why? Make it print [10, 20, 30]. Would rewriting the loop as a list comprehension help?
def make_scalers():
scalers = []
for k in [1, 2, 3]:
scalers.append(lambda x: k * x)
return scalers
print([f(10) for f in make_scalers()])
Answer
Answer to Question 9.2.
It prints [30, 30, 30]. To get [10, 20, 30], bind the value when the function is created: lambda x, k=k: k * x. A list comprehension does not help.
A closure in Python captures the variable, not its value. All three lambdas refer to the same k in the scope of make_scalers, and they look it up when called. By then the loop has finished and k is 3. Default arguments are evaluated when the function is defined, which is why k=k takes a snapshot. functools.partial does the same and leaves no extra parameter that a caller could pass by accident.
Classic wrong answer. “Use a comprehension, since it has its own scope.” It does, but only one scope for the whole comprehension. [lambda x: k * x for k in [1, 2, 3]] also gives [30, 30, 30]: the test runs it.
Why it matters. Building a list of callbacks or scalers in a loop is everyday research code, and the failure is silent: every instrument gets the last instrument’s parameters.
Question 9.3 ★ QD, Dev, LLE • any • junior
Listing 9.3 is meant to drop every zero quantity. Built with g++ 11.4, -std=c++20 -O2, it prints something. What, and why? What happens if the last element is the zero? Which tool shows the fault, and what is the fix?
int main() {
std::vector<long> qty{10, 0, 0, 20};
for (auto it = qty.begin(); it != qty.end(); ++it) {
if (*it == 0) {
qty.erase(it);
}
}
for (long q : qty) std::printf("%ld ", q);
std::puts("");
}
Answer
Answer to Question 9.3.
On this compiler it prints 10 0 20, one zero survives, and the program is undefined. With a zero in last place it runs off the end of the vector. Use std::erase(qty, 0L).
erase invalidates every iterator at or after the erased element, so it may no longer be used, and ++it is already undefined. What happened in the run: the elements slid left, so it pointed at the second zero, and ++it stepped over it. That is why one zero survives.
Now make the input {10, 0}. The erase leaves it equal to end(), the ++it moves past it, and the test it != qty.end() never becomes false. AddressSanitizer reports a heap-buffer-overflow on that input. The libstdc++ debug mode, -D_GLIBCXX_DEBUG, stops even the first input, with a message about a singular iterator.
C++20’s std::erase does the job in one pass. Before C++20 you would write the erase-remove idiom. The manual loop can be repaired with it = qty.erase(it) and no increment on that branch, but each erase shifts the tail, so it costs where std::erase costs .
Classic wrong answer. “It removes all the zeros, since erase returns normally.” Reading the loop on {10, 0, 0, 20} step by step shows the skip at once. Do that aloud in the interview.
Why it matters. Removing while iterating is the classic reading question, and the follow-up about the last element is where candidates show whether they see the undefined behaviour or only the wrong output.
Question 9.4 ★ QD, Dev, LLE • any • junior
What does Listing 9.4 print? There are two faults in allowed. One is a wrong answer and one is a side effect. What is each, and how would you make the compiler catch the second?
std::map<std::string, long> limits{{"AAA", 1000}};
bool allowed(const std::string& symbol, long qty) {
return qty <= limits[symbol];
}
int main() {
std::printf("%d\n", allowed("AAA", 500));
std::printf("%d\n", allowed("BBB", 0));
std::printf("%zu\n", limits.size());
}
Answer
Answer to Question 9.4.
It prints 1, 1, 2. The wrong answer: a symbol with no limit is allowed. The side effect: asking about BBB inserts it, so the map grows from one entry to two.
std::map::operator[] is defined as try_emplace(x).first->second: if the key is absent, it inserts a value-initialised entry, here 0, and returns a reference to it. So limits["BBB"] creates a limit of 0, and passes. A risk check that says yes to an instrument it has never heard of should fail closed.
To make the compiler catch it, make the map const, or take it by const&. operator[] has no const overload, so the code stops compiling: the test does this. Then write the lookup with find:
const auto it = limits.find(symbol);
return it != limits.end() && qty <= it->second;
That prints 1, 0, 1. at is the other choice, and it throws on a missing key.
Follow-up. Why does the side effect matter if the wrong answer is fixed some other way? Because the map is written during what looks like a read. Two threads calling allowed now race, even though neither thinks it is a writer. Memory also grows with every misspelt symbol.
Why it matters. This is the shortest example of a read that writes, and such a line appears in real risk checks.
Question 9.5 ★ QD, Dev, LLE • any • junior
What does Listing 9.5 print, and why? Suppose name were pure virtual in Strategy, and the constructor called it through another member function. What then? How would you get the behaviour its author wanted?
struct Strategy {
Strategy() { std::printf("start %s\n", name()); }
virtual ~Strategy() = default;
virtual const char* name() const { return "base"; }
};
struct Momentum : Strategy {
const char* name() const override { return "momentum"; }
};
int main() {
Momentum m;
std::printf("run %s\n", m.name());
}
Answer
Answer to Question 9.5.
It prints start base, then run momentum. During Strategy’s constructor the object is a Strategy, so the call goes to Strategy::name.
The standard says that a virtual function called from a constructor resolves to the final overrider in the constructor’s own class, not in a more-derived one. The reason is sound: Momentum’s members do not exist yet, and an override might use them. Once construction ends, m.name() sees Momentum as expected.
If name is pure and the constructor calls it directly, the compiler can complain. Through a helper it cannot see the problem. That call is undefined, and at -O0 the test run aborts with pure virtual method called.
To get what the author wanted, separate construction from starting. Either have a factory that builds the object and then calls start() on it, or pass the name up through the constructor: Strategy(const char* name).
Classic wrong answer. “Virtual calls always go to the most-derived class.” Not during construction or destruction. The same holds in a destructor, where the derived part has already gone.
Why it matters. Logging or registering in a base-class constructor is a natural thing to write, and it is the usual way this bug appears: a strategy registers itself under the wrong name.
Question 9.6 ★ QD, Dev, LLE • bank • junior
What three numbers does Listing 9.6 print? Name the effect, give the usual fix for the book, and say how to make the mistake fail to compile.
struct Instrument {
virtual ~Instrument() = default;
virtual double value() const { return 0.0; }
};
struct Bond : Instrument {
double price;
explicit Bond(double p) : price(p) {}
double value() const override { return price; }
};
int main() {
std::vector<Instrument> book;
book.push_back(Bond(99.5));
Bond b(101.0);
Instrument& ref = b;
Instrument copy = b;
std::printf("%.1f %.1f %.1f\n", book[0].value(), ref.value(), copy.value());
}
Answer
Answer to Question 9.6.
It prints 0.0 101.0 0.0. This is object slicing. Store pointers in the vector, usually unique_ptr, and delete the base class’s copy constructor to make the mistake fail to compile.
A vector of Instrument holds objects of exactly that type, each with room for an Instrument only. push_back(Bond(99.5)) copies only the Instrument part, so the price is gone and the virtual table pointer is Instrument’s: book[0].value() is 0.0. copy is the same thing written out: Instrument copy = b. Only ref is still a Bond, so it gives 101.0.
With the vector of unique_ptr, the first number becomes 99.5. Add Instrument(const Instrument&) = delete;, together with a defaulted default constructor, and both slicing lines stop compiling. That is the usual rule for a class meant to be used through a base pointer: no public copying. If you do need copies, add a virtual clone.
Follow-up. Is there a way to keep the instruments side by side in memory? Yes, if the set of types is closed: std::vector<std::variant<Bond, Swap>> stores each value in place with no slicing and no extra allocation. The cost is that adding a type means changing that line.
Why it matters. A bank’s pricing code is full of instrument hierarchies, and a slice gives a plausible number with no error at all.
Question 9.7 ★ QD, Dev, LLE • crypto, market maker • junior
With rustc 1.97 on x86-64, what does Listing 9.7 do under cargo run? And under cargo run --release? Is either of them unsafe in Rust’s sense? Fix imbalance in two ways, depending on what the caller wants.
fn imbalance(bids: &[u32], asks: &[u32]) -> usize {
bids.len() - asks.len()
}
fn main() {
let bids = vec![10, 20];
let asks = vec![10, 20, 30];
println!("{}", imbalance(&bids, &asks));
}
Answer
Answer to Question 9.7.
Under cargo run it panics, and the message is attempt to subtract with overflow. With --release it prints in full. Neither is unsafe in Rust’s sense. Use abs_diff for the size of the gap, or a signed result if the sign matters.
len() returns usize, and does not fit. The Rust Reference calls arithmetic overflow a programmer’s error, but it is not undefined behaviour. With debug assertions on, the compiler must insert a check that panics. In a release build the default is to wrap, as the test confirms. The program stays memory safe either way: the wrong number comes out, or the process stops, and no invariant of the language is broken. You can keep the checks in release builds with overflow-checks = true in the Cargo profile, and the test confirms that an optimised build then panics too.
The fix depends on the caller. bids.len().abs_diff(asks.len()) gives 1 here if only the size of the gap matters. checked_sub returns None if a negative result means “this should not happen”. a as i64 - b as i64 gives if the caller wants the sign.
Classic wrong answer. “Rust catches overflow.” Only with the checks on. The same code ships a different behaviour from what you tested, so the release run in the test matters.
Why it matters. Crypto and market-making firms using Rust ask this to check that you know what safe does and does not promise.
Question 9.8 ★ QR, QT, QD, Dev, DS • any • junior
What two lines does Listing 9.8 print on CPython? A colleague stores price levels in a dict keyed by float, and fills disappear now and then. What do you tell them?
levels = {}
levels[0.1 + 0.2] = 500
print(0.3 in levels, levels.get(0.3, 0))
print(round(2.675, 2), round(0.5), round(1.5), round(2.5))
Answer
Answer to Question 9.8.
It prints False 0, then 2.67 0 2 2. Tell your colleague to key the levels by an integer number of ticks.
is 0.30000000000000004 in binary floating point, and the literal 0.3 is a different double. Dict lookup uses exact equality, so the entry exists under one key and the lookup asks for another, and the fill appears to vanish. Two prices that print the same can differ in the last bit if they came by different arithmetic.
The second line shows two more traps in round. 2.675 is stored slightly below that value, as 2.67499999…, so it rounds down, and the Python documentation says so itself. Halves go to the even neighbour: round(0.5) is 0, round(1.5) and round(2.5) are both 2. Rounding to the tick’s decimals does give one canonical double, so round(0.1 + 0.2, 2) == 0.3, but it works only while the error stays far below half a tick, and each writer must remember to do it. Integer ticks need no such care.
They solve it completely: at a tick of 0.001, the keys 100 and 200 add to exactly 300. Decimal("0.1") + Decimal("0.2") also equals Decimal("0.3"), which suits reports and money, at a cost in speed.
Classic wrong answer. “Compare with a tolerance.” You cannot look up a dict with a tolerance, and tolerance is not transitive, so you cannot build a consistent key from it either.
Why it matters. Prices as floats are the most common design fault in candidates’ order books, and many firms check for it in the first minute.
Question 9.9 ★ QD, Dev • bank • junior
On OpenJDK 11 with default settings, what does Listing 9.9 print? Which of those results does the Java language promise, and which belong to this JVM?
public class Boxes {
public static void main(String[] args) {
Integer a = 127, b = 127;
Integer c = 128, d = 128;
String s = "AAA", t = "AAA", u = new String("AAA");
System.out.println((a == b) + " " + (c == d) + " " + c.equals(d));
System.out.println((s == t) + " " + (s == u) + " " + s.equals(u));
}
}
Answer
Answer to Question 9.9.
It prints true false true and true false true. The language promises every result here except c == d: only that false belongs to this JVM.
== on references asks whether they are the same object. The Java Language Specification says that boxing an integer between and 127 twice gives the same object, so a == b is always true. Beyond that range nothing is promised either way. The specification adds that less memory-limited implementations might cache more, up to 32K for int. OpenJDK 11 with default settings gives two objects for 128, so c == d is false.
For strings, the specification says literals are interned, so s == t is true. new String("AAA") is a new object by definition, so s == u is false, while equals compares contents.
The rule is short: use equals on objects, and == only on primitives. This bug is nasty because it passes every test with small quantities.
Classic wrong answer. “== on Integer compares values.” It would, if one side were a primitive int, because the other side is then unboxed. With two Integer variables it compares references.
Why it matters. Bank systems are full of boxed values in collections. A check like qty == limit works in every small test case and breaks at 128.
Question 9.10 ★★ QD, Dev, LLE • any • junior, experienced
Listing 9.10 compiles without a warning under g++ 11.4, -std=c++20 -O2 -Wall -Wextra -Werror. What does it print? Is the program correct? What would AddressSanitizer say? Would the answer change with std::make_shared?
struct Order {
virtual long qty() const { return 0; }
~Order() { std::puts("~Order"); }
};
struct LimitOrder : Order {
std::string tag = "a tag too long for the small-string buffer";
long qty() const override { return 100; }
~LimitOrder() { std::puts("~LimitOrder"); }
};
int main() {
std::unique_ptr<Order> p = std::make_unique<LimitOrder>();
std::printf("%ld\n", p->qty());
}
Answer
Answer to Question 9.10.
It prints 100, then ~Order, and ~LimitOrder never runs. The program is undefined: deleting a LimitOrder through an Order* needs a virtual destructor. AddressSanitizer stops it. With std::make_shared<LimitOrder> it is defined, and both destructors run.
The standard says that in a single-object delete, if the static type differs from the dynamic type, the static type must have a virtual destructor, or the behaviour is undefined. qty is virtual, so the class is polymorphic, but the destructor is not. In this run, tag is never destroyed and its heap buffer is lost.
ASan reports new-delete-type-mismatch: the allocated type has 40 bytes and the deallocated one 8. With that check turned off, it reports the leak of the string instead. The test shows both.
Why was there no warning? g++ has -Wdelete-non-virtual-dtor, which -Wall turns on, but the delete expression here is inside unique_ptr, in a system header, where warnings are suppressed. Write delete p on a raw pointer and the compile fails under the same flags, as the test confirms. So do not trust the silence.
The fix is one word: virtual ~Order(). It then prints ~LimitOrder, then ~Order, and ASan is silent.
Follow-up. Why does std::make_shared<LimitOrder> work without the virtual destructor? The shared pointer’s control block records how to destroy the object it was created with, the real LimitOrder, so the deleter does not go through Order. The test prints both destructor messages, and ASan is silent. It is guaranteed, but only for this pointer type: add the virtual destructor anyway, since the next person uses unique_ptr.
Why it matters. This is the classic C++ interview question, and the twist is that the usual smart pointer hides the warning.
Question 9.11 ★★ QD, Dev, LLE • any • junior, experienced
Listing 9.11 prints the expected total, and it has run for months. What is wrong with it? How much does it lose, and on which path? Which tool shows it, and what is the fix? Your colleague offers a catch, a delete and a rethrow inside handle. Do you accept that?
struct Order { long qty; std::string tag; };
void validate(const Order& o) {
if (o.qty <= 0) throw std::invalid_argument("bad qty");
}
long handle(long qty) {
Order* o = new Order{qty, "tag"};
validate(*o);
const long q = o->qty;
delete o;
return q;
}
int main() {
long total = 0;
for (long q : {100L, -1L, 200L}) {
try {
total += handle(q);
} catch (const std::exception& e) {
std::puts(e.what());
}
}
std::printf("%ld\n", total);
}
Answer
Answer to Question 9.11.
It leaks one Order every time validate throws. Here that is one object of 40 bytes, plus nothing else, because the short tag fits in the string’s own buffer. LeakSanitizer finds it. Use std::make_unique. Do not accept the catch.
The path is the -1 order. new succeeds, validate throws, the stack unwinds, and the raw pointer o is destroyed without touching what it points to. So delete never runs. The total is still right, 300, because the leak has nothing to do with the arithmetic, which is why it could run for months: a few bytes per rejected order.
ASan’s leak check reports Direct leak of 40 byte(s) in 1 object(s), allocated in handle. With auto o = std::make_unique<Order>(…) and no delete, the destructor runs during unwinding. The test confirms it: same output, nothing in the report.
The colleague’s version does work for one pointer. But it duplicates the cleanup on two paths. It gets worse with each new resource, because a second allocation needs nested handlers. And the next person to add an early return forgets it. This is why the rule is that every resource has an owning object whose destructor releases it. Here, in fact, you need no heap at all: const Order o{qty, "tag"}; is the simplest correct code.
Classic wrong answer. “It is only 40 bytes.” It is 40 bytes each time, and it grows with the rate of rejects, which spike exactly when the market or a client goes wrong.
Why it matters. Reading for the path that throws, not the path that runs, is the habit this question tests.
Question 9.12 ★★ QD, Dev, LLE • any • junior, experienced
Listing 9.12 is compiled as C++20 with g++ 11.4. Is it defined? Now make levels_ public and loop over latest().levels_. Is that defined? What did C++23 change? Give a fix for C++20 and a way to make the compiler reject the mistake.
class Snapshot {
public:
Snapshot() : levels_{100, 200, 300} {}
const std::vector<long>& levels() const { return levels_; }
private:
std::vector<long> levels_;
};
Snapshot latest() { return Snapshot{}; }
int main() {
long total = 0;
for (long q : latest().levels()) total += q;
std::printf("%ld\n", total);
}
Answer
Answer to Question 9.12.
The listing is undefined in C++20, and ASan reports stack-use-after-scope. The version with the public member is defined and gives 600. C++23 makes the first version defined too.
A range-for is defined as auto&& range = initializer; followed by a loop. Binding a reference to a temporary extends the life of that temporary, but only if the reference binds to it, or to a subobject of it. Here it binds to whatever levels() returns. That function returns a reference, and the compiler cannot see that it points into the temporary Snapshot. So the Snapshot dies at the end of the initialiser, before the loop starts, and the loop walks a destroyed vector.
latest().levels_ names a subobject of the temporary directly, so the extension applies to the whole Snapshot. The test runs it clean under ASan. So the same-looking line is safe through a data member and unsafe through a getter.
C++23 added a rule to [class.temporary]: a temporary created in a range-for’s initialiser lives as long as the range reference. g++ 11.4 in C++20 mode does not do this, as the ASan report shows.
For C++20, keep the object in a named variable. Write const Snapshot snap = latest(); first, then loop over snap.levels(). To make the compiler reject it, add reference qualifiers: levels() const& plus a deleted levels() const&&. The test shows the listing then fails to compile.
Classic wrong answer. “Temporaries live to the end of the statement, and the loop is one statement.” The temporary lives to the end of the full expression that initialises the range, which ends before the body runs.
Why it matters. A getter on a function’s return value inside a loop is a shape every C++ code base has. It often seems to work, because the freed memory is still there.
Question 9.13 ★★ QD, Dev, LLE • any • junior, experienced
Two threads run Listing 9.13. It compiles with g++ 11.4 under -Wall -Wextra -Werror, and the author expects 200000. What is wrong? Show the fix. What happens if you write the same line with parentheses instead of braces?
std::mutex m;
long total = 0;
void add(long n) {
std::lock_guard<std::mutex>{m};
total += n;
}
int main() {
auto work = [] { for (int i = 0; i < 100000; ++i) add(1); };
std::thread a(work), b(work);
a.join();
b.join();
std::printf("%ld\n", total);
}
Answer
Answer to Question 9.13.
The line std::lock_guard<std::mutex>{m}; creates a temporary, which locks and unlocks at the semicolon. So total += n runs unprotected and there is a data race. Give the guard a name: std::lock_guard<std::mutex> lock{m};.
The object has no name, so its life ends with its full expression, before the next line. The compiler sees a legal expression with a constructor and destructor that have side effects, and gives no warning, even under -Wall -Wextra -Werror. ThreadSanitizer reports data race at once on total. Without it, the total may or may not come out right, depending on how the threads interleave. With the name added, TSan is silent and the program prints 200000.
With parentheses, std::lock_guard<std::mutex>(m); parses as a declaration of a variable called m of type lock_guard, with no initialiser. The extra parentheses around a declarator are allowed. Since lock_guard has no default constructor, it does not compile here, by luck: the test confirms it. For a type with a default constructor it would compile and hide the global m.
Follow-up. Does std::scoped_lock make this safer? Not by itself. std::scoped_lock lock; with no mutex at all compiles, since the template takes any number of mutexes including none, and it locks nothing. TSan still reports the race on that version. Name the guard, and name the mutex in it.
Why it matters. This is a one-token bug that no compiler warning catches, and spotting a nameless guard is a fair test of having really read lock code.
Question 9.14 ★★ QD, Dev, LLE • any • junior, experienced
Listing 9.14 prints 1000 1000 every time, because the threads never overlap. What is the bug in transfer? Would ThreadSanitizer report it on this run? Fix it. Then: what does your fix do on a transfer from an account to itself?
struct Account { std::mutex m; long balance = 1000; };
void transfer(Account& from, Account& to, long n) {
std::lock_guard a(from.m);
std::lock_guard b(to.m);
from.balance -= n;
to.balance += n;
}
int main() {
Account x, y;
std::thread t1([&] { transfer(x, y, 1); });
t1.join();
std::thread t2([&] { transfer(y, x, 1); });
t2.join();
std::printf("%ld %ld\n", x.balance, y.balance);
}
Answer
Answer to Question 9.14.
transfer(x, y) locks x then y, and transfer(y, x) locks y then x. If two such calls overlap, each can hold one mutex and wait for the other for ever. TSan does report it on this run, as lock-order-inversion. Use std::scoped_lock lock(from.m, to.m);.
This run cannot deadlock, because the first thread is joined before the second starts. That is the point of the question: you cannot catch this with a test that happens to pass. TSan does not need the deadlock to occur. It builds a graph of which mutex was held when another was acquired, and reports a cycle. Here it prints Cycle in lock order graph, on a run that printed the right answer.
std::scoped_lock with two mutexes uses std::lock, which the standard says does not result in deadlock. The usual technique is to lock one and try the others, backing off on failure. Another fix is a fixed global order, say by address or by account id. That also lets you see the rule in the code, and it costs no retries.
The self-transfer is the trap. With from and to the same account, scoped_lock tries to lock one mutex twice, and a std::mutex locked twice by one thread is undefined. In the test it hung until the timeout. So add a guard at the top: if (&from == &to) return;, or the equivalent rule for your domain.
Classic wrong answer. “Always lock in the order of the arguments.” That is the bug. The order must be a property of the mutexes, not of the call.
Why it matters. Two locks taken in opposite orders on different code paths is the classic deadlock. A good answer mentions the tool, the fix, and the edge case the fix creates.
Question 9.15 ★★ QD, Dev, LLE • any • junior, experienced
Find the bug in pop in Listing 9.15 and say exactly how it goes wrong with these two consumers. How would you make the failure loud? Fix it. Would changing notify_all to notify_one also fix it?
std::mutex m;
std::condition_variable cv;
std::deque<int> q;
int pop() {
std::unique_lock lock(m);
if (q.empty()) cv.wait(lock);
const int v = q.front();
q.pop_front();
return v;
}
void push(int v) {
{
std::lock_guard lock(m);
q.push_back(v);
}
cv.notify_all();
}
int main() {
std::thread a([] { std::printf("a %d\n", pop()); });
std::thread b([] { std::printf("b %d\n", pop()); });
std::this_thread::sleep_for(std::chrono::milliseconds(200));
push(7);
std::this_thread::sleep_for(std::chrono::milliseconds(200));
push(8);
a.join();
b.join();
}
Answer
Answer to Question 9.15.
The bug is the if: after wait returns, nothing checks that the queue still has an item. Use the predicate form, cv.wait(lock, [] { return !q.empty(); });. notify_one alone would not fix it.
Here is how it fails. Both consumers wait. push(7) calls notify_all, so both wake and compete for the mutex. The first takes 7. The second then gets the mutex, returns from wait, and goes straight to q.front() on an empty deque. That call is undefined. With no checks on, nothing stops the read, nor the pop_front after it, and this may pass unnoticed for a long while.
To make it loud, build with -D_GLIBCXX_ASSERTIONS, which turns on the library’s precondition checks. The test run aborts with SIGABRT at front. With the predicate the same build prints both values, and TSan is silent.
notify_one wakes one thread, which looks enough. It still leaves two ways to fail. First, wait is allowed to return with no notify at all: the standard’s wording includes “or spuriously”. Second, between the notify and the woken thread getting the mutex, a third thread calling pop can take the item, because it finds the queue non-empty and does not wait.
The rule is short: a condition variable never tells you the condition is true, only that it is worth checking again. So always wait in a loop or use the predicate form, which is that loop.
Classic wrong answer. “if is fine because push always adds an item before notifying.” That is true, but the item may be gone by the time this thread holds the lock.
Why it matters. This appears in nearly every queue that candidates write at a whiteboard. Interviewers look for the loop, and then ask why.
Question 9.16 ★★ QD, Dev, LLE • any • junior, experienced
What does Listing 9.16 print? Its author was sure there would be no copies. Why is there one? Then say why a move constructor without noexcept can also bring copies back, inside std::vector.
struct Tracked {
inline static int copies = 0, moves = 0;
std::string text;
explicit Tracked(std::string t) : text(std::move(t)) {}
Tracked(const Tracked& o) : text(o.text) { ++copies; }
Tracked(Tracked&& o) noexcept : text(std::move(o.text)) { ++moves; }
};
class Holder {
public:
explicit Holder(const Tracked item) : item_(std::move(item)) {}
private:
Tracked item_;
};
int main() {
Tracked t("abc");
Holder h(std::move(t));
std::printf("copies %d, moves %d\n", Tracked::copies, Tracked::moves);
}
Answer
Answer to Question 9.16.
It prints copies 1, moves 1. The parameter is const Tracked, and std::move of a const object gives a const rvalue, which binds to the copy constructor. Drop the const and it prints copies 0, moves 2.
std::move moves nothing. It is a cast to an rvalue reference, and it keeps const. Overload resolution then has two candidates: Tracked(Tracked&&) needs a non-const argument, so it does not fit, and Tracked(const Tracked&) does. So the compiler quietly copies, with no warning. The one move is the outer one, from t into the parameter. The one copy is from the parameter into item_.
The const on a by-value parameter looks harmless and even virtuous, since it does not change the function’s signature. For a parameter you intend to move from, it is exactly wrong. The same goes for a const local variable that you later std::move.
In std::vector, growth has to give the strong exception guarantee: if something throws while relocating, the old buffer must still be intact. A move that throws would have destroyed the old elements. So the vector uses std::move_if_noexcept: it moves only if the move constructor is noexcept, or if the type cannot be copied. The test shows it: two emplace_back calls print copies 0, moves 1 with noexcept, and copies 1, moves 0 without it.
Classic wrong answer. “std::move forces a move.” It only makes one possible. Counting with a tracked type, as here, is how you check.
Why it matters. Unnoticed copies of large messages in a hot path come from exactly these two lines. They give no compile error and no test failure, only a slower program.
Question 9.17 ★★ QD, Dev, LLE • market maker, hedge fund • junior, experienced
Listing 9.17 prints 1 then done when built normally. Is it correct? What would AddressSanitizer report, and at which line? Design the fix. What does it need from the class, so that no one can break it by accident?
class Feed {
public:
void subscribe(std::function<void(long)> f) { subs_.push_back(std::move(f)); }
void publish(long v) { for (auto& f : subs_) f(v); }
private:
std::vector<std::function<void(long)>> subs_;
};
class Strategy {
public:
explicit Strategy(Feed& feed) { feed.subscribe([this](long v) { total_ += v; }); }
long total() const { return total_; }
private:
long total_ = 0;
};
int main() {
Feed feed;
auto s = std::make_unique<Strategy>(feed);
feed.publish(1);
std::printf("%ld\n", s->total());
s.reset();
feed.publish(2);
std::puts("done");
}
Answer
Answer to Question 9.17.
It is not correct: the second publish calls a lambda holding a dangling this, and it writes to freed memory. ASan reports heap-use-after-free at total_ += v. The fix gives the subscription an owner, so the destructor unsubscribes.
The feed stores the std::function, which stores the raw this. The feed knows nothing about the strategy’s lifetime. After s.reset(), the strategy is gone, but its lambda is still in subs_. Built normally, the write lands on freed memory and nothing shows, so you see 1, done. In production the same write lands in whatever was allocated there next, and the symptom appears somewhere else entirely.
The fix in Listing 9.41 makes subscribe return a handle, stored in the strategy, and the destructor gives it back. A list keeps iterators valid while other subscribers come and go. It runs clean under ASan.
There is a second bug to prevent. The default copy constructor would copy slot_ but leave the lambda pointing at the original object, and both destructors would erase the same slot. So the class must delete copying, as here. The test shows that copying then does not compile.
Another common design uses std::shared_ptr for the strategy and captures a std::weak_ptr in the lambda. The feed locks it on every call, and an expired one is skipped. That is simpler with several threads, and it costs an atomic operation per call.
class Feed {
public:
using Slot = std::list<std::function<void(long)>>::iterator;
Slot subscribe(std::function<void(long)> f) {
subs_.push_back(std::move(f));
return std::prev(subs_.end());
}
void unsubscribe(Slot s) { subs_.erase(s); }
void publish(long v) { for (auto& f : subs_) f(v); }
private:
std::list<std::function<void(long)>> subs_;
};
class Strategy {
public:
explicit Strategy(Feed& feed)
: feed_(feed), slot_(feed.subscribe([this](long v) { total_ += v; })) {}
~Strategy() { feed_.unsubscribe(slot_); }
Strategy(const Strategy&) = delete; // [this] is inside the feed: never copy
Strategy& operator=(const Strategy&) = delete;
long total() const { return total_; }
private:
Feed& feed_;
Feed::Slot slot_;
long total_ = 0;
};
int main() {
Feed feed;
auto s = std::make_unique<Strategy>(feed);
feed.publish(1);
std::printf("%ld\n", s->total());
s.reset();
feed.publish(2);
std::puts("done");
}
Follow-up. What if a callback unsubscribes itself during publish? This version then erases the element the range-for is standing on, which is undefined. The usual answer is to mark it dead and remove it after the loop, or to loop over a copy.
Why it matters. Callbacks are how feeds reach strategies. Getting the order of destruction right is daily work in a trading system.
Question 9.18 ★★ QD, Dev, DS • any • junior, experienced
What does the last line of Listing 9.18 print on CPython, and why? What should this class hash on? What happens if you simply delete its __hash__?
class Order:
def __init__(self, oid, qty):
self.oid, self.qty = oid, qty
def __eq__(self, other):
return (self.oid, self.qty) == (other.oid, other.qty)
def __hash__(self):
return hash((self.oid, self.qty))
book = set()
o = Order(1, 100)
book.add(o)
o.qty = 50 # a partial fill
print(o in book, len(book), any(x is o for x in book))
Answer
Answer to Question 9.18.
It prints False 1 True: the order is in the set, and in cannot find it. Hash on oid alone, the part that never changes, and have __eq__ agree. If you delete __hash__, book.add(o) raises a TypeError.
A set stores each item in a slot chosen from its hash at the time of insertion. Changing qty changes the hash that __hash__ now computes, so the lookup looks in a different slot. It finds nothing, and it never compares. The item is still there, as the last two values show: len is 1, and a scan by identity finds it. You now have an order that you can neither find nor remove.
The Python data model warns against this directly: a class that defines mutable objects and implements __eq__ should not implement __hash__, since a key’s hash value must be immutable. The language enforces half of this. A class that defines __eq__ without __hash__ has its hash set to None, so instances are unhashable. The test shows that error.
With hash(self.oid) and equality on oid, the line prints True 1 True. But note what that changes: two orders with the same id are now equal whatever their quantity. That is what you want for identity in a book. It is not what you want if you are comparing two snapshots. A cleaner design separates them: a dict from id to order keeps the identity in the key and leaves the order free to change.
Classic wrong answer. “Sets compare with ==, so it will find it.” They compare only within a slot chosen by the hash, so the comparison never happens.
Why it matters. Hashing a mutable field is an easy mistake with a nasty symptom: items that are there and yet are not.
Question 9.19 ★★ QR, QD, Dev, DS • any • junior, experienced
Listing 9.19 is a pricer that caches its results. What do the two lines print? A long-running process creates one Pricer per curve, and its memory keeps growing. Why? Give a fix that keeps the cache.
import gc
import weakref
from functools import lru_cache
class Pricer:
def __init__(self, curve):
self.curve = curve
@lru_cache(maxsize=None)
def price(self, strike):
return self.curve[0] * strike
p = Pricer([1.5] * 1_000_000)
print(p.price(100))
ref = weakref.ref(p)
del p
gc.collect()
print(ref() is None)
Answer
Answer to Question 9.19.
It prints 150.0 and False: after del p and a full collection, the pricer is still alive. The cache keeps it alive, along with its million-element curve. Give each instance its own cache.
@lru_cache on a method wraps the function once, at class level. Its key contains all the arguments, and the first of them is self. So the class-level cache holds a strong reference to every instance that ever called it. The Python FAQ says the same of lru_cache on methods: “it creates a reference to the instance”. With maxsize=None it never evicts, so each new Pricer and its curve stay until the process ends. That is the memory growth. Linters flag this pattern too: ruff’s rule B019 reports this very line.
To keep the caching, create the cache in the constructor:
self.price = lru_cache(maxsize=None)(self._price)
The cache then belongs to the instance. There is now a reference cycle (instance to cache to bound method to instance), so the object is freed by the cycle collector rather than at once. The test prints True after gc.collect(). Bounding maxsize is sensible too: with the class-level form it limits the damage but does not cure it, because a few big instances can stay pinned.
Follow-up. Is there another bug waiting in this class? Yes. The cache key does not include self.curve’s contents. If someone updates the curve in place, the cache returns stale prices. A cache should be keyed on everything the result depends on, or the input should be immutable.
Why it matters. Caching in a pricer is natural, and this is the commonest real memory leak in Python research and pricing services.
Question 9.20 ★★ QR, QT, QD, Dev, DS • any • junior, experienced
Listing 9.20 measures one New York day on Python 3.10. What do the two lines print? Which one is the length of that day? Name a calculation at a trading firm that would silently go wrong with the first.
from datetime import datetime, timezone
from zoneinfo import ZoneInfo
ny = ZoneInfo("America/New_York")
start = datetime(2026, 3, 8, 0, 0, tzinfo=ny)
end = datetime(2026, 3, 9, 0, 0, tzinfo=ny)
print(end - start)
print(end.astimezone(timezone.utc) - start.astimezone(timezone.utc))
Answer
Answer to Question 9.20.
It prints 1 day, 0:00:00, then 23:00:00. The second is true: on 8 March 2026 New York moved its clocks forward, so that day had 23 hours.
The Python documentation states the rule: if both datetimes are aware and have the same tzinfo attribute, the tzinfo attributes are ignored and no time zone adjustment is done. Here both use the same ny object, so the subtraction works on wall-clock fields, midnight to midnight, and gives one day. Converted to UTC, the two carry different offsets: the test shows hours just before the change and after. The subtraction then gives the elapsed time. The same trap runs the other way on 1 November 2026, a day of 25 hours.
Anything that divides by elapsed time goes wrong silently with the first form. Volume per hour, or a time-weighted average spread for that day, is off by about 4%, as 24 hours is used for a 23-hour day. A timeout or a session schedule computed by adding hours to a local time can be off by an hour. An annualisation or a time-to-expiry computed on local timestamps can pick up the same error.
The rule at trading firms: store and compute in UTC, or in integer nanoseconds since the epoch, and convert to local time only to display, or to apply exchange session rules, which are themselves defined in local time.
Classic wrong answer. “zoneinfo handles daylight saving.” It does give the right offsets, but the subtraction ignores them when both sides have the same tzinfo, as here.
Why it matters. Two days a year, this is an hour of wrong data, an hour early or late at the open. It usually surfaces first in production, because few tests use those dates.
Question 9.21 ★★ QD, Dev, LLE • crypto, market maker • junior, experienced
What does Listing 9.21 print, with rustc 1.97? Which messages do you never see, and why? Is that a bug by Rust’s own rules? Fix it with one change of type, and say what the fixed version prints.
use std::cell::RefCell;
use std::rc::Rc;
struct Node {
name: &'static str,
next: RefCell<Option<Rc<Node>>>,
}
impl Drop for Node {
fn drop(&mut self) {
println!("drop {}", self.name);
}
}
fn main() {
let a = Rc::new(Node { name: "a", next: RefCell::new(None) });
let b = Rc::new(Node { name: "b", next: RefCell::new(Some(a.clone())) });
*a.next.borrow_mut() = Some(b.clone());
println!("{} {}", Rc::strong_count(&a), Rc::strong_count(&b));
}
Answer
Answer to Question 9.21.
It prints 2 2 and nothing more. You never see drop a or drop b, because the two nodes own each other and neither count reaches zero. By Rust’s rules that is not a safety bug. Change Rc to Weak in the next field.
Each node starts with one strong reference from its local variable, and the other node adds one, hence 2 2. At the end of main, the two local variables go away and each count drops to 1. Nothing further can drop them, since each is kept alive by the other. So the memory is never freed and the destructors never run.
The standard library says so plainly: a cycle between Rc pointers will never be deallocated, and Weak is there to break cycles. The Rust Reference lists leaks of memory under behaviour that is not considered unsafe, along with deadlocks. So never rely on Drop to release a limit or close a position.
With RefCell<Weak<Node>> and Rc::downgrade, the fixed version prints 1 1, then a (the upgrade from b still succeeds), then drop b, drop a. A Weak does not keep the value alive, so upgrade returns an Option, and you must handle None.
Classic wrong answer. “Rust prevents memory leaks.” It prevents use-after-free and data races in safe code. A leak is memory safe.
Follow-up. Which pointer should be weak in a tree? The one from child to parent. A parent owns its children, and a child only needs to reach its parent while the parent exists.
Why it matters. Graph-shaped state, such as orders pointing at their levels and levels at orders, tempts people into cycles in Rust as in any language with reference counts.
Question 9.22 ★★ QD, Dev, LLE • crypto, market maker • experienced
Listing 9.22 compiles with no warning under rustc 1.97. What happens when you run it, and why? Does the 2024 edition change this? Fix it with one extra line.
use std::sync::Mutex;
fn main() {
let queue = Mutex::new(vec![1, 2, 3]);
match queue.lock().unwrap().pop() {
Some(x) => {
queue.lock().unwrap().push(x * 2);
println!("requeued {x}");
}
None => println!("empty"),
}
println!("done");
}
Answer
Answer to Question 9.22.
On rustc 1.97 it hangs for ever. The guard from the first lock() lives until the end of the match, and the arm tries to lock the same mutex again. The 2024 edition does not change this. Add a let first: let item = queue.lock().unwrap().pop();, then match item {.
queue.lock().unwrap() creates a temporary MutexGuard, and pop() borrows from it only briefly. You might expect the guard to go once pop returns. But the Rust Reference notes that the scrutinee of a match is not a temporary scope, so temporaries in the scrutinee can be dropped after the match expression. The guard is still alive through the arms. The second lock() on the same thread then waits for a lock that it holds itself. The documentation of Mutex::lock says this call might panic. Here it hung, and the test sees the timeout, so do not rely on the panic.
The 2024 edition changes two scopes, and neither is this one. Temporaries in an if let scrutinee now drop before the else branch, but not before the body, and tail expression temporaries drop earlier. The test builds this match and the if let form under edition 2024, and both still hang, since the second lock is in the body.
With the let, the statement ends at its semicolon, the guard drops, and the program prints requeued 3, then done.
Classic wrong answer. “The borrow checker prevents deadlocks.” It prevents data races. The Rust Reference lists deadlocks as not considered unsafe.
Why it matters. Nothing in the text shows the guard, let alone how long it lives. Reading for that scope is the skill, and it is the one that finds this deadlock before production does.
Question 9.23 ★★ QD, Dev • bank • junior, experienced
On OpenJDK 11, what does Listing 9.23 print? Why is the second line surprising given the first? State the rule that was broken and the fix. Then give another way to break a key, even with both methods written.
import java.util.HashMap;
import java.util.Map;
public class EqualsOnly {
static final class Key {
final String symbol;
Key(String symbol) { this.symbol = symbol; }
@Override public boolean equals(Object o) {
return o instanceof Key && ((Key) o).symbol.equals(symbol);
}
}
public static void main(String[] args) {
Map<Key, Long> limits = new HashMap<>();
limits.put(new Key("AAA"), 1000L);
System.out.println(new Key("AAA").equals(new Key("AAA")));
System.out.println(limits.get(new Key("AAA")));
}
}
Answer
Answer to Question 9.23.
It prints true, then null. Key overrides equals but not hashCode, so two equal keys have different hash codes, and the map does not find the entry. Add hashCode, which returns symbol.hashCode().
The Object documentation states the contract: if two objects are equal according to equals, calling hashCode on each must produce the same integer result, so it is generally necessary to override hashCode whenever equals is overridden. The default hashCode is not based on the fields, so equal keys almost always have different hashes. HashMap.get compares the stored hash first, and calls equals only if they match, so even a key in the same bucket is not found. So the lookup returns null without ever calling your equals. With the override the program prints true and 1000.
The compiler says nothing, because both methods are optional. A null from get also looks like “no limit set”. That is the dangerous part. In a limit check, unboxing the null into a long gives a NullPointerException at best. At worst it reads as “no limit, so allow”.
Another way to break it: keep both methods, but use a mutable field in them, and change the field after inserting. The hash now differs from the stored one, and the same lookup returns null. This is why key classes should be immutable. Here symbol is final, and the class is final too, so a subclass cannot change what equality means. The test runs this mutable variant too.
Classic wrong answer. “It prints 1000, because the keys are equal.” HashMap never reaches the equality check.
Why it matters. This is the first question on a Java interview at a bank, because every position and limit map depends on it.
Question 9.24 ★★ QD, Dev • bank • junior, experienced
On OpenJDK 11, Listing 9.24 should print 400000. It does not. The map is a ConcurrentHashMap, so what went wrong? Make the fix one line. What would you use if the counter were updated from very many threads and read rarely?
import java.util.concurrent.ConcurrentHashMap;
public class LostMerge {
static final ConcurrentHashMap<String, Long> position = new ConcurrentHashMap<>();
static void add(String symbol, long qty) {
Long old = position.get(symbol);
position.put(symbol, (old == null ? 0L : old) + qty);
}
public static void main(String[] args) throws InterruptedException {
Thread[] pool = new Thread[4];
for (int i = 0; i < pool.length; i++) {
pool[i] = new Thread(() -> {
for (int j = 0; j < 100000; j++) add("AAA", 1);
});
pool[i].start();
}
for (Thread t : pool) t.join();
System.out.println(position.get("AAA"));
}
}
Answer
Answer to Question 9.24.
It prints a number far below 400000, about 130000 in two runs here with four threads, and it changes from run to run. The map is thread-safe call by call, but get followed by put is two calls, so updates get lost between them. Replace both lines by position.merge(symbol, qty, Long::sum);.
Each operation of ConcurrentHashMap is safe, and the map’s internals are never corrupted. But thread A can read 5, thread B can read 5, both compute 6, and both write 6. One increment is gone. This is a check-then-act race, and no choice of collection saves you from it while the logic stays spread over two calls.
merge does the read, the computation and the write as one step on that key. The documentation states that the entire method invocation is performed atomically, and that update operations by other threads may be blocked meanwhile. So keep the function short, and do not touch the map from inside it. The test prints exactly 400000 with this line.
compute does the same with a more general function, and putIfAbsent covers the first-time case. Here, Long::sum is all that is needed.
For a counter hit by many threads and read rarely, use LongAdder. It keeps one or more variables that grow in number under contention, and adds them up on read. Its Javadoc calls it usually preferable to AtomicLong for such sums, at the expense of more space. map.computeIfAbsent(symbol, s -> new LongAdder()).add(qty) is the usual form.
Classic wrong answer. “Make the map a Hashtable or wrap it in synchronizedMap.” Those lock each call, so the race between the two calls is still there.
Why it matters. Position keeping in a concurrent service is exactly this code. A number that drifts low is one of the worst kinds of bug, since nothing throws.
Question 9.25 ★★ QD, Dev • bank • junior, experienced
Listing 9.25 is built in Release mode on .NET 8. The Strategy that Make creates goes out of scope, and nothing in Main refers to it. What does the program print after the collection, and why? Why would many short-lived strategies be a problem here? Give the usual fix.
using System;
using System.Runtime.CompilerServices;
class Feed {
public event Action<long>? Tick;
public void Publish(long v) => Tick?.Invoke(v);
}
class Strategy {
long total;
public Strategy(Feed feed) { feed.Tick += OnTick; }
void OnTick(long v) => total += v;
public long Total => total;
}
static class Program {
[MethodImpl(MethodImplOptions.NoInlining)]
static WeakReference Make(Feed feed) => new WeakReference(new Strategy(feed));
static void Main() {
var feed = new Feed();
WeakReference w = Make(feed);
GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();
Console.WriteLine(w.IsAlive);
GC.KeepAlive(feed);
}
}
Answer
Answer to Question 9.25.
It prints True: the strategy survives the collection. The event in feed holds a delegate that holds the strategy. Many short-lived strategies would therefore pile up, each still receiving ticks. Unsubscribe with -=, normally in Dispose.
feed.Tick += OnTick creates a delegate whose target is the Strategy instance, and adds it to the feed’s list. Garbage collection follows references, so a live feed keeps the delegate alive, which keeps the strategy alive. Microsoft’s programming guide says this in nearly those words: as long as the publishing object holds that reference, garbage collection will not delete your subscriber object, so you should unsubscribe before you dispose of the subscriber.
The listing is built with care to show this honestly. NoInlining on Make and the Release build mean that no local variable keeps the strategy alive by accident. GC.KeepAlive(feed) keeps the feed alive through the check. So the only path left is the event.
Short-lived subscribers on a long-lived publisher are the bad shape: an instrument’s view subscribing to a global feed, say, for the length of a screen. Memory grows, and so does CPU, because each dead strategy still runs OnTick on every tick, doing work for nothing. That second symptom is often the one you notice first.
With IDisposable and feed.Tick -= OnTick in Dispose, and using var s in Make, the program prints False.
Classic wrong answer. “C# has a garbage collector, so it cannot leak.” The collector frees what is unreachable. This is reachable, which is a leak in everything but name.
Follow-up. Does the strategy holding a reference to the feed cause the same problem? No. That reference runs the other way: a strategy that is otherwise unreachable can be collected along with its reference. Only publisher to subscriber keeps a subscriber alive.
Why it matters. Long-running desktop and pricing applications at banks often hit this one.
Question 9.26 ★★ QR, QD, Dev, DS • any • junior, experienced
All three tests in Listing 9.26 pass under pytest. How many of them could ever fail? What is wrong with vwap, and with each test? Rewrite the tests so they catch the bug.
import pytest
def vwap(trades):
return sum(price for price, _ in trades) / len(trades)
def test_two_trades():
assert vwap([(100.0, 50), (102.0, 50)]) == pytest.approx(101.0)
def test_empty():
try:
vwap([])
except ZeroDivisionError:
pass
def test_one_trade():
assert (vwap([(100.0, 7)]) == 99.0, "a single trade is its own average")
Answer
Answer to Question 9.26.
Only the first can ever fail on a wrong number, and it cannot catch the bug. vwap ignores the quantities and takes a simple mean. Test with unequal quantities, and use pytest.raises and a bare assert.
Go through them. test_two_trades uses equal quantities, 50 and 50, and for those the simple and weighted means agree: 101 either way. test_empty has no assert and swallows the exception it hopes for, so it passes if the call raises that and passes if it returns. test_one_trade asserts a two-element tuple. A non-empty tuple is always true, so it can never fail, even though it compares against a wrong value, 99. Python itself warns at compile time: assertion is always true. The test captures that warning. pytest happily counts 3 passed.
The right function is the sum of divided by the sum of . Rewrite:
assert vwap([(100.0, 10), (102.0, 30)]) == 101.5
On the current function that gives 101, and it fails. Hand-compute your expected value, and do not paste it from a run.
with pytest.raises(ValueError): then vwap([]), which fails if nothing raises. Then assert vwap([(100.0, 7)]) == 100.0.
Add properties, which catch whole families of bugs: the result lies between the lowest and highest price, and multiplying every quantity by 3 changes nothing. The test runs both on 200 random lists.
To check the tests themselves, break the code on purpose and see that a test fails. This is the idea behind mutation testing, and here it would have exposed all three at once.
Classic wrong answer. “Coverage is 100%, so it is tested.” Every line runs. Nothing is checked.
Why it matters. Firms read your tests as closely as your code. Tests that cannot fail are worse than none, because they give confidence.
Question 9.27 ★★ QD, Dev, LLE • market maker, hedge fund • junior, experienced
Listing 9.27 sends 101 lots at a price of 500, and the compiler has nothing to say. Design the types so that this mistake does not compile. A colleague fears the wrappers cost time in the hot path. With g++ 11.4, -std=c++20 -O2, on x86-64 Linux, what do you expect in the machine code?
void send(long price, long qty) {
std::printf("%ld at %ld\n", qty, price);
}
int main() {
long price = 101, qty = 500;
send(qty, price);
}
Answer
Answer to Question 9.27.
Give each quantity its own type: struct Price { long ticks; }; and struct Qty { long lots; };. send(qty, price) then does not compile. In the machine code, expect no difference at all from plain long.
Listing 9.42 is the result. It compiles at the same flags, prints the right thing, and swapping the arguments is now an error: the test confirms it. For the cost, it compares two functions:
long cost_raw(long, long) and long cost_typed(Price, Qty).
Under g++ 11.4 at -O2 on x86-64 Linux, both compile to the same instructions, movq, imulq, ret (plus an endbr64 on Ubuntu’s default flags). The test compares them line by line. The reason lies in the System V calling convention: a small struct of one integer goes in a register exactly as the integer does. A wrapper has no run-time existence, and all of it happens in the type checker.
Add only the operations that make sense. Price minus price is a price difference, and quantity times price is a notional. Price plus price has no meaning, so leave it out, and the compiler now stops that too. Conversions from long should be explicit, or the protection goes away.
There is one hole in this listing. These are aggregates, so send({500}, {101}) still compiles and still swaps. If that worries you, add an explicit constructor.
struct Price { long ticks; };
struct Qty { long lots; };
void send(Price price, Qty qty) {
std::printf("%ld at %ld\n", qty.lots, price.ticks);
}
long cost_raw(long price, long qty) { return price * qty; }
long cost_typed(Price price, Qty qty) { return price.ticks * qty.lots; }
int main() {
Price price{101};
Qty qty{500};
send(price, qty);
std::printf("%ld %ld\n", cost_raw(101, 500), cost_typed(price, qty));
}
Classic wrong answer. “Comment the parameters, or use named variables.” The listing does name its variables, and that is how it fails.
Why it matters. A swapped price and quantity is a fat-finger bug made by a programmer. Type wrappers are one of the very few tools that remove a class of bug at no run-time cost.
Question 9.28 ★★ QD, Dev, QR • any • junior, experienced
A test started failing somewhere in the last 1024 commits. Each run, build included, takes 6 minutes. How many runs does a bisect need, and how long does it take? Now the test is flaky: on a commit with the bug it fails only 1 run in 4, and on a commit without the bug it never fails. How many runs per commit do you need for the whole bisect to find the right commit with probability at least 99%? How long is that, in the worst case?
Answer
Answer to Question 9.28.
The plain bisect needs 10 runs, , so 60 minutes. With the flaky test you need 24 runs per commit, and the worst case is 24 hours.
The git bisect command is a binary search: each test halves the range, so 1024 commits take 10 steps. That assumes one good commit and one later bad commit, with one change from good to bad between them.
With the flaky test, a failure is conclusive, since a commit without the bug never fails. A pass is not. A bad commit passes runs in a row with probability . In that case the search takes a bad commit for a good one, and goes the wrong way for good. On the worst path, all 10 commits tested are bad, and you need them all to be judged correctly:
Taking the tenth root, , so . Indeed gives 0.9900 and gives 0.987. So each bad commit needs a miss rate of about .
For the time, the worst case is 10 commits at 24 runs each: 240 runs, or 24 hours. Every good commit costs all 24 runs, because it has to pass them all. A bad commit stops at its first failure, after about 4 runs on average. A typical path meets 5 of each, so runs, or 14 hours. A simulation of 4000 searches in the test finds the right commit more than 99% of the time.
In practice, first make the test less flaky, or run it on many machines at once. With 24 runs in parallel, each step takes 6 minutes and the whole search an hour.
Classic wrong answer. “Run it three or four times, since it fails one time in four.” Four runs still miss with probability 32%, and over ten steps the search is more likely to be wrong than right.
Why it matters. Finding when a rare fault came in is a routine task at a firm, and the arithmetic decides whether it is an afternoon’s work or a waste of a week.
Question 9.29 ★★ QD, Dev • bank • junior, experienced
You are reviewing Listing 9.28. A book rebuilt from history must not take new orders, so its author made it a subclass that raises. The code runs. What do you say in the review, and what shape would you ask for instead? Is there a case where you would accept it?
class Book:
def __init__(self):
self.orders = {}
def add(self, oid, qty):
self.orders[oid] = qty
def total(self):
return sum(self.orders.values())
class ReplayBook(Book):
"""A book rebuilt from history. Nothing may be added to it."""
def add(self, oid, qty):
raise NotImplementedError("read-only")
def top_up(book, oid, qty):
book.add(oid, qty)
return book.total()
Answer
Answer to Question 9.29.
I would ask for a change. ReplayBook breaks what Book promises, since any code handed a Book may call add, and here that raises. Split the interface: a read-only view with total, and a mutable Book built on top of it.
The test for inheritance is whether the subclass can stand in everywhere the base is used. This is the Liskov substitution principle, and top_up shows the failure: with a Book it returns a total, and with a ReplayBook it raises. Nothing in the types warns the author of top_up, so the failure shows up at run time, probably in the path nobody tests, and only for replayed books.
The direction of the hierarchy is wrong too. Read-only is the smaller interface, so it should be the base. ReadableBook has total; Book adds add. ReplayBook is then a ReadableBook, and top_up declares that it needs a Book. With a static checker or a C++ compiler, handing it a replayed book is an error at the point of the mistake. In Python you would state this with typing.Protocol and let a type checker enforce it.
Often you should go further and drop inheritance. Replay needs a loader that fills a book and then hands out a read-only view. Composition gives you that with no new class of books at all, and the same Book implementation stays the only one to test.
There is a case for accepting the subclass: a short-lived script, one caller, under a deadline. Say why, and leave a note. I would also accept it if the base class’s contract already declares the operation optional, and callers are written to expect that. But then that contract is the design to question.
Classic wrong answer. “It is fine, because NotImplementedError tells you exactly what went wrong.” It tells you at run time, on the replay path. A type would tell you before anything runs.
Why it matters. Interviewers want to hear a reason and a trade-off, not the name of a principle. A design answer that says when you would not bother is more convincing.
Question 9.30 ★★★ QD, Dev, LLE • any • experienced
Listing 9.29 prints 200 in every unit test and sometimes prints nonsense in production. Built with g++ 11.4, -O2 -Wall -Wextra -Werror, it gives no warning. What is the bug, and why does the test pass? How would you make the failure happen every time, on the spot, with no new tools and no recompile? What would it print then? Give the one-token fix.
struct Stats { long count; long sum; };
Stats* make_stats() { return new Stats; }
long average(const long* xs, int n) {
Stats* s = make_stats();
for (int i = 0; i < n; ++i) {
s->count += 1;
s->sum += xs[i];
}
const long out = s->sum / s->count;
delete s;
return out;
}
int main() {
const long xs[] = {100, 200, 300};
std::printf("%ld\n", average(xs, 3));
}
Answer
Answer to Question 9.30.
new Stats leaves count and sum uninitialised, and reading them is undefined. Tests pass because fresh heap memory happens to be zero. Run with MALLOC_PERTURB_=165: it then prints 1. Write new Stats{}.
Stats has no constructor, so new Stats does default initialisation, which for a plain struct does nothing. new Stats{} does value initialisation, which zeroes it. The two differ by two characters, and neither gets a warning under -Wall -Wextra -Werror, since the allocation and the use are in different functions.
Why does the test pass? In a short-lived process, the allocator hands out memory fresh from the operating system, and that is zeroed. In a long-lived process, the allocator reuses blocks that were freed, so you see old contents, which is the nonsense in production. So the unit test and production really do have different initial conditions.
glibc gives you a way to expose this with no recompile. The man page for mallopt describes M_PERTURB: bytes of allocated memory are initialised to the complement of the value’s low byte. Setting the environment variable MALLOC_PERTURB_=165 fills allocations that come from the main allocator with 0x5a, and the program then prints 1: both fields start at the same huge value, and adding 600 and 3 hardly changes the ratio. With new Stats{}, the same run prints 200. On glibc 2.35, a block reused from the per-thread cache keeps what it had, so to catch every one also set GLIBC_TUNABLES=glibc.malloc.tcache_count=0. The check needs no rebuild, so it can run in every test job.
Other tools exist. g++ 11.4 rejects -fsanitize=memory, as the test confirms. Clang’s MemorySanitizer is the sanitizer built for this, and it needs a rebuild under Clang. Valgrind’s Memcheck reports the use of uninitialised values too. Neither was run for this answer.
Classic wrong answer. “Set the fields to zero in a constructor everywhere.” That works, but default member initialisers, long count = 0;, are shorter and cannot be forgotten in a new constructor.
Why it matters. “It works on my machine and in tests” is what this bug looks like, and a developer who can force it to reproduce in one command is who you want to have around.
Question 9.31 ★★★ QD, Dev, LLE • market maker, hedge fund • experienced
The resident memory of a trading process grows by a few hundred megabytes a day. You run it under AddressSanitizer with leak checking on, and at exit it reports nothing. Listing 9.30 is the shape of the code. Why is the tool silent? There are three different things that can make memory grow. Name them, and describe how you tell which one you have, in order.
struct Order { long id; char pad[120]; };
std::map<long, Order> done; // finished orders, kept "for the audit"
void on_fill(long id) { done.emplace(id, Order{id, {}}); }
int main() {
for (long id = 0; id < 100000; ++id) on_fill(id);
std::printf("%zu\n", done.size());
}
Answer
Answer to Question 9.31.
The memory is still reachable: done is a global that points to every order. LeakSanitizer reports only memory with no live pointer left at exit, so it says nothing. The three causes are a true leak, unbounded growth of live data, and allocator retention or fragmentation.
The test shows the difference. The listing, run under ASan with 100000 orders, reports nothing. Replace the map insert by a bare new Order whose pointer is thrown away, and it reports the leak at once. The tool answers one exact question, “is anything unreachable?”, and here it is correct. The fault lies in the design: this is what people call a logical leak.
Tell them apart in this order. First, record the allocator’s own view over time alongside the resident size. glibc’s mallinfo2 reports allocated space and free space. Add uordblks and hblkhd for the allocated bytes, since large blocks are mapped directly and counted apart.
If allocated bytes grow with resident memory, you have real growth in use, a leak or a grow-only structure. A heap profiler then gives you the allocation sites, ranked by bytes still live. A site that keeps climbing is your suspect, and you read what holds it. If LeakSanitizer is silent, it is reachable, so look for maps, caches, queues and logs with no bound.
If allocated bytes are flat and resident memory grows, there are two cases. Either free space inside the allocator is growing, or the memory never came through malloc: direct mappings, thread stacks and libraries with their own allocators do not appear at all, so check /proc/PID/smaps. In the first case the allocator is holding freed pages it cannot return, usually because of a mix of sizes that fragments its arenas, or many threads with separate arenas. Look at the free-space figure, and test with another allocator or a bounded arena.
For this code: each entry costs 136 bytes of payload (8 for the key and 128 for the order), plus the tree node’s overhead. Two million fills a day is at least 272 MB a day, which matches the growth. Write the audit to disk, keep only the session’s records in memory, and add a metric for the map’s size.
Classic wrong answer. “ASan says no leaks, so it must be fragmentation.” That skips the likeliest cause. Reachable growth is much more common.
Why it matters. Trading processes run for days with no restart, and a growth problem is a routine investigation. Interviewers want an ordered method, not a list of tools.
Question 9.32 ★★★ QD, Dev, LLE • any • experienced
Listing 9.31 looks careful: it checks, locks and checks again. Compiled with g++ 11.4 for x86-64, is it correct? Exactly what can go wrong, and does the strong memory model of x86-64 save it? Give the simplest correct version, then one that keeps the double check. How does the same pattern get fixed in Java?
struct Config { long limit = 1000; };
Config* instance = nullptr;
std::mutex m;
Config* get() {
if (instance == nullptr) {
std::lock_guard lock(m);
if (instance == nullptr) instance = new Config;
}
return instance;
}
int main() {
std::vector<std::thread> pool;
for (int i = 0; i < 4; ++i) pool.emplace_back([] { (void) get()->limit; });
for (auto& t : pool) t.join();
std::printf("%ld\n", get()->limit);
}
Answer
Answer to Question 9.32.
It is not correct. The first read of instance is unsynchronised against the write in another thread, so this is a data race and undefined behaviour. x86-64 does not save it. The simple correct version is a function-local static. Keeping the double check needs std::atomic with acquire and release.
The classic failure: thread A runs new Config, which is three steps, allocate, construct, assign. Nothing stops the assignment from becoming visible to thread B before the construction, so B sees a non-null pointer and reads a limit that is not yet set. B never takes the lock, so the lock cannot help.
The strong hardware memory model of x86-64 keeps stores in order, but the language’s rules bind the compiler, and it is entitled to reorder a non-atomic write, or to assume no other thread is looking. And the program is undefined regardless of what the machine does. TSan reports data race on this listing in the test.
The simplest fix, also from the test:
Config* get() { static Config instance; return &instance; }
The standard guarantees this: if control enters the declaration concurrently while the variable is being initialised, the other threads wait for it to finish. g++ does it with a guard variable that is checked on every call and __cxa_guard_acquire on the slow path, so it is itself a double check, written for you. TSan is silent.
To keep the pattern, use std::atomic<Config*>. The fast path does an acquire load. The slow path holds the mutex, reloads with relaxed order, constructs, and does a release store. Then a thread that sees the pointer also sees the object, and TSan is silent on this version too. std::call_once is the library form.
In Java, the same pattern works if the field is volatile: the Java Language Specification says that a write to a volatile field happens-before every subsequent read of it. Without volatile it is broken in the same way. That rests on the specification, since no test here can force the reordering on a JVM.
Classic wrong answer. “x86 has a strong memory model, so it is fine in practice.” The compiler is what reorders here, and a passing test proves little about a race.
Why it matters. This pattern still appears in old C++ and Java code, and it separates people who remember the recipe from those who can say what goes wrong.
Question 9.33 ★★★ QD, Dev, LLE • any • experienced
A program is three files. Listing 9.32 and Listing 9.33 each define one global, and Listing 9.34 prints limit, with BASE_RATE unset. A colleague changes the order of the object files in the link line and the risk limit becomes zero. Explain that. What exactly prints in each order on g++ 11.4 and GNU ld? Give a fix that works in any order, and a keyword that turns the mistake into a compile error.
long compute_base() {
const char* s = std::getenv("BASE_RATE");
return s ? std::atol(s) : 100;
}
long base_rate = compute_base();
extern long base_rate;
long limit = base_rate * 10;
extern long limit;
int main() { std::printf("%ld\n", limit); }
Answer
Answer to Question 9.33.
With the object files in the order main, rates, limits, it prints 1000. With main, limits, rates, it prints 0. This is the static initialisation order problem. Use a function-local static, and mark a global constinit to make it a compile error.
Both globals need a call or a read at start-up, so they have dynamic initialisation. Within one file, the order is the order of definition. Across files the standard leaves it indeterminately sequenced, so the order is up to the implementation, and with g++ and GNU ld here it followed the link line.
Before any dynamic initialisation runs, all globals are zero-initialised. So when limits runs first, it reads base_rate as 0, and computes . Nothing is uninitialised, no sanitizer complains, and the risk limit is simply zero. Later base_rate becomes 100, as it should, and the wrong value stays in limit. It can get worse: a change in a build script far from either file can flip it.
The fix turns the global into a function:
long& base_rate() { static long v = compute_base(); return v; }
The first caller triggers the initialisation, whichever file it is in, and the test prints 1000 for both link orders.
constinit is the other tool, new in C++20. It requires that a variable be initialised at compile time, and the standard makes the program ill-formed if it has dynamic initialisation. constinit long limit = base_rate * 10; fails to compile, which is what you want: it turns a silent ordering bet into an error. It does not fix anything by itself. It tells you which variables to rewrite.
The most robust design has no such globals. Read the configuration once in main, build the objects in order, and pass them where they are needed. That makes the order a line of code that you can read.
Classic wrong answer. “Put limits.o after rates.o and leave a comment.” It works on this toolchain, and the standard promises nothing. A static library or a new linker can change it.
Follow-up. Is the function-local version safe with threads? Yes, the initialisation is thread-safe since C++11. There is one remaining catch: destruction order at exit, if another global’s destructor uses it after it has gone. Here it is a long, so this does not arise.
Why it matters. Limits and configuration held in globals are real, and a zero limit found in production is a very bad day.
Question 9.34 ★★★ LLE, QD, Dev • market maker, hedge fund • experienced
Listing 9.35 shows five ways to call score. Compiled with g++ 11.4 and its standard library, -std=c++20 -O2, on x86-64: which of the five functions contain an indirect call or jump? Which is the surprise? Beyond speed, what decides between them when you design a strategy engine?
struct Signal {
virtual ~Signal() = default;
virtual long score(long x) const = 0;
};
struct Momentum final : Signal {
long score(long x) const override { return 2 * x; }
};
struct Carry final : Signal {
long score(long x) const override { return x + 1; }
};
using Any = std::variant<Momentum, Carry>;
long by_virtual(const Signal& s, long x) { return s.score(x); }
long by_final(const Momentum& s, long x) { return s.score(x); }
long by_variant(const Any& a, long x) {
return std::visit([x](const auto& s) { return s.score(x); }, a);
}
long by_switch(const Any& a, long x) {
if (const auto* m = std::get_if<Momentum>(&a)) return m->score(x);
return std::get<Carry>(a).score(x);
}
template <class S> long by_template(const S& s, long x) { return s.score(x); }
template long by_template<Momentum>(const Momentum&, long);
Answer
Answer to Question 9.34.
by_virtual and by_variant contain an indirect call or jump. by_final and by_template inline to two lines (plus an endbr64 on Ubuntu’s default flags), and by_switch is compares and branches, with no indirect call. The surprise is by_variant: many people assume std::visit avoids an indirect call.
by_virtual loads the virtual table and ends in jmp *: the compiler cannot know the type. by_final takes a Momentum, which is final, so there is no override to look for, and it inlines. by_template knows the type at compile time, and the test finds identical code to by_final.
On libstdc++ 11, std::visit builds a table of function pointers indexed by the alternative, then uses call *(%rdx,%rax,8). The test reads this out of the assembly. So in this version it is a virtual call in other clothes, plus a check for the valueless state. by_switch tests the index with std::get_if, and everything inlines into two compares and two branches, with a cold path that throws. A later library may compile std::visit differently, so read your own compiler’s output, as this test does, before believing either claim.
For speed, the instruction itself is rarely the main cost. The larger loss is that the compiler cannot inline through an indirect call or optimise across it. Measure it in place.
Other things usually decide the design:
Who can add a type? If strategies arrive from other teams and plugins, the set is open, and virtual is the right tool. Adding a type then touches no existing code. If the set is closed and known, a variant lets the compiler check that every operation handles every type.
Where does it live? A variant is as big as its largest alternative, stored in place with no allocation. That suits a vector of messages. Virtual objects mostly need the heap, and pointer chasing costs more than the call.
Templates give the best code when the type is fixed at build time. But they spread through headers, slow the build and cannot be chosen by a configuration file at run time.
Classic wrong answer. “Variants are always faster than virtual calls.” As this compiler shows, it is not even a different kind of call.
Why it matters. Low-latency interviews ask this to see whether you reach for the assembly, and whether you can argue the design beyond the nanoseconds.
Question 9.35 ★★★ QD, Dev, QT • market maker, hedge fund, crypto • experienced
Listing 9.36 models what a position keeper does across a reconnect, when the gateway resends the last fill. What does it print, and what should it print? Redesign on_fill so a resend is harmless. What must your design do when a fill is missing rather than repeated? How do you test it?
class Position:
def __init__(self):
self.qty = 0
def on_fill(self, fill):
self.qty += fill["qty"] if fill["side"] == "B" else -fill["qty"]
fills = [{"seq": 1, "side": "B", "qty": 100}, {"seq": 2, "side": "S", "qty": 30}]
p = Position()
for f in fills:
p.on_fill(f)
for f in fills[-1:]: # the connection dropped; on reconnect the last fill is resent
p.on_fill(f)
print(p.qty)
Answer
Answer to Question 9.35.
It prints 40. It should print 70, since the resend is the same fill. Make on_fill idempotent by remembering the highest sequence number applied per session, and skip anything at or below it. A gap must stop the keeper.
The fault is that on_fill treats every message as a new event. On any connection that resends after a reconnect, delivery is at least once, not exactly once, so the receiver must make repeats harmless. Listing 9.43 has the shape.
class Position:
def __init__(self):
self.qty = 0
self.last_seq = {} # session -> the highest sequence number applied
def on_fill(self, session, fill):
if fill["seq"] <= self.last_seq.get(session, 0):
return # already applied: a resend changes nothing
if fill["seq"] != self.last_seq.get(session, 0) + 1:
raise RuntimeError("gap: stop and recover before going on")
self.qty += fill["qty"] if fill["side"] == "B" else -fill["qty"]
self.last_seq[session] = fill["seq"]
Three decisions matter here. First, the sequence is per session, so the state is a map, and the test confirms that two sessions do not interfere. Second, a gap raises an error. Silently applying fill 4 after fill 2 hides a missing fill, and the position is then wrong in a way nobody notices. A trading system should stop quoting and ask for the missing range. Third, the check and the update go together, so the state moves only when a fill is applied.
A high-water mark needs the fills to arrive in order on each session, and it stores one number. If order is not guaranteed, or sequence numbers restart, key on a unique execution id instead and remember the ids you have seen. That set grows, so it needs trimming, say at the end of the session. Whichever you use, the mark has to survive a restart along with the position. Save both together, in one atomic write, or rebuild both from the log. If you save the position without the mark, the first resend after a restart counts twice again.
For the tests, take a random fill sequence and deliver it with random resends of the last few messages between new ones. The final position must equal the plain sum of the fills, whatever the resend pattern. The test runs that 200 times. Add a test for a gap, and a test that restarts in the middle.
Classic wrong answer. “Reconcile with the exchange at the end of the day.” You should, but a position that is wrong for hours can breach a limit in the meantime, in either direction.
Why it matters. Reconnects happen daily, and a double-counted fill means you trade on a wrong position. This is one of the most common real faults in a position keeper.
Question 9.36 ★★★ QD, Dev, QR, QT • hedge fund, market maker • experienced
You want the strategy in Listing 9.37 to run unchanged in live trading and in a backtest. You replay five ticks, 2 seconds apart in market time, as fast as the machine can, all above the level. How many orders does it send? How many should it send? Redesign it. What else, beyond the clock, stops a backtest from repeating itself exactly?
import time
class Throttled:
"""Sends at most one order per second, on every tick above a level."""
def __init__(self, gateway, level):
self.gateway, self.level = gateway, level
self.last = 0.0
def on_tick(self, price):
now = time.time()
if price > self.level and now - self.last >= 1.0:
self.last = now
self.gateway.send(price)
Answer
Answer to Question 9.36.
It sends one order. It should send five, since the ticks are 2 seconds apart in market time. The strategy reads the machine’s clock. Inject the clock and the gateway, so the strategy never finds out which world it is in.
time.time() is hidden input. During a replay, all five ticks arrive within microseconds of wall-clock time, so the 1-second throttle blocks the other four. Live, the same code sends all five. A backtest of this would be wrong in a way that is hard to see, because it runs fine and gives numbers. It would also change with the speed of the machine.
Listing 9.44 has the same logic, except that it asks self.clock.now(), and it sends through whatever gateway it is given. In a backtest, the replay loop sets the simulated clock to each event’s timestamp before delivering the event. Live, the clock wraps the system clock and the gateway sends real orders. The test replays the same ticks and gets all five orders, twice, identically.
class Throttled:
"""The same logic: the time and the way out are given to it."""
def __init__(self, clock, gateway, level):
self.clock, self.gateway, self.level = clock, gateway, level
self.last = float("-inf")
def on_tick(self, price):
now = self.clock.now()
if price > self.level and now - self.last >= 1.0:
self.last = now
self.gateway.send(price)
class SimClock:
"""In a backtest the replay loop moves this clock to each event's timestamp."""
def __init__(self):
self.t = 0.0
def now(self):
return self.t
Note the fixed version’s start value too: self.last is minus infinity. In the original it was 0.0, which works only because time.time() is a large number. A simulated clock starting at 0 would have blocked the first order, and that bug appears only with the new design.
Other hidden inputs break repetition: random numbers without a saved seed; iteration over a Python set, whose order for strings changes between runs; threads, whose interleaving changes the order of events; reading the current date, or a file that changes, or a price from a live service. Order of events with equal timestamps needs a stable rule too.
The test of the whole design is simple: run the same input twice and compare every output, byte for byte. Make that a standing test. In C++, the injection can be a template parameter, so the live path pays nothing for it.
Classic wrong answer. “Sleep in the replay so that time passes correctly.” Then a year of data takes a year, and it is still not deterministic.
Why it matters. “The same code in research and production” is a goal at nearly every firm. Time is where it usually fails first.
Question 9.37 ★★★ QD, Dev, QR • market maker, hedge fund • experienced
The two tests in Listing 9.38 pass, and Book still has a bug. Without reading for it, write a random test that finds it: what does it generate, what does it check after each step, and how does it give you a short failing case rather than a list of thirty operations? What does the short case come out as? What is the bug?
class Book:
def __init__(self):
self.levels = {} # price -> total quantity
self.orders = {} # id -> (price, qty)
def add(self, oid, price, qty):
self.orders[oid] = (price, qty)
self.levels[price] = self.levels.get(price, 0) + qty
def cancel(self, oid):
price, qty = self.orders.pop(oid)
self.levels[price] -= qty
def best(self):
return max(self.levels) if self.levels else None
def test_add_then_best():
b = Book()
b.add(1, 100, 5)
b.add(2, 101, 5)
assert b.best() == 101
def test_cancel_lower():
b = Book()
b.add(1, 100, 5)
b.add(2, 101, 5)
b.cancel(1)
assert b.best() == 101
Answer
Answer to Question 9.37.
Generate random adds and cancels, keep a plain model of the live orders beside the book, check invariants after every step, and shrink a failure by deleting operations. It comes down to two operations: add order 5 at price 100, then cancel order 5. The bug: cancel leaves a level at quantity 0, so best returns a price nobody is quoting.
Listing 9.45 is all of it, in plain Python with random. It uses no library. Each run draws 30 operations over a small range of ids and prices, because small ranges make collisions likely, and collisions are where bugs live. The model is a dict of live orders. It is simple enough to be obviously right, and that is the point. After each step, two facts must hold: the best price equals the highest price among live orders, and the sum over the levels equals the sum over the live orders.
def check(make_book, runs=500, steps=30, seed=1):
"""Random operations against the plain truth. Returns a short failing list, or None."""
rng = random.Random(seed)
for _ in range(runs):
ops = [(rng.choice("ac"), rng.randint(1, 5), rng.randint(99, 101), rng.randint(1, 9))
for _ in range(steps)]
if failure(make_book, ops):
return shrink(make_book, ops)
return None
def failure(make_book, ops):
"""Run the operations and check every invariant after each one. True if any fails."""
book, live = make_book(), {}
for kind, oid, price, qty in ops:
if kind == "a" and oid not in live:
book.add(oid, price, qty)
live[oid] = (price, qty)
elif kind == "c" and oid in live:
book.cancel(oid)
del live[oid]
if book.best() != max((p for p, _ in live.values()), default=None):
return True
if sum(book.levels.values()) != sum(q for _, q in live.values()):
return True
return False
def shrink(make_book, ops):
"""Drop operations one at a time for as long as the failure survives."""
i = 0
while i < len(ops):
shorter = ops[:i] + ops[i + 1:]
if failure(make_book, shorter):
ops = shorter
else:
i += 1
return ops
Shrinking is simple too: try removing each operation in turn, and keep the removal if the failure survives. That turned 30 operations into the 2 quoted above, which you can read in one glance. Fixing it means deleting the level when it reaches zero. After that, 1000 runs find nothing. The seed is fixed, so a failure can always be replayed.
The generator skips adds of a live id, so it says nothing about that case, and Book mishandles it: add(1, 100, 5), add(1, 101, 5) leaves 5 lots stranded at 100. Either reject a duplicate id, or let the test generate one and specify the result.
Why did the unit tests miss it? test_cancel_lower cancels the lower of two prices, and only the best is checked, so an empty lower level does not show. Its author tested what they had thought of. A random test has no such preference.
The hard part is choosing the invariants, not generating the inputs. Good ones: compare with a slow model, check that a round trip restores the state, and check conservation of quantity. A library such as Hypothesis does the generation and shrinking better for you, and a firm that already has it should use it. In an interview, showing that you can write the 30-line version counts for more.
Classic wrong answer. “Add a test for cancelling the best.” That catches this bug, and it is good to keep as a regression test. It does nothing for the next one you did not think of.
Why it matters. Order books have many states and few simple inputs, which is the ideal case for random testing. A candidate who proposes it unprompted stands out.
Question 9.38 ★★★ Dev, QD, LLE • market maker, hedge fund, bank • experienced
A binary message on your internal bus is read by six services and written by two. You need to add a field and retire another, and you cannot stop everything at once. How do you roll this out, in order? Which rules must the format follow, and which tests do you add? You also keep years of recorded messages: what does that change?
Answer
Answer to Question 9.38.
Add the field as optional with a default, and upgrade every reader first, then the writers. Retire the old field in the reverse order: stop all readers from needing it, stop the writers from sending it, and then mark its number as never to be reused. Keep decoders for old recordings for as long as you keep the recordings.
Order matters because you cannot flip everything at once, so at each step every reader must handle every message then on the bus. Rollout for adding:
- Change the schema, and give the new field a default that means “not known”.
- Deploy all six readers. They accept old and new messages, and use the default when the field is absent.
- Deploy the writers, which start filling the field.
- Only then write logic that relies on the field being there.
For removal, run it backwards: readers stop using the field, then writers stop sending it, then the schema marks it retired.
The format must let an old reader skip what it does not know, and a new reader find out what is absent. A tagged format gives you this directly. The Protobuf documentation states that old binaries simply ignore a new field when parsing, and that removing a field is safe as long as its number is not used again. That is the rule that matters: reusing a number makes decoding ambiguous, and its best-practice guide says to reserve deleted numbers and never to add a required field.
Formats with a fixed layout, common on latency-sensitive paths, need more care. A field can only be added at the end of a block, because anywhere else it moves the offset of every later field. The message header then needs a version, or a length so that readers can skip what they do not understand. The field’s meaning and units are part of the contract too. Changing cents to ticks under the same field is the worst case, since every decoder keeps working and every number is wrong. Add a new field for that instead.
Tests to add. Keep a set of recorded messages from each past version in the repository, and decode them in every build. Have the build compare the schema with the previous one, and fail on an incompatible change. During the rollout, run old and new decoders side by side on live traffic and compare their outputs.
Recorded data changes the time horizon. A replay in five years must still read today’s messages. So schemas are never deleted, the version travels with the data, and a change that seems safe for live traffic can still break research.
Classic wrong answer. “Bump a version and deploy everything over the weekend.” Recorded data still has the old version, a rollback brings it back, and six teams are rarely ready at the same moment.
Why it matters. Firms live with their formats for years. Knowing the order of the rollout separates people who have done this from people who have only read about it.
Question 9.39 ★★★★ QD, Dev, LLE • market maker, hedge fund • experienced
Listing 9.39 is one side of an order book, written for a take-home test. It is used for the bid side and the ask side. You have ten minutes to review it. There are at least three bugs. For each, give the smallest sequence of calls that shows it, and say what you would run to see it. Which of them does AddressSanitizer miss? Then say what you would change in the design.
struct Order { long id; long qty; };
class Side { // used for both the bid side and the ask side
public:
void add(long price, long id, long qty) {
auto& level = levels_[price];
level.push_back({id, qty});
index_[id] = &level.back();
}
bool cancel(long id) {
auto it = index_.find(id);
if (it == index_.end()) return false;
it->second->qty = 0;
index_.erase(it);
return true;
}
long take(long qty) { // best price first, oldest first
long filled = 0;
for (auto& [price, level] : levels_) {
for (auto& o : level) {
const long n = std::min(o.qty, qty - filled);
o.qty -= n;
filled += n;
if (n > 0 && o.qty == 0) index_.erase(o.id);
}
if (spent(level)) levels_.erase(price);
if (filled == qty) break;
}
return filled;
}
bool empty() const { return levels_.empty(); }
long best() const { return levels_.begin()->first; }
private:
static bool spent(const std::vector<Order>& level) {
return std::ranges::all_of(level, [](const Order& o) { return o.qty == 0; });
}
std::map<long, std::vector<Order>> levels_;
std::unordered_map<long, Order*> index_;
};
Answer
Answer to Question 9.39.
There are four bugs: a dangling pointer after a second order at a level, an erase inside a range-for, the wrong end of the book for bids, and a cancel that leaves its level behind. ASan catches only the first. The second is a memory bug that it misses, and the last two are logic that no memory tool can see.
Dangling pointer. index_ stores &level.back(), a pointer into a std::vector, and the next push_back at that level may reallocate. Sequence: add(100, 1, 5), add(100, 2, 5), cancel(1). ASan reports heap-use-after-free in cancel.
Erase inside the loop. take erases the level from levels_ inside the range-for that walks levels_, so the loop’s iterator is invalid when it steps. Sequence: add(100, 1, 5), add(101, 2, 5), take(7). Here ASan says nothing: the step is in _Rb_tree_increment, compiled inside the uninstrumented libstdc++.so, and the freed node still holds good pointers. It prints the right fill. -D_GLIBCXX_DEBUG stops it, with a message about a singular iterator. So one tool is never enough.
Wrong side. The map orders prices upward, so begin() is the lowest. For asks that is the best. For bids it is the worst. Sequence on a bid side: add(100, 1, 5), add(101, 2, 5), take(3). It fills at 100 and not at the bid of 101. No tool finds this: only a test with an expected answer does. The comment on the class is the clue.
Stale level. cancel sets the quantity to zero and leaves the entry. After add(100, 1, 5), cancel(1), best() returns 100 with nothing there. A take erases each spent level it visits, so empty entries pile up only deeper in the book, where no take reaches, and there they use memory for good.
Listing 9.46 fixes all four. A std::list per level keeps iterators stable. Each index entry stores its price and iterator, so a cancel is plus the level lookup. The side is a template parameter, so a bid side uses std::greater. take advances with the iterator that erase returns. The test checks it under ASan on 30 random scripts for each side, against a slow Python model.
template <class Better> // std::less for asks, std::greater for bids
class Side {
public:
void add(long price, long id, long qty) {
auto& level = levels_[price];
level.push_back({id, qty});
index_[id] = {price, std::prev(level.end())}; // list iterators survive growth
}
bool cancel(long id) {
auto it = index_.find(id);
if (it == index_.end()) return false;
auto level = levels_.find(it->second.price);
level->second.erase(it->second.where);
if (level->second.empty()) levels_.erase(level);
index_.erase(it);
return true;
}
long take(long qty) {
long filled = 0;
for (auto it = levels_.begin(); it != levels_.end() && filled < qty;) {
auto& level = it->second;
while (!level.empty() && filled < qty) {
Order& o = level.front();
const long n = std::min(o.qty, qty - filled);
o.qty -= n;
filled += n;
if (o.qty == 0) { index_.erase(o.id); level.pop_front(); }
}
it = level.empty() ? levels_.erase(it) : std::next(it);
}
return filled;
}
bool empty() const { return levels_.empty(); }
long best() const { return levels_.begin()->first; }
private:
struct Where { long price; std::list<Order>::iterator where; };
std::map<long, std::list<Order>, Better> levels_;
std::unordered_map<long, Where> index_;
};
On design, I would say four more things. A long for both price and quantity allows them to be swapped, so use types. There is no check on duplicate ids or non-positive quantities. A list allocates on every order, so a hot path would use a pool or an intrusive list. And a real book needs an interface for a fill’s details, not only how much was filled.
Classic wrong answer. “It passes under ASan, so the memory handling is right.” The second bug passes under ASan on every input you try. Each tool sees only what it instruments.
Why it matters. This is the take-home review in its purest form. Interviewers count bugs, but they also watch whether you can show each one with a short sequence.
Question 9.40 ★★★★ QD, Dev, QR, DS • any • junior, experienced
A candidate’s take-home answer is Listing 9.40: it reads a CSV of trades with columns symbol, price and qty, and reports the volume-weighted average price per symbol. It gives the right numbers on the sample file. You are reviewing it. List what is wrong, most serious first, and for each say what input shows it. Then sketch the version you would accept and the tests that go with it.
import csv
totals = {}
def load(path, symbols=[]):
f = open(path)
for row in csv.DictReader(f):
if row["symbol"] in symbols or not symbols:
key = row["symbol"]
if key not in totals:
totals[key] = [0.0, 0]
totals[key][0] += float(row["price"]) * int(row["qty"])
totals[key][1] += int(row["qty"])
return totals
def vwap(symbol):
notional, qty = totals[symbol]
return notional / qty
def report(path):
for symbol in load(path):
print(symbol, round(vwap(symbol), 2))
Answer
Answer to Question 9.40.
Most serious first: a hidden global that mixes files, one bad row killing the whole run, a crash on zero quantity, floats for money, and a file never closed. The mutable default looks bad, but it is harmless here. Accept a pure function over parsed trades, plus a thin file wrapper.
Global state. totals survives between calls. Load day 1 (10 at 100 and 30 at 102, VWAP 101.5), then day 2 (10 at 200), and vwap("AAA") gives 121.2, mixing the two days. No error appears. This is the worst fault, because the number looks plausible. It also means the function cannot be tested without clearing a global first.
No handling of bad rows. One row with price equal to oops raises ValueError, and the file is lost at that row. So a single bad print loses a day. Worse, the earlier rows are already in totals, so a caller who catches the error sees partial totals. Decide on a policy: count and report the bad rows.
Zero quantity. Rows of +10 and -10, as in a correction, give ZeroDivisionError in vwap.
Floats. sum([0.1] * 10) is not 1.0, so small errors build up over millions of rows, and then round adds half-to-even behaviour in the report. Prices arrive as exact text, so keep them exact, as Fraction, Decimal or integer ticks.
The file is never closed. open has no with. CPython closes the file when the object is collected, and raises a ResourceWarning, as the test confirms. Nothing in the language promises when that happens. The file handle is a resource, so say when it ends.
Mutable default. symbols=[] is never changed, so it does no harm here, and saying so shows you read it rather than matched a pattern. Still, ask for None or a tuple. in on a list is also linear, so a set would be better. The way it is written, an empty list means “everything”, which is a surprising rule to leave undocumented.
Structure. load opens, parses, filters and accumulates, and report prints. Nothing can be used alone.
Listing 9.47 separates these: parse turns rows into trades and collects errors, vwaps is a pure function, and report only handles the file. The tests cover both days giving their own answers, bad rows counted with their row numbers, whether a word for a price, a short row or a price of 1/0, None for zero quantity, ten trades at 0.1 averaging exactly 0.1, and, on random trades, that shuffling the input and calling a second time change nothing.
import csv
from collections.abc import Iterable, Iterator
from fractions import Fraction
from typing import NamedTuple
class Trade(NamedTuple):
symbol: str
price: Fraction
qty: int
def parse(rows: Iterable[dict], errors: list) -> Iterator[Trade]:
for n, row in enumerate(rows, 1):
try:
yield Trade(row["symbol"], Fraction(row["price"]), int(row["qty"]))
except (KeyError, ValueError, TypeError, ZeroDivisionError) as e:
errors.append((n, repr(e)))
def vwaps(trades: Iterable[Trade]) -> dict:
"""A pure function: same trades in, same numbers out, and no hidden state."""
totals = {}
for t in trades:
notional, qty = totals.get(t.symbol, (Fraction(0), 0))
totals[t.symbol] = (notional + t.price * t.qty, qty + t.qty)
return {s: (n / q if q else None) for s, (n, q) in totals.items()}
def report(path: str) -> tuple[dict, list]:
errors = []
with open(path, newline="") as f:
return vwaps(parse(csv.DictReader(f), errors)), errors
The fixed version keeps one running pair per symbol, so memory stays small. It does not check for duplicate trades, and in an interview I would ask what the file guarantees before adding that. A NUL byte still stops the csv reader itself on Python 3.10: treat that as a corrupt file, not a bad row.
Classic wrong answer. “Leading with the mutable default.” It is the first thing a pattern-matcher sees and the least harmful thing here. Order your review by what can cost money.
Why it matters. Reviewing is a large part of the job, and a take-home review tests judgement: what you put first, and whether you can show each problem with an input.