Quant developer HFT course · Module 2: Safe C++
When an object's life begins and ends
The question
This function returns a reference to the best price level. It compiles without a warning and works in the unit test. Why does it crash in production?
The function
const Level& best_bid(const Instrument& ins) {
Book book = load_book(ins); // a local copy of the book
return book.bids.front(); // reference into a local
}
The reference points into book, which is destroyed when the function returns. The caller receives a reference to memory that no longer holds a Level. In a unit test the stack frame may not have been reused yet, so reading through the reference happens to return the old values. In production something else is called in between, the stack is overwritten, and the read returns garbage or crashes. Undefined behaviour that works in tests is the most expensive kind.
Most C++ memory bugs in interviews are of this shape: something holds a reference, pointer, iterator or view to an object whose lifetime has ended. To find them you need precise rules for when lifetimes end.
The rules you need
Automatic objects (locals) are constructed when execution reaches their definition and destroyed at the end of the enclosing block, in reverse order of construction. That order is guaranteed and code relies on it: a lock taken first is released last.
Members are constructed in the order they are declared in the class, not the order they appear in the constructor's initialiser list, and destroyed in reverse. A member initialised from another member that is declared after it reads an unconstructed object; compilers warn about the mismatched order with -Wreorder.
Dynamic objects live from new (or the function that calls it for you) to delete. Smart pointers tie that span to the life of an owner, which is the subject of the next two lessons.
Temporaries live until the end of the full expression that created them, which is roughly the end of the statement. This is the rule that catches people:
const std::string& name = get_instrument().name; // fine: member of a temporary, bound directly: extended
const Level& lvl = get_book().bids.front(); // dangling: front() returns a reference,
// the temporary Book dies at the semicolonLifetime extension, and where it stops
Binding a temporary directly to a const& or && local extends the temporary's life to that of the reference. That is why const std::string& s = make_string(); is safe.
The extension applies only when the reference binds to the temporary itself, or to a subobject reached by member access. It does not pass through a function call. get_book().bids.front() calls front(), which returns a reference the compiler cannot trace back to the temporary, so nothing is extended and the Book dies at the semicolon. The same trap appears in range-for loops: for (auto& l : get_book().bids) is fine, because the range expression is bound to a reference, but before C++23 for (auto& l : get_book().bids_view()) dangles if bids_view() returns a view into the temporary. C++23 extended the lifetime of temporaries in the range-for initialiser; older compilers and codebases still have the bug.
Spotting it in code review
Ask, for every reference, pointer, iterator or view: who owns the object it refers to, and is that owner guaranteed to outlive it? Patterns to flag on sight:
- Returning a reference or pointer to a local, or to a member of a local.
- Storing a reference or view in a member, where the referent is a function argument.
- Taking
.front(),.data(),.c_str(),begin()or a view of a temporary. - Capturing locals by reference in a lambda that runs later (lesson 6 of this module).
- Keeping pointers into a container that can reallocate (lesson 7).
The tools that catch these at run time are AddressSanitizer (-fsanitize=address), which reports use-after-free and use-after-scope with the line that freed the memory, and recent compilers' dangling warnings such as -Wdangling-reference in GCC 13. They are worth naming in an interview: they are how a team stops these bugs reaching production.
The fix
Return the level by value. A Level is a price and a quantity, two machine words, cheaper to copy than to reason about. If the caller genuinely needs to refer to the live level in a long-lived book, then the book must be owned by something that outlives the reference, and the function should take the book by reference rather than build a local copy.
Check yourself
Question 1 of 3
In what order are the members of struct S { A a; B b; S() : b(), a() {} }; constructed?
Declaration order always wins. Compilers warn about initialiser lists written in a different order because a member initialised from a later one would read an unconstructed object.
Question 2 of 3
Which line leaves r dangling? Assume make_book() returns a Book by value and top() returns const Level&.
Binding make_book() directly to a const reference extends its life. Calling top() on the temporary returns a reference that does not extend anything, so the book dies at the semicolon. The value forms copy.
Question 3 of 3
A function returns a reference to a local and the unit test passes. Why?
Nothing in the language makes this work. The test passed by luck: the frame had not yet been reused. AddressSanitizer's use-after-return checking would catch it.
In the interview
Say your own answer out loud first, then open the one we would give.
When is a temporary destroyed?
At the end of the full expression that created it, normally the end of the statement. The exception is when it is bound directly to a const lvalue reference or an rvalue reference, which extends it to the reference's lifetime. That extension does not pass through function calls, so a reference returned by a method of a temporary dangles.
How do you find dangling references in a large codebase?
Review for the patterns: references or views stored beyond the call, references into temporaries, pointers into containers that can grow. Then rely on tools: AddressSanitizer in the test suite catches use-after-free and use-after-scope with stack traces, and modern compilers warn on some dangling cases directly.
In what order are members destroyed, and why does it matter?
In reverse declaration order. It matters when one member uses another in its destructor, for example a worker thread that reads a queue: the queue must be declared before the thread so the thread is stopped and joined before the queue is destroyed.