C++ Interview Prep · Part 2

Iterators, Moves, Smart Pointers, Concurrency

The four highest-yield topics after virtual functions, including the second-order follow-ups. If it's asked often enough to sink an interview, it's in here. Traps marked ⚠️, one-liners marked 💬.

⚠️ interview trap💬 one-liner worth saying verbatim

01Iterator Invalidation

An iterator, pointer, or reference into a container is a promise that a particular element sits at a particular place. Container operations can break that promise — and using a broken one is undefined behavior (UB), not an exception, not a null check. It often "works" in testing and corrupts memory in production. Interviewers probe this because it's the easiest live-coding bug to write.

Why each container invalidates — from memory layout

You already know the layouts, so the rules derive rather than memorize:

The table (know this cold)

Container / operationIteratorsPointers & references
vector push_back, no reallocationvalid (except end())valid
vector push_back with reallocationALL invalidALL invalid
vector insert/erase at position pinvalid from p onwardinvalid from p onward
deque push_front / push_backall invalidvalid ⚠️
deque insert/erase in middleall invalidall invalid
list insertvalidvalid
list/map/set eraseonly the erased oneonly the erased one
map/set insertvalidvalid
unordered_* insert causing rehashall invalidvalid ⚠️
unordered_* insert, no rehashvalidvalid
unordered_* eraseonly the erased oneonly the erased one

The two ⚠️ rows are the interview favorites: operations where iterators die but references survive. Being able to explain why (index rebuilt / rehash, but nodes stayed put) is the senior answer.

The classic bugs

// Bug 1: reference held across push_back
std::vector<int> v = {1, 2, 3};
int& first = v[0];
v.push_back(4);        // may reallocate
first = 99;            // UB if it did — writes to freed memory

// Bug 2: inserting from yourself
v.push_back(v[0]);     // SAFE — the standard requires push_back to handle an
// argument aliasing the vector's own storage (copied before the old block dies).
v.insert(v.end(), v.begin(), v.end());   // UB — RANGE insert from the same
// container: the source iterators invalidate mid-operation. Single-element
// self-insert is handled; self-range is not. Worth voicing in an interview.

// Bug 3: erasing inside a range-for
for (int x : v)
    if (x % 2 == 0) v.erase(...);  // UB — range-for holds iterators

Erasing correctly, per container

// vector / deque: erase returns the next valid iterator
for (auto it = v.begin(); it != v.end(); ) {
    if (bad(*it)) it = v.erase(it);   // reseat from return value
    else          ++it;
}

// vector, erasing many: erase-remove idiom (single O(n) pass)
v.erase(std::remove_if(v.begin(), v.end(), bad), v.end());

// C++20: same thing, one call
std::erase_if(v, bad);

// map / set / unordered_*: same reseat pattern works (C++11 erase returns next)
for (auto it = m.begin(); it != m.end(); )
    it = bad(*it) ? m.erase(it) : std::next(it);

Second-order points (the follow-up questions)

⚠️ Trap — capacity vs size

reserve(n) pre-allocates capacity so subsequent push_backs won't reallocate (pointers stay valid up to n elements) — it does not change size(). resize(n) changes size. If you know the element count up front, reserve is both a performance win and an invalidation guarantee — say that in interviews. Growth is geometric (factor ~1.5–2×), which is what makes push_back amortized O(1).

💬 One-liners"Contiguous containers invalidate by moving elements; node containers invalidate only what's erased; hashed containers sit in between — rehash kills iterators but nodes don't move, so references survive."

"Using an invalidated iterator is UB, not an error you can catch."

↑ back to top

02Move Semantics

The problem moves solve

Copying a vector<string> means allocating and copying every buffer. But if the source is a temporary about to die, copying is waste — you could just steal its buffers. Move semantics is the language machinery for "steal, don't copy, when the source won't need it."

Value categories (the 60-second version)

rvalue = prvalue or xvalue. An rvalue reference T&& binds only to rvalues — that's how a function can offer a separate overload for "sources you may gut":

void take(const std::string& s);  // binds to everything (copies)
void take(std::string&& s);        // binds to rvalues only (steals)

std::string name = "yel";
take(name);             // lvalue → const& overload
take(name + "!");       // prvalue → && overload
take(std::move(name));  // xvalue  → && overload
⚠️ Trap — std::move moves nothing

std::move(x) is a cast to T&& — zero runtime work. It's a label saying "treat x as stealable." The actual stealing happens only if something (a move constructor, a move assignment, the && overload) accepts the invitation. std::move on a const object silently copies — the move ctor takes T&&, a const T&& won't bind to it, and overload resolution falls back to the copy ctor. No warning. Classic quiz question.

Writing move operations

class Buffer {
    char* data_ = nullptr;
    size_t n_ = 0;
public:
    Buffer(Buffer&& o) noexcept
        : data_(std::exchange(o.data_, nullptr)),  // steal + null the source
          n_(std::exchange(o.n_, 0)) {}

    Buffer& operator=(Buffer&& o) noexcept {
        if (this != &o) {
            delete[] data_;
            data_ = std::exchange(o.data_, nullptr);
            n_    = std::exchange(o.n_, 0);
        }
        return *this;
    }
};

RVO and copy elision

std::string make() {
    std::string s = "long string ...";
    return s;                 // NRVO: usually constructed in place, zero copies/moves
    // return std::move(s);   // PESSIMIZATION — disables NRVO, forces a move
}

Sink parameters (the API-design question)

class Person {
    std::string name_;
public:
    Person(std::string name) : name_(std::move(name)) {}  // by value + move
};
// rvalue arg: move + move (≈free).  lvalue arg: copy + move (minimum possible).

For parameters the function will keep, take by value and move into place: one signature, optimal for both call styles. The const&/&& overload pair saves one cheap move at the cost of 2ⁿ overloads. "By value and move for sinks" is the expected answer to "how should this constructor take its arguments?"

Generation rules (what silently disappears)

Full treatment — value categories, reference collapsing, the Noisy trap gallery — lives in the dedicated move-semantics deep dive.

Perfect forwarding (recognize, use correctly)

template <typename T>
void wrapper(T&& arg) {                // forwarding reference, NOT rvalue ref
    target(std::forward<T>(arg));      // lvalue stays lvalue, rvalue stays rvalue
}
💬 One-liners"std::move is a cast — it does nothing except change overload resolution."

"Inside a move constructor the parameter is an lvalue; forget std::move on the members and you've written a copy constructor with extra steps."

"Never return std::move(local) — worst case it's redundant, best case it defeats NRVO."

↑ back to top

03Smart Pointer Internals

unique_ptr — ownership at zero cost

auto p = std::make_unique<Widget>(42);  // preferred over unique_ptr<Widget>(new ...)
auto q = std::move(p);                  // ownership transfers; p is now null
// auto r = q;                          // COMPILE ERROR — copy is deleted

shared_ptr — the control block

A shared_ptr is two pointers (16 bytes): one to the object, one to a heap-allocated control block containing:

auto a = std::make_shared<Widget>();   // ONE allocation: object + control block together
auto b = std::shared_ptr<Widget>(new Widget);  // TWO allocations

auto c = a;             // copy: strong count 1 → 2 (atomic increment)
auto d = std::move(a);  // move: counts untouched — a just goes null
⚠️ Trap — two control blocks, one object

Widget* raw = new Widget; shared_ptr<Widget> a(raw), b(raw); creates two independent control blocks, each convinced it owns the object → double delete → UB. Same disease as shared_ptr(this) inside a member function. The cure for the this case: inherit std::enable_shared_from_this<Widget> and call shared_from_this(), which finds the existing control block instead of minting a new one.

weak_ptr — observing without owning

std::weak_ptr<Widget> w = a;      // weak count++, strong count unchanged
if (auto s = w.lock()) {          // atomically: still alive? then take a strong ref
    s->use();                      // safe for s's lifetime
}                                  // else the object is gone

Second-order points (the follow-up questions)

💬 One-liners"unique_ptr is a raw pointer with a destructor — same size, same speed, plus a compiler-enforced ownership story."

"shared_ptr is two pointers; the price is a heap control block and atomic count traffic — copies cost, moves are free."

"Cycles: ownership edges shared, back-edges weak."

"A smart pointer in a signature is a claim about ownership — functions that just use the object take a reference."

↑ back to top

04Concurrency Basics

What a data race actually is

Two threads access the same memory location, at least one access is a write, and there's no synchronization ordering them → data race → UB. Not "sometimes wrong values" — undefined, the compiler may assume it can't happen and optimize accordingly. The famous demo:

int counter = 0;
// two threads each run: for (int i = 0; i < 1'000'000; ++i) ++counter;
// result: almost never 2'000'000. ++counter is read-modify-write, three steps,
// and interleavings lose updates. Also formally UB regardless of the count.

Mutexes and the lock types

std::mutex m;
int counter = 0;

void inc() {
    std::lock_guard<std::mutex> lk(m);   // RAII: locks now, unlocks in destructor
    ++counter;
}                                        // unlock even if an exception flies

condition_variable — waiting for a fact to become true

std::mutex m;
std::condition_variable cv;
std::queue<Task> q;

// consumer
std::unique_lock<std::mutex> lk(m);
cv.wait(lk, [&]{ return !q.empty(); });  // unlocks while asleep, relocks to test
Task t = std::move(q.front()); q.pop();
lk.unlock();

// producer
{ std::lock_guard<std::mutex> lk(m); q.push(std::move(task)); }
cv.notify_one();

atomics — when a mutex is overkill

std::atomic<int> counter{0};
counter.fetch_add(1);          // or ++counter — single indivisible instruction
bool was = flag.exchange(true); // test-and-set

Threads themselves + odds and ends

std::thread t(work, arg);       // starts immediately; args are COPIED into the thread
t.join();                        // must join or detach before t is destroyed,
                                 // else std::terminate
std::jthread jt(work);           // C++20: joins automatically in its destructor

Second-order points (the follow-up questions)

💬 One-liners"A data race is UB, not a wrong answer — the fix is synchronization, not retries."

"Locks are RAII or they're bugs."

"condition_variable waits are loops: spurious wakeups and stolen wakeups both re-test the predicate."

"Atomics protect a variable; mutexes protect an invariant."

"Lock-free means a CAS loop — and a benchmark against the mutex it replaced."

↑ back to top

05Combined Cheat Sheet

Iterator invalidation

Move semantics

Smart pointers

Concurrency

↑ back to top