This page is for anyone facing a C++ round, from a first job to a senior systems or backend role. Most C++ interviews start with pointers, references and const, move to object lifetime, RAII and the rule of five, then test smart pointers, move semantics, virtual functions, templates and STL containers. Senior rounds add a debugging story and a judgement call about ownership or exceptions. Each question shows what the interviewer is really checking, the shape of a strong answer and a short answer you can say out loud. Practise saying them, then swap in your own stories.
Search all questions by round, difficulty and level, or save the ones you want to practise.
Reference: an alias; must be bound when created, cannot be reseated, and has no null value in valid code.
Pointer: a variable holding an address; can be null, can point somewhere else later, supports arithmetic.
Choosing: reference when the argument must exist, pointer when it is optional or can change target.
"A reference is another name for an existing object. It has to be bound when it's declared, it can't be made to refer to something else later, and in valid code there's no such thing as a null reference. A pointer is its own variable that holds an address, so it can be null, it can be pointed at a different object, and you can do arithmetic on it. In function signatures I use a const reference for inputs that must exist and are too big to copy, a plain reference when the function has to modify the caller's object, and a pointer only when 'nothing' is a valid answer, like an optional output. For ownership I don't use raw pointers at all, I use smart pointers, so a raw pointer or reference in my code just means 'I'm borrowing this'."
void scale(Matrix& m, double k); // must exist, will be changed
double norm(const Matrix& m); // must exist, read only
bool find(const Tree& t, int key, Node** out = nullptr); // optional output
Saying references are just pointers with nicer syntax and can be reassigned, or using raw pointers to signal ownership.
Pointer to const: const int* p means you cannot change the int through p, but p can move.
Const pointer: int* const p means p always points to the same place, but the int can change.
Const member function: promises not to change the object, so it can be called on const objects and through const references.
"The trick is to read it right to left. const int* p is a pointer to a const int: I can make p point somewhere else, but I can't change the value through it. int* const p is a const pointer to an int: p is stuck on one address, but I can change the value. A member function with const after the parameter list promises not to change the object's state, and that matters a lot in practice, because if I pass an object by const reference, which I do all the time, I can only call its const member functions. So if someone forgets to mark a getter const, it can't be used on const objects. If a member really needs to change inside a const function, like a cache or a mutex, it gets marked mutable."
class Account {
public:
int balance() const { return balance_; } // callable on const Account&
void deposit(int amount) { balance_ += amount; }
private:
int balance_ = 0;
};
void print(const Account& a) {
std::cout << a.balance(); // fine
// a.deposit(10); // compile error: not a const function
}
Mixing up pointer-to-const and const pointer, or treating const as a style choice with no effect on what can be called.
Basic: after an exception, nothing leaks and objects are in a valid, though possibly changed, state.
Strong: the operation either fully succeeds or has no effect, like a transaction.
No-throw: the operation never throws; destructors, swaps and moves should aim for this.
Destructors: they are noexcept by default; a throw escaping one calls std::terminate.
"The basic guarantee says that if an operation throws, nothing leaks and every object is still valid, though the state may have changed. The strong guarantee says it either succeeds completely or leaves everything exactly as it was, like a transaction. Copy-and-swap is the usual way to get it: do the risky work on a copy, then swap it in with an operation that can't throw. The no-throw guarantee means the operation never throws, and that's what destructors, swap and move operations should give, because the other guarantees are built on them. Destructors are implicitly noexcept since C++11, so if an exception escapes one, std::terminate is called. Even without that, a destructor that throws while another exception is already unwinding the stack terminates the program. So if cleanup can fail, like a flush on close, I offer an explicit close function that can report errors, and the destructor only does a best effort and swallows or logs."
Saying it is fine to throw from a destructor as long as someone catches it.
Closure: a lambda creates an object; captured variables become its members, copied or referenced.
By value vs reference: [x] copies at creation time; [&x] stores a reference that must stay valid.
Danger: a lambda that outlives its scope, stored as a callback or run on a thread, must not hold references to locals.
"A lambda expression creates an unnamed object, a closure, and each captured variable becomes a member of it. Capture by value copies the variable when the lambda is created. Capture by reference stores a reference, so it sees later changes but depends on the original still being alive. The bug happens when the lambda outlives the scope it was made in. If a function returns a lambda that captured a local by reference, or hands it to a thread or an event queue, the local is gone by the time the lambda runs, and it reads a dangling reference. That's undefined behaviour and often shows up as random values. Capturing this has the same risk if the object is destroyed first. So when a lambda escapes its scope I capture by value, use an init capture to move things in, or capture a shared_ptr or weak_ptr to keep the object alive or check it."
std::function<int()> makeCounter() {
int count = 0;
// return [&count] { return ++count; }; // dangling: count dies here
return [count]() mutable { return ++count; }; // owns its own copy
}
auto job = [data = std::move(bigVector)] { process(data); }; // init capture
Using [&] for a callback that runs later without thinking about the lifetime of what it captures.
Problem: catch(...) everywhere hides bugs and lets the program continue in a broken state.
Right place: catch at boundaries that can recover or report, like a request handler, a thread entry point or main.
Root cause: fix what threw, and use RAII so unwinding leaves state clean.
"I understand the reaction, a crash in production is painful. But catching everything in every function and carrying on is usually worse than the crash. The code after a swallowed exception runs on state that may be half updated, so we trade a loud crash for silent wrong results, and we lose the stack context that tells us what happened. I'd suggest two things. First, find out what actually threw and fix that. Second, catch at the few boundaries where we can really do something: the top of each request handler, where we can log with context and return an error, each thread's entry function, since an exception escaping a thread calls std::terminate, and main. Inside the code we rely on RAII so unwinding leaves everything clean, and we only catch specific exceptions where there's a real recovery, like retrying a network call."
Agreeing to catch everything and continue, or saying exceptions should never be caught.
Automatic: a local object is destroyed at the end of its scope, with no code needed from you.
Dynamic: an object made with new lives until someone calls delete; forget it and you leak.
Matching: new pairs with delete, new[] with delete[]; mixing them is undefined behaviour.
"A local object has automatic lifetime. It's created where it's declared and its destructor runs when it goes out of scope, whether the function returns normally or an exception leaves it. An object created with new has dynamic lifetime: it lives on the free store until someone calls delete on it, and if nobody does, that's a leak. The pairing rule is strict. new goes with delete and new[] goes with delete[], because the array form has to run the destructor for every element and may track the count differently. Mixing them is undefined behaviour, which can mean a crash, heap corruption, or silently skipping destructors. In modern code I rarely write new or delete at all. I use locals where I can, std::vector instead of new[], and make_unique when I really need heap lifetime."
Saying delete and delete[] are interchangeable for simple types, or that the operating system cleans up leaks so they do not matter.
Idea: an object acquires a resource in its constructor and releases it in its destructor.
Guarantee: destructors run on every exit from a scope, including when an exception unwinds the stack.
Examples: unique_ptr for memory, lock_guard for mutexes, fstream for files.
"RAII means tying a resource's lifetime to an object's lifetime. The constructor acquires the resource, the destructor releases it, and because C++ guarantees destructors run when an object goes out of scope, including during stack unwinding from an exception, the cleanup can't be forgotten. It's not just memory. A lock_guard locks a mutex in its constructor and unlocks it in its destructor, so an early return or an exception can't leave the mutex locked. An fstream closes its file, a unique_ptr frees its object. Compared to manual cleanup, the big win is that correctness doesn't depend on every code path remembering to release things. Add a new early return next year and it's still correct. When I write a class that wraps a handle from a C library, I make it an RAII wrapper so the rest of the code never sees the raw close call."
void transfer(Account& from, Account& to, int amount) {
std::lock_guard<std::mutex> lock(ledgerMutex); // locked here
if (amount <= 0) return; // unlocked here
from.withdraw(amount); // if this throws, unwinding unlocks
to.deposit(amount);
} // and unlocked here
Describing RAII as only about memory, or not knowing that destructors run during exception unwinding.
Suspect: undefined behaviour that the optimiser exploits, such as uninitialised variables, out-of-bounds access, use-after-free or data races.
Tools: build with sanitizers, turn up warnings, and debug the release build with symbols.
Narrow: reproduce reliably, then bisect with a smaller input or recent commits.
"My first suspicion is undefined behaviour, not a compiler bug. Debug builds often hide it: memory gets filled with patterns, variables stay on the stack, and code runs in the order it's written. The optimiser is allowed to assume undefined behaviour never happens, so things like an uninitialised variable, reading past the end of an array, using freed memory, signed overflow in a loop or a data race between threads can suddenly break. So I'd get a reliable reproduction, then build with optimisation plus AddressSanitizer and UndefinedBehaviorSanitizer, and ThreadSanitizer if threads are involved. I'd turn warnings up and read them. I'd also build the release configuration with debug symbols so I can see a real stack trace from the crash. If that still doesn't find it, I'd bisect recent commits. Only after all of that would I suspect the compiler itself, and I'd want a tiny reproducer before claiming it."
Blaming the compiler first, or shipping the debug build to production to make the problem go away.
Initialiser list: builds each member directly with its value, before the constructor body runs.
Required for: const members, reference members, and members or bases with no default constructor.
Order: members are initialised in the order they are declared in the class, not the order in the list.
explicit: stops a one-argument constructor from being used for silent conversions.
"The initialiser list is the part after the colon. It constructs each member directly with its value. If I assign in the body instead, each member is first default-constructed and then assigned, which is wasted work for something like a string, and it doesn't work at all for const members, reference members, or members with no default constructor. One trap: members are always initialised in the order they're declared in the class, not the order I write them in the list. So if one initialiser reads a member that's declared later, it reads an uninitialised value. Compilers warn about a mismatched order, and I keep that warning on. explicit goes on constructors that take one argument, so the compiler won't use them for implicit conversions. Without it, a function that takes a Temperature would quietly accept a plain double, and that kind of bug is easy to miss in review."
class Temperature {
public:
explicit Temperature(double celsius) : celsius_(celsius) {}
private:
double celsius_;
};
void setTarget(Temperature t);
// setTarget(21.5); // error: constructor is explicit
setTarget(Temperature{21.5}); // fine
struct Window {
int width_;
int area_;
int height_;
Window(int w, int h) : width_(w), height_(h), area_(width_ * height_) {}
// bug: area_ is declared before height_, so it is built first
// and reads height_ before it has a value
};
Believing members are initialised in the order written in the list, or that assigning in the body is the same as initialising.
Three: if a class needs a custom destructor, copy constructor or copy assignment, it almost always needs all three.
Five: since C++11, add the move constructor and move assignment, or moves quietly fall back to copies.
Zero: hold resources in members that manage themselves, and write none of the five.
"The rule of three says that if a class needs a hand-written destructor, copy constructor or copy assignment, it almost certainly needs all three, because it's managing a resource directly. If you write only the destructor, the compiler's copy does a shallow copy of the pointer and you get a double delete. C++11 added move construction and move assignment, which makes it the rule of five. A detail people miss is that declaring a destructor or a copy operation stops the compiler from generating the move operations, so moves silently become copies. In new code I aim for the rule of zero. I put resources into members that already manage themselves, like std::vector, std::string or std::unique_ptr, and then the compiler-generated versions are correct and I write none of them. Only a small, dedicated resource-wrapper class should ever need the full five."
Listing the rules without knowing why the default copy is wrong for an owning raw pointer.
Problem: the default copy copies the pointer, so both objects delete the same array.
Copy constructor: allocate a new array and copy the elements across.
Assignment: copy-and-swap handles self-assignment and gives the strong exception guarantee.
"The default copy just copies the pointer, so two objects would own one array and both destructors would delete it. That's a double free. The copy constructor allocates its own array of the same size and copies the elements. For assignment I use copy-and-swap: the parameter is taken by value, so the copy constructor does the allocation, then I swap my contents with the parameter's and let its destructor free my old array. It handles self-assignment for free and it's exception safe: if the allocation throws, it throws before my object has changed at all. The swap is marked noexcept, because the whole guarantee rests on that last step never failing. In real code I'd just use std::vector and write none of this, but it's a good test of whether you understand ownership."
class Buffer {
public:
explicit Buffer(std::size_t n) : size_(n), data_(new int[n]{}) {}
~Buffer() { delete[] data_; }
Buffer(const Buffer& other)
: size_(other.size_), data_(new int[other.size_]) {
std::copy(other.data_, other.data_ + size_, data_);
}
Buffer& operator=(Buffer other) { // copy made by the constructor
swap(other);
return *this; // old buffer freed with other
}
void swap(Buffer& other) noexcept {
std::swap(size_, other.size_);
std::swap(data_, other.data_);
}
private:
std::size_t size_;
int* data_;
};
Writing an assignment operator that deletes its own data before copying, which breaks on self-assignment and leaves a dangling pointer if new throws.
Values: an lvalue has an identity you can refer to again; an rvalue is typically a temporary about to disappear.
std::move: a cast to an rvalue reference; it moves nothing by itself.
The move: happens when the cast result picks a move constructor or move assignment.
"An rvalue reference, written T&&, binds to things that are about to go away, like temporaries, which lets a class write a move constructor that steals their resources instead of copying them. std::move doesn't move anything. It's just a cast that says 'treat this as an rvalue'. The actual move happens only if that cast makes overload resolution pick a move constructor or move assignment. If the type has no move operations, you get a copy. If the object is const, you also get a copy, because a const rvalue can't bind to a non-const T&&. After a move, standard library objects are left valid but in an unspecified state, so I can assign to them or destroy them but shouldn't read their value. One more trap: a named rvalue reference parameter is itself an lvalue, so inside the function you need std::move again to pass it on as an rvalue."
std::string a = "long text that lives on the heap";
std::string b = std::move(a); // move constructor runs here
// a is valid but unspecified: assign before reading it
const std::string c = "x";
std::string d = std::move(c); // copies: c is const
Believing std::move performs the move itself, or reading a moved-from object as if it kept its value.
Steal: take the other object's pointer and size, then set its pointer to null so its destructor is harmless.
Assign: free your own buffer, take the other's, and guard against self-move.
noexcept: when a vector grows, it moves elements only if the move cannot throw; otherwise it copies.
"The move constructor takes the other object's pointer and size and leaves the other one empty, with a null pointer, so its destructor does nothing harmful. std::exchange makes that neat. Move assignment does the same, but first frees my own buffer, and I guard against moving an object into itself. The noexcept part really matters. When a vector runs out of capacity, it allocates a bigger array and transfers the elements. It promises that if something throws halfway through, the original vector is unchanged. It can only keep that promise with moves if the move can't throw, because a half-finished move can't be undone. So if the move constructor isn't marked noexcept and the type can be copied, the vector copies every element on growth instead. That's a silent performance hit I've seen in real code. Since these moves only swap pointers, they genuinely can't throw, so marking them noexcept is honest."
Buffer(Buffer&& other) noexcept
: size_(std::exchange(other.size_, 0)),
data_(std::exchange(other.data_, nullptr)) {}
Buffer& operator=(Buffer&& other) noexcept {
if (this != &other) {
delete[] data_;
size_ = std::exchange(other.size_, 0);
data_ = std::exchange(other.data_, nullptr);
}
return *this;
}
Copying the pointer without nulling the source, which leads to a double delete.
Scenario: a derived object is deleted through a pointer to its base class.
Without virtual: undefined behaviour; in practice the derived destructor usually never runs.
Rule: a base meant for polymorphic delete gets a public virtual destructor, otherwise a protected non-virtual one.
"If I have Base* p = new Derived and later call delete p, the compiler needs to know the real type to destroy it properly. With a virtual destructor, the call goes through virtual dispatch, the Derived destructor runs first, then the Base one. Without it, the behaviour is undefined by the standard. What usually happens is that only the Base destructor runs, so anything Derived owns, like a file, a buffer or a socket, never gets released. The same applies to a unique_ptr of Base holding a Derived. So my rule is: if a class has any virtual functions and is meant to be deleted through a base pointer, it gets a public virtual destructor, usually just virtual ~Base() = default. If a base isn't meant to be deleted that way, I make its destructor protected and non-virtual so the mistake won't compile."
Saying the missing virtual destructor just leaks a little memory, instead of recognising it as undefined behaviour.
Table: each polymorphic class typically has one table of pointers to its virtual function implementations.
Pointer: each object carries a hidden pointer to its class table, set during construction.
Cost: an extra pointer per object, an indirect call, and usually no inlining unless the compiler can devirtualise.
"The standard doesn't say how, but every major compiler uses the same idea. For each class with virtual functions, the compiler builds a table of function pointers, one slot per virtual function, pointing at that class's version. Each object gets a hidden pointer to its class's table, set by the constructor. When I call p->draw() through a base pointer, the generated code loads the object's table pointer, picks the draw slot, and calls through it. So the cost is one extra pointer in every object, a couple of loads and an indirect call, and more importantly the compiler usually can't inline the call. In most code that's irrelevant. In a tight loop over millions of small objects it can matter, and then I look at marking classes final so the compiler can devirtualise, or at grouping objects by type."
Claiming there is one vtable per object, or that virtual calls are too slow to use in general.
Overloading: same name, different parameters, chosen by the compiler from the argument types.
Overriding: a derived class replaces a virtual function with the same signature, chosen at runtime by the real type.
override: makes the compiler check that you really override something, catching signature typos.
"Overloading is several functions with the same name but different parameter lists, and the compiler picks one at compile time from the argument types. Overriding is when a derived class provides its own version of a base class virtual function with the same signature, and the choice happens at runtime based on the object's real type. The override keyword matters because without it a tiny mismatch, like a missing const or an int where the base takes a long, silently creates a new function instead of overriding. The code compiles, and the base version gets called at runtime. With override, that's a compile error. There's also a related trap called name hiding: if the derived class declares any function named draw, it hides all the base class overloads of draw, and you need a using declaration to bring them back."
struct Shape {
virtual void draw(int scale) const;
virtual ~Shape() = default;
};
struct Circle : Shape {
void draw(int scale) override; // error: base version is const
void draw(int scale) const override; // correct
};
Saying overriding is resolved at compile time, or not knowing that a signature mismatch silently fails to override.
Pure virtual: a function declared with = 0; any class with one is abstract and cannot be instantiated.
Concrete: a derived class becomes instantiable only once it overrides every pure virtual function.
Interface: a class with only pure virtual functions and a virtual destructor.
"A class is abstract if it has at least one pure virtual function, which is a virtual function declared with = 0. You can't create an object of an abstract class, but you can have pointers and references to it. A derived class is still abstract until it overrides every pure virtual function it inherits. To get what other languages call an interface, I write a class with only pure virtual functions and a virtual destructor, no data members. Code then depends on that interface through a reference or a smart pointer, which makes it easy to swap implementations or pass a fake one in a unit test. One detail that surprises people is that a pure virtual function can still have a body, which derived classes can call explicitly, but it doesn't make the class concrete."
class Logger {
public:
virtual ~Logger() = default;
virtual void write(std::string_view msg) = 0;
};
class FileLogger : public Logger {
public:
void write(std::string_view msg) override { /* append to file */ }
};
Thinking an abstract class cannot have any implemented functions or data, or forgetting the virtual destructor on the interface.
Behaviour: during the base constructor, the call goes to the base version, not the derived one.
Reason: derived members are not constructed yet, so calling derived code would touch uninitialised state.
Pure virtual: calling a pure virtual this way is undefined behaviour, often a crash.
"It calls the base class version. While the base constructor is running, the object's dynamic type is the base class, because the derived part hasn't been constructed yet. That's deliberate: if C++ dispatched to the derived override, that function could read derived members that haven't been initialised, which would be far worse. The same thing happens in reverse in destructors, since by the time the base destructor runs, the derived part is already gone. If the function is pure virtual in the base, calling it from the constructor is undefined behaviour, and many implementations abort with a 'pure virtual function called' message. When I need derived-specific setup, I either pass the needed values into the base constructor as arguments, or I use a factory function that constructs the object fully and then calls an init step."
Saying the derived override runs, as it would in some other languages.
What: copying a derived object into a base object keeps only the base part.
Where: passing by value to a base parameter, or storing in a container of base objects.
Fix: pass by reference, store smart pointers to the base, or delete copying on polymorphic bases.
"Slicing happens when a derived object is copied into a variable of the base type. Only the base part is copied, the derived data is cut off, and the new object really is a Base, so virtual calls on it go to the base versions. The common places it sneaks in are a function that takes Shape by value instead of by reference, and a std::vector of Shape where people push Circles. Everything compiles, but every draw call does the base behaviour. The fixes are to pass polymorphic objects by reference or pointer, and to store them as std::vector of std::unique_ptr of Shape. On polymorphic base classes I often delete or protect the copy operations so that an accidental by-value copy doesn't compile at all. Catching exceptions by value is another place it bites, which is why I always catch by const reference."
void render(Shape s); // slices: always calls Shape::draw
void render(const Shape& s); // keeps the real type
std::vector<Shape> a; // slices every Circle pushed in
std::vector<std::unique_ptr<Shape>> b; // keeps polymorphism
Not recognising that a vector of base objects cannot hold derived objects polymorphically.
unique_ptr: one owner, move-only, frees the object when the owner goes away; no reference count.
shared_ptr: many owners, a reference count in a control block, freed when the last owner goes.
Default: start unique; move to shared only when ownership is truly shared.
"unique_ptr means exactly one owner. It can't be copied, only moved, and when it goes out of scope the object is deleted. With the default deleter it costs about the same as a raw pointer. shared_ptr is for when several parts of the program really share ownership and nobody knows who'll be last. It keeps a reference count in a separate control block, and copying it updates that count atomically, so there's a real cost in memory and in every copy. I default to unique_ptr because it makes ownership obvious from the type, it's cheaper, and it's easy to turn into a shared_ptr later if needed, while going the other way isn't possible. Most of the time, a function that just uses an object shouldn't take a smart pointer at all. It takes a reference or a raw pointer, because it's only borrowing."
auto conn = std::make_unique<Connection>(host); // sole owner
pool.add(std::move(conn)); // ownership moves
auto cfg = std::make_shared<Config>(load()); // read by many workers
for (auto& w : workers) w.setConfig(cfg); // each holds a copy
Using shared_ptr everywhere by default, or saying unique_ptr can be copied.
Cycle: two objects hold shared_ptrs to each other, so neither count ever reaches zero.
weak_ptr: observes without owning; it does not keep the object alive.
Use: call lock() to get a shared_ptr, which is empty if the object is already gone.
"shared_ptr frees an object when its count reaches zero. If a parent holds a shared_ptr to its child and the child holds a shared_ptr back to its parent, then even when the rest of the program drops them, each keeps the other's count at one and both leak. weak_ptr fixes that. It points at an object managed by shared_ptr without adding to its owner count. So I decide which direction is ownership, here parent owns child, and make the back link a weak_ptr. To use it, I call lock(), which gives me a shared_ptr if the object still exists or an empty one if it's gone, and I check that before using it. It's also useful for caches and observer lists, where I want to know about an object without keeping it alive."
struct Node {
std::vector<std::shared_ptr<Node>> children; // owns children
std::weak_ptr<Node> parent; // does not own parent
};
void visitParent(const Node& n) {
if (auto p = n.parent.lock()) { // shared_ptr or empty
p->touch();
}
}
Using a weak_ptr without lock() or without checking the result, or not knowing that shared_ptr cycles leak.
One allocation: make_shared puts the object and the control block in one block of memory.
Safety: no bare new in the code; in older standards this also avoided a leak in function arguments.
Downsides: no custom deleter, and the memory stays allocated until the last weak_ptr is gone.
"shared_ptr needs a control block for the counts. With shared_ptr<T>(new T), that's two allocations: one for the object, one for the control block. make_shared does a single allocation holding both, which is faster and friendlier to the cache. It also means there's no bare new in the code, and before C++17 it avoided a real leak when a shared_ptr was built inside a function call alongside another argument that could throw. There are cases where it's worse. You can't give make_shared a custom deleter. And because the object and control block share one allocation, the object is destroyed when the last shared_ptr goes, but the memory isn't returned until the last weak_ptr goes too. So for a large object watched by long-lived weak_ptrs, the separate allocation can be the better choice."
Saying the only difference is shorter syntax.
Agree on the goal: fewer leaks and fewer ownership bugs is the right aim.
Costs: atomic reference counting, hidden lifetimes, cycles that leak, and code that no longer says who owns what.
Alternative: unique_ptr by default, shared_ptr where ownership is truly shared, references for borrowing.
"I'd start by agreeing with the goal, because raw owning pointers really do cause leaks and crashes. But I'd explain that shared_ptr everywhere doesn't remove the need to think about ownership, it hides it. Every copy updates an atomic count, which costs something in hot paths. Objects live as long as anyone holds a copy, so lifetimes become hard to predict, which is bad for things like file handles or locks. And any two objects pointing at each other form a cycle that leaks, which is exactly the bug we were trying to kill. I'd suggest a simple rule instead: unique_ptr by default, shared_ptr only when several owners genuinely share the object and we can say why, and plain references or raw pointers for functions that only borrow. I'd offer to write that up as a short guideline and pair on converting one module so we can compare."
Either agreeing without thinking about cost and cycles, or dismissing the teammate without offering a better rule.
Instantiation: the compiler generates real code for each set of template arguments that is used.
Visibility: to do that it needs the full definition, not just a declaration, in the file being compiled.
Costs: longer builds, larger binaries and long error messages; explicit instantiation and concepts help.
"A template isn't code by itself, it's a recipe. When I use max<int> or vector<Order>, the compiler instantiates the template for those arguments and generates real functions or classes. To do that it needs to see the whole definition in the file it's compiling. If I put a template's definition in a .cpp file and only the declaration in the header, other files compile fine but nothing generates the code for their types, so the build fails at link time with undefined symbols. That's why templates live in headers. There's an alternative, explicit instantiation, where the .cpp lists the exact types to generate, which works when the set of types is known. The costs of templates are compile time, since every file that uses them does the work, larger binaries from many instantiations, and hard error messages, which C++20 concepts improve by stating requirements up front."
Describing templates as working like runtime generics with type erasure, or not being able to explain the linker error.
Full specialization: a separate definition of a template for one exact set of arguments.
Partial specialization: allowed for class templates only; function templates use overloading instead.
Hash keys: an unordered_map key needs a hash and an equality check; specialising std::hash provides the hash.
"Specialization lets me give a template a different definition for particular arguments. A full specialization covers one exact type. A partial specialization covers a pattern, like any pointer type, but that's only allowed for class templates. For function templates I use an overload instead. A very common real use is hashing. unordered_map needs to hash its keys and compare them for equality. For my own struct there's no hash, so I either specialise std::hash for it, which the standard allows for user-defined types, or pass a hasher as a template argument to the map. I also need operator== so the map can tell apart keys that hash to the same bucket. The hash doesn't need to be perfect, but it should spread keys well, because a bad hash puts everything into a few buckets and lookups slow down towards linear."
struct Point {
int x, y;
bool operator==(const Point& o) const { return x == o.x && y == o.y; }
};
namespace std {
template <>
struct hash<Point> {
size_t operator()(const Point& p) const noexcept {
size_t h = hash<int>{}(p.x);
return h ^ (hash<int>{}(p.y) + 0x9e3779b9 + (h << 6) + (h >> 2));
}
};
}
std::unordered_map<Point, std::string> labels;
Forgetting equality, or claiming function templates can be partially specialised.
Growth: contiguous storage; when size reaches capacity it allocates a bigger block and moves the elements.
Reallocation: invalidates every iterator, pointer and reference into the vector.
Insert and erase: without reallocation, invalidate only from the changed position to the end.
"A vector keeps its elements in one contiguous block, with a size and a capacity. When push_back finds size equal to capacity, it allocates a bigger block, usually growing by a multiple so the average cost of push_back stays constant, moves or copies the elements across and frees the old block. At that point every iterator, pointer and reference into the vector is invalid, because the elements live somewhere else now. If there's no reallocation, insert and erase invalidate things only at and after the position you changed, since those elements shift. The classic bug is taking a pointer to an element, pushing more items, and then using the pointer. If I know the final size, reserve avoids the reallocations, and if I need stable addresses while adding, I store indexes, or use std::list, or std::deque if I only add at the ends, since that keeps references to existing elements valid."
Assuming a pointer to a vector element stays valid after push_back.
Naive bug: after erase(it), it is invalid, so ++it is undefined behaviour.
Loop fix: use the iterator erase returns and only advance when nothing was erased.
Idiom: erase-remove with remove_if, or std::erase_if in C++20, in one pass.
"The naive loop erases at it and then does ++it, but erase invalidates it, so incrementing is undefined behaviour. It often skips elements or runs past the end. The fix in a loop is to use the return value of erase, which is an iterator to the element after the one removed, and only increment when I didn't erase. That's correct but slow for a vector, because every erase shifts the rest of the elements, so it can be quadratic. The idiomatic version is erase-remove. std::remove_if walks once, moves the elements I want to keep to the front and returns the new logical end, then one erase call chops off the tail. That's a single linear pass. In C++20 std::erase_if wraps the whole thing in one call, and it's what I'd write today."
// Loop: correct, but can be quadratic on a vector
for (auto it = v.begin(); it != v.end(); ) {
if (*it < 0) it = v.erase(it);
else ++it;
}
// Erase-remove: one linear pass
v.erase(std::remove_if(v.begin(), v.end(),
[](int x) { return x < 0; }),
v.end());
// C++20
std::erase_if(v, [](int x) { return x < 0; });
Calling remove_if without erase and believing the vector got shorter.
map: a balanced search tree, keys kept sorted, logarithmic lookup, needs a less-than comparison.
unordered_map: a hash table, average constant-time lookup, worst case linear, needs hash and equality.
Choose: ordered iteration or range queries mean map; pure key lookups usually mean unordered_map.
"std::map is a balanced binary search tree, in practice a red-black tree. Keys stay sorted, lookups, inserts and erases are logarithmic, and I can iterate in order or ask for ranges with lower_bound and upper_bound. It needs a less-than comparison on the key. std::unordered_map is a hash table. Lookups are constant time on average, but with a bad hash or many collisions they degrade towards linear, and iteration order is unspecified and can change after a rehash. It needs a hash function and equality. So I pick map when order matters, like a price ladder sorted by price or 'give me everything between these two times', and unordered_map for pure lookups by key, like a cache by ID. For small collections or hot paths I measure, because a sorted vector is sometimes faster than both thanks to contiguous memory."
Saying unordered_map is always constant time, or not knowing that map keeps keys sorted.
Symptom: what failed, how often, and why it was hard to reproduce.
Evidence: the tools you used, like sanitizers, a debugger, core dumps or a heap profiler.
Cause and fix: the real root cause in plain words and the change you made.
Prevention: what you changed so the same kind of bug could not come back.
"At my last company a service that parsed market data crashed maybe once a day, always in a different place, which usually means memory corruption. I couldn't reproduce it locally, so I built it with AddressSanitizer and replayed a recorded day of traffic. Within an hour it reported a heap use-after-free. A handler kept a raw pointer to an element inside a vector, and a later message added elements, the vector reallocated, and the handler wrote through the stale pointer. The fix was to store an index instead of a pointer. Afterwards I added a sanitizer build to our CI, ran the replay test nightly, and wrote a short guideline for the team about not holding pointers into containers that can grow. We didn't see that crash again, and the sanitizer job caught two similar bugs in review within the next few months."
A story that ends with adding null checks or a try/catch until the crash stopped, with no root cause.
Why: the concrete pain, such as leaks, crashes or slow onboarding.
Approach: small steps behind tests, one module or one pattern at a time.
Tools: compiler warnings, static analysis, sanitizers and code review.
Result: what improved and what you chose not to touch.
"In my last role we had a large codebase full of raw new and delete, and most of our crash reports were leaks or double frees. Rather than a big rewrite, I proposed doing it one module at a time. First we raised the compiler to a newer standard with warnings turned up and fixed what that showed. Then for each module I added tests around the public behaviour, replaced owning raw pointers with unique_ptr, and left non-owning ones as raw pointers or references, so the types finally said who owned what. We ran every change through AddressSanitizer in CI. Some places had genuinely shared lifetimes and got shared_ptr, but only after we'd argued about it in review. Over a few months the leak reports mostly stopped. I deliberately left one old, stable module alone, because nothing was failing there and the risk wasn't worth it."
Describing a big-bang rewrite with no tests, or modernising purely for style with no problem to solve.
Baseline: a repeatable benchmark and a number before any change.
Profile: the profiler output that showed the hot spot, not a guess.
Fix: what you changed and why it helped at the machine level.
Check: the new number, and a test so it does not quietly regress.
"In a project that processed large sensor logs, one stage was taking minutes. First I wrote a benchmark on a fixed input so I had a number to compare. Then I ran it under perf, and the surprise was that most of the time wasn't in the maths at all. It was in memory allocation and string copies. A function took a std::string by value in a hot loop, and we built a small temporary vector for every record. I changed the parameter to a string_view, reused one vector across iterations with clear and a reserved capacity, and added a missing reserve on the output vector. The stage went from minutes to under twenty seconds. I added the benchmark to our CI with a threshold, so a later change that brought the allocations back would be caught before release."
Optimising by instinct, for example rewriting loops by hand, without a profile or a before-and-after number.
ClapAssist is an AI interview assistant for Mac and Windows. It listens to the interview on your computer and shows you what to say, in short lines you can read while you talk. Your resume and notes are never stored on our servers. It stays out of screen share on every plan; only you can see it.