Skip to content
>_ StartupJobList_

Interview questions · Worked answers

C++ interview questions for backend and systems engineers

Strong answers state ownership, borrow lifetime, invalidation, and synchronization relations. They distinguish syntactic constraints from semantic promises and passing tests from memory-model guarantees.

For: Mid-level to senior backend and systems engineers

Guide scope and versions

C++20 baseline checked against WG21 N4860, the C++20 Draft International Standard. Examples use C++20 facilities; later living-draft wording is not treated as pinned source.

Question 1 of 12

A function returns a reference or view. How do you prove the object is still alive?

For a class object, its lifetime ends when its destructor call starts, or when its storage is released or reused. For a non-class object, lifetime ends when it is destroyed, or when its storage is released or reused. A reference, pointer, span, or string_view does not extend an ordinary local object’s lifetime. A returned borrow is safe only when tied to storage that outlives every use.

A string_view over a local string carries a pointer and length, not characters. When the function returns, the string is destroyed, so later access uses storage whose object lifetime ended. It may seem to work while bytes remain untouched, but C++ gives no usable object there. The same analysis applies to references to locals, spans over local vectors, and pointers into temporaries.

Fix the ownership boundary. Return an owning string when the result is independent, accept caller-owned storage when allocation belongs to the caller, or return a view only from storage whose lifetime is explicit. Constness does not make a borrow own its source. Lifetime extension rules are narrow and do not turn a returned view into an owner.

C++20 · Worked example
#include <string>
#include <string_view>

std::string make_label() {
    return "ready";
}

int main() {
    std::string owned = make_label();
    std::string_view view = owned;
    return view == "ready" ? 0 : 1;
}

The returned string owns characters. The view is valid while its owner lives and storage is not invalidated.

Compiled and run in an isolated Debian arm64 container with Clang 19.1.7 in C++20 mode; observed exit status 0.

Follow-up: Would a view to a string literal be safe?

Yes. A string literal has static storage duration, so a view formed over its characters can outlive the call. A view into a local std::string does not: that owner is destroyed at return.

Read the source: WG21 N4860 [basic.life], [class.temporary] · WG21 N4860, C++20 Draft International Standard

↑ Back to questions

Question 2 of 12

How should an API express ownership transfer and borrowing?

Use values and RAII owners to make lifetime management local. unique_ptr represents exclusive ownership and visible transfer; references, pointers, and views conventionally borrow when documented. Use shared_ptr only when shared lifetime is part of the design.

RAII binds cleanup to object lifetime, so early returns and exceptions still run destructors. Passing a value by value is often simplest. unique_ptr makes heap ownership transfer explicit: moving it into a function transfers responsibility and empties the caller’s owner.

A raw pointer does not itself say whether it owns, but Core Guidelines treat raw pointers as non-owning by default. A reference means required borrowing; a pointer can mean optional borrowing. A stored borrow needs a lifetime contract. shared_ptr does not solve cycles, synchronization, or unclear ownership.

C++20 · Worked example
#include <memory>
#include <utility>

struct Job {
    int id;
};

void run(std::unique_ptr<Job>) {}

int main() {
    auto job = std::make_unique<Job>(Job{7});
    run(std::move(job));
    return job ? 1 : 0;
}

Ownership transfers into run; the caller’s unique_ptr is empty afterward.

Compiled and run in an isolated Debian arm64 container with Clang 19.1.7 in C++20 mode; observed exit status 0.

Follow-up: When should an API accept unique_ptr instead of T&?

Accept unique_ptr when taking ownership; accept T& for required borrowing during the call.

Read the source: C++ Core Guidelines R.1, R.3, R.20 and R.21 · WG21 N4860, C++20 Draft International Standard

↑ Back to questions

Question 3 of 12

Why can two shared_ptr objects keep an unreachable graph alive?

Each shared_ptr keeps its object alive while its strong count is nonzero. If A owns B and B owns A, dropping external pointers leaves both counts nonzero. Make a non-owning back edge with weak_ptr and lock it for temporary ownership.

Reference counting reclaims an object when its last strong owner releases it. It does not trace a graph, so it cannot infer that a strongly connected component is unreachable externally. A parent-child tree often uses strong ownership down and a weak back-reference.

weak_ptr observes without increasing the strong count. lock() checks if the object lives and, if so, creates temporary strong ownership. Check the result. This is a lifetime check, not a lock on mutable fields. use_count() is not synchronization because it can change immediately and establishes no happens-before relation. The shared_ptr control block supports concurrent ownership-count operations through distinct shared_ptr instances that share ownership. That guarantee does not make concurrent access to the Node object safe; synchronize mutable pointee state separately. Concurrent mutation of the same shared_ptr variable also needs synchronization.

C++20 · Worked example
#include <memory>

struct Node {
    std::shared_ptr<Node> child;
    std::weak_ptr<Node> parent;
};

int main() {
    auto parent = std::make_shared<Node>();
    auto child = std::make_shared<Node>();
    parent->child = child;
    child->parent = parent;
    return child->parent.lock() == parent ? 0 : 1;
}

The child-to-parent edge is weak, avoiding a strong cycle. lock temporarily promotes the observation.

Compiled and run in an isolated Debian arm64 container with Clang 19.1.7 in C++20 mode; observed exit status 0.

Follow-up: Is weak_ptr::lock a mutex?

No. lock safely promotes lifetime if possible. Control-block operations through distinct shared_ptr instances are safe, but Node fields still need synchronization, and the same shared_ptr variable cannot be concurrently mutated without it.

Read the source: WG21 N4860 [util.smartptr.shared] and [util.smartptr.weak] · WG21 N4860, C++20 Draft International Standard

↑ Back to questions

Question 4 of 12

What does std::move do, and why does noexcept matter for moving?

std::move is a cast that permits binding to an rvalue-reference overload; it does not move by itself. The move constructor defines the operation. Library types generally leave moved-from objects valid but unspecified. A non-throwing move can help containers preserve exception guarantees.

std::move casts to an rvalue-reference type. Overload resolution may select a move constructor, but it can still copy, or select const-reference copying if no move exists. A named T&& parameter is an lvalue expression inside its function, so forwarding it needs move or forward.

A moved-from object remains an object, but its value is generally unpredictable. Many library types promise valid-but-unspecified state. During vector growth, a throwing move can conflict with the strong exception guarantee; a truthful noexcept move can permit moving. Never mark a move noexcept if it can throw.

C++20 · Worked example
#include <string>
#include <utility>

int main() {
    std::string source = "payload";
    std::string destination = std::move(source);
    return destination == "payload" ? 0 : 1;
}

The example asserts only the destination value, not the moved-from string contents.

Compiled and run in an isolated Debian arm64 container with Clang 19.1.7 in C++20 mode; observed exit status 0.

Follow-up: May code call clear on a moved-from string?

Yes, operations whose preconditions hold remain valid. Do not infer its old value or size.

Read the source: WG21 N4860 [lib.types.movedfrom], [vector.modifiers], [except.spec] · WG21 N4860, C++20 Draft International Standard

↑ Back to questions

Question 5 of 12

A vector insertion makes a saved iterator unsafe. What invalidates it?

Vector operations can invalidate pointers, references, and iterators. Reallocation invalidates all. Without reallocation, insertion invalidates handles at or after insertion; erasure invalidates handles at or after the erased position.

A vector stores elements contiguously. Growing capacity typically allocates a new buffer and releases the old one, invalidating pointers, references, iterators, and spans. reserve prevents reallocation only while size stays within capacity; it does not promise stable handles across mutations.

Without reallocation, middle insertion shifts a suffix, invalidating handles at or after insertion. Erasure shifts later elements. A loop that erases and increments the old iterator is wrong. Use erase’s returned iterator and reacquire handles after invalidation.

C++20 · Worked example
#include <vector>

int main() {
    std::vector<int> values{1, 2, 3, 4};
    for (auto it = values.begin(); it != values.end();) {
        if (*it % 2 == 0) {
            it = values.erase(it);
        } else {
            ++it;
        }
    }
    return values == std::vector<int>{1, 3} ? 0 : 1;
}

The loop uses erase’s returned iterator and never reads the invalidated one.

Compiled and run in an isolated Debian arm64 container with Clang 19.1.7 in C++20 mode; observed exit status 0.

Follow-up: Does reserve make every iterator stable?

No. It can prevent reallocation within capacity, but insertion and erasure still invalidate suffix handles.

Read the source: WG21 N4860 [vector.modifiers] and [vector.capacity] · WG21 N4860, C++20 Draft International Standard

↑ Back to questions

Question 6 of 12

What do std::span and std::string_view own?

Neither owns elements or characters. span views a contiguous sequence; string_view views characters. They carry access and extent, not storage lifetime. The owner must remain alive and avoid invalidation while the view is used.

span lets a function accept an array-like range without choosing whether the caller stores it in an array or vector. It copies no elements. string_view is convenient for parsing, but is not guaranteed null terminated at its end. A function that stores either view must document owner lifetime or copy retained data.

The owner may invalidate a view before destruction: vector growth can reallocate and string operations have specified invalidation rules. A view from a temporary owner can dangle at the end of the full expression. A synchronous parameter can be safe; caching the view is an un-enforced lifetime extension.

C++20 · Worked example
#include <span>
#include <vector>

int sum(std::span<const int> values) {
    int total = 0;
    for (int value : values) {
        total += value;
    }
    return total;
}

int main() {
    std::vector<int> values{2, 3, 5};
    return sum(values) == 10 ? 0 : 1;
}

The span borrows only during the call; the vector remains alive and unchanged.

Compiled and run in an isolated Debian arm64 container with Clang 19.1.7 in C++20 mode; observed exit status 0.

Follow-up: Is a temporary vector argument always safe?

For a synchronous call that does not retain the span, the temporary survives through the full expression. Retaining it or scheduling work is unsafe.

Read the source: WG21 N4860 [views.span], [string.view], [basic.life] · WG21 N4860, C++20 Draft International Standard

↑ Back to questions

Question 7 of 12

What exception guarantee does a mutating operation provide, and how should cleanup behave?

The strong guarantee leaves observable state unchanged on failure; the basic guarantee preserves invariants and avoids leaks. RAII cleans up during unwinding. A second exception escaping while another is unwinding calls terminate.

A common strong-guarantee pattern builds replacement state separately, then commits with a non-throwing swap. If rollback is impossible or costly, preserve invariants and document a weaker guarantee. The example injects a failure after the candidate exists but before the commit, so it checks that the original value remains intact. The guarantee belongs to the whole operation, including callbacks, not one assignment.

RAII destroys stack objects during normal exit and unwinding. A destructor that throws during unwinding terminates. noexcept does not turn cleanup failure into success. Record failures through a non-throwing channel or expose an explicit operation that can report errors before destruction.

C++20 · Worked example
#include <stdexcept>
#include <string>

void replace(std::string& current, std::string candidate, bool fail_before_commit) {
    if (fail_before_commit) {
        throw std::runtime_error("injected before commit");
    }
    current.swap(candidate);
}

int main() {
    std::string name = "old";
    try {
        replace(name, std::string{"new"}, true);
    } catch (const std::runtime_error&) {
        // The injected failure happened before the commit.
    }
    return name == "old" ? 0 : 1;
}

The replacement candidate is constructed before the call. The controlled exception is thrown before swap; catch handles it and the final check confirms the original state remains old. No rollback of external side effects is claimed.

Compiled and run in an isolated Debian arm64 container with Clang 19.1.7 in C++20 mode; observed exit status 0.

Follow-up: What if the operation invokes a callback before committing?

The strong guarantee holds only if callback effects are absent or reversible. If a callback publishes an event or writes externally, a later exception can leave that effect even when the local object is unchanged; document the weaker boundary or move the callback after commit.

Read the source: WG21 N4860 [except.spec] and [except.terminate] · WG21 N4860, C++20 Draft International Standard

↑ Back to questions

Question 8 of 12

What does a C++20 concept prove about a template argument?

A concept is a compile-time predicate constraining template arguments, usually by checking expressions and types. It improves diagnostics and overload selection. Syntactic satisfaction does not prove semantic properties such as complexity, multipass behavior, or stability.

A requires expression checks whether value.size() is well formed; the return-type requirement makes the example’s std::size_t comparison contract explicit. A constrained function rejects arguments that fail before instantiating its body. Standard concepts compose richer requirements, and overlapping constraints affect overload ordering.

The compiler checks encoded syntax and types; it cannot generally prove a user iterator is multipass or size is constant time. A type may satisfy expression checks while violating semantic requirements. Name narrow requirements honestly and state other preconditions.

C++20 · Worked example
#include <concepts>
#include <cstddef>
#include <vector>

template<class T>
concept HasSize = requires(const T& value) {
    { value.size() } -> std::convertible_to<std::size_t>;
};

template<HasSize T>
auto count(const T& value) {
    return value.size();
}

int main() {
    return count(std::vector<int>{1, 2, 3}) == 3 ? 0 : 1;
}

The concept requires size() to be convertible to std::size_t. It still does not establish that the result is meaningful, cheap, or consistent with iteration.

Compiled and run in an isolated Debian arm64 container with Clang 19.1.7 in C++20 mode; observed exit status 0.

Follow-up: Why use ranges::forward_range rather than checking begin and end?

The named concept includes semantic requirements such as multipass behavior. Expression checks prove less.

Read the source: WG21 N4860 [temp.constr] and [range.range] · WG21 N4860, C++20 Draft International Standard

↑ Back to questions

Question 9 of 12

Why does a named T&& parameter behave as an lvalue, and when is std::forward needed?

A forwarding reference is an rvalue reference to a deduced, cv-unqualified template parameter. An lvalue argument makes T an lvalue reference. The named parameter is always an lvalue expression, so forward<T> restores the caller’s category. Fixed Widget&& is not forwarding.

Value category belongs to expressions. In template<class T> void f(T&& value), value is an lvalue expression even when caller passed a temporary. Passing it onward directly selects an lvalue overload. forward<T> preserves lvalues as lvalues and rvalues as rvalues.

Do not forward every named variable. Use forward for forwarding references with deduced T. Use move when the function decides to consume its object; use an ordinary expression when it should remain an lvalue. Over-forwarding can consume an object unexpectedly.

C++20 · Worked example
#include <utility>

int pick(int&) { return 1; }
int pick(int&&) { return 2; }

template<class T>
int relay(T&& value) {
    return pick(std::forward<T>(value));
}

int main() {
    int value = 0;
    return relay(value) == 1 && relay(0) == 2 ? 0 : 1;
}

The lvalue deduces T as int&, the temporary as int, preserving overload selection.

Compiled and run in an isolated Debian arm64 container with Clang 19.1.7 in C++20 mode; observed exit status 0.

Follow-up: Should a wrapper use move instead?

No when preserving both categories; move always casts to rvalue and can consume an lvalue.

Read the source: WG21 N4860 [temp.deduct.call] and [forward] · WG21 N4860, C++20 Draft International Standard

↑ Back to questions

Question 10 of 12

What makes a data race, and what exactly does a mutex protect?

A data race occurs when conflicting potentially concurrent actions access one memory location, at least one non-atomic, and neither happens-before the other. It is undefined behavior. A mutex protects an invariant only if all participating accesses follow its locking protocol.

Two threads incrementing a plain integer without synchronization race, even if a processor increment appears indivisible. Use a mutex for compound invariants or an atomic for an independent value. Every reader and writer of a protected multi-field state must take the mutex.

An atomic counter prevents a data race on that counter, not adjacent fields. If one thread updates an atomic and a plain field while another reads both, the plain field needs synchronization. A release/acquire protocol can publish the plain field when the acquire observes the release and no later writes race with the reader. Unlocking a mutex synchronizes with a later successful lock on that mutex.

C++20 · Worked example
#include <mutex>
#include <thread>

int main() {
    std::mutex mutex;
    int total = 0;
    auto add = [&] {
        for (int i = 0; i < 1000; ++i) {
            std::lock_guard<std::mutex> guard(mutex);
            ++total;
        }
    };
    std::thread first(add);
    std::thread second(add);
    first.join();
    second.join();
    return total == 2000 ? 0 : 1;
}

One mutex protects increments; joins precede the final read.

Compiled and run in an isolated Debian arm64 container with Clang 19.1.7 in C++20 mode; observed exit status 0.

Follow-up: Can an atomic counter publish a separate plain value?

The counter’s atomicity alone cannot. A release store and acquire load can publish earlier writes if the load reads that store or its release sequence, the value is not changed afterward, and object lifetime is valid. Without that happens-before edge, concurrent plain access still races.

Read the source: WG21 N4860 [intro.races] and [thread.mutex] · WG21 N4860, C++20 Draft International Standard

↑ Back to questions

Question 11 of 12

How can release/acquire publish data, and what does relaxed ordering fail to guarantee?

A release operation can synchronize with an acquire that reads from it or its release sequence. Prior writes then happen-before later reads. Relaxed operations provide atomicity and per-object modification order but do not publish surrounding non-atomic data by themselves.

A one-time publication writes payload then stores true to a flag with release. A reader loads acquire and, if it observes that release, may read payload because the write happens-before the read. This assumes one publisher, no later payload mutation, and valid lifetime.

With relaxed operations the flag remains atomic but there is no synchronizes-with edge. Replacing both flag operations in this example with relaxed leaves the plain payload write and read without a happens-before relationship, so they form a data race and the program has undefined behavior. This remains true even if the write appears to finish first in an observed run. Relaxed is useful for independent counters or when another mechanism orders the data.

C++20 · Worked example
#include <atomic>
#include <exception>
#include <thread>

int main() {
    int payload = 0;
    std::atomic<bool> ready{false};
    std::thread writer([&] {
        payload = 42;
        ready.store(true, std::memory_order_release);
    });
    std::thread reader([&] {
        while (!ready.load(std::memory_order_acquire)) {
        }
        if (payload != 42) {
            std::terminate();
        }
    });
    writer.join();
    reader.join();
}

Acquire reading release orders payload access. One writer, no later mutation; busy waiting is illustrative.

Compiled and run in an isolated Debian arm64 container with Clang 19.1.7 in C++20 mode; observed exit status 0.

Follow-up: Must acquire read the release store?

It must read that release or its release sequence. An unrelated value provides no publication edge.

Read the source: WG21 N4860 [atomics.order] and [intro.multithread] · WG21 N4860, C++20 Draft International Standard

↑ Back to questions

Question 12 of 12

What can sanitizers prove about lifetime, undefined behavior, and races?

Sanitizers instrument executed paths and report defect classes they observe; they do not prove correctness or race-freedom. ASan finds many out-of-bounds and use-after-free errors, UBSan checks selected operations, and TSan detects many races. Builds and runtime costs differ.

A report is evidence about a detected execution; silence says only that the instrumented run did not report a supported issue on exercised paths. A dangling pointer can escape detection if memory remains addressable or the branch is not reached. Tests without the relevant interleaving do not establish race-freedom.

ASan and TSan runtimes are generally not combined in one process. UBSan covers selected checks, not every undefined operation. Record the toolchain, instrumentation flags and exact test command when reproducing a finding. Treat findings as reproducible defects to minimize.

Shell commands for Clang · Worked example
clang++ -std=c++20 -O1 -g -fsanitize=address,undefined -fno-omit-frame-pointer sample.cpp -o sample-asan
./sample-asan

clang++ -std=c++20 -O1 -g -fsanitize=thread sample.cpp -o sample-tsan
./sample-tsan

Separate illustrative builds; success covers only executed paths and supported checks.

Executed both published Clang sanitizer commands on deterministic synthetic defects in an isolated Debian arm64 container with Clang 19.1.7. AddressSanitizer reported a heap-buffer-overflow and ThreadSanitizer reported a data race, as expected.

Follow-up: Which sanitizer would you use for a dangling vector reference?

Start with ASan and a minimized test forcing reallocation and stale use. Do not execute UB solely to demonstrate output.

Read the source: Clang AddressSanitizer documentation · Clang UndefinedBehaviorSanitizer documentation · Clang ThreadSanitizer documentation

↑ Back to questions

Salary context

Advertised salaries for these skills

These are Hacker News job posts that mention these technologies, not employee earnings or a measure of what a skill adds to pay. The amounts summarize annualized advertised minimums in approximate USD. Groups can overlap and differ in roles, locations and dates.

C++ posts

53 posts · 53 usable advertised salary amounts

2024-06-14 to 2026-10-06

$150,000

Median advertised minimum, approximate USD/year

Middle 50%: $120,326–$185,000

Explore C++ salaries → Browse matching job posts →
Currency and calculation basis

Approximate USD using ECB daily reference rates as of 2026-10-09. Original currencies and offer ranges remain on the source listings. These figures cover posts tagged C++, rather than every job in the broader technology family.

↑ Back to questions

Keep exploring