Written for someone who already knows C, knows what a vtable is, and wants the parts of C++ that are actually
peculiar rather than the parts that are just C with different punctuation. The through-line is where the machine
code comes from: what a value category costs, what the compiler synthesises for you and when it stops, what a
shared_ptr allocates, what a virtual call and an exception really compile to, and which of
the language’s conveniences are free and which are not. The language reference comes first —
175 entries covering every keyword, operator, fundamental type and attribute, because that is what you reach for
with the editor still open; the keywords are set as keycaps, and hovering one raises a worked code example rather than
a sentence. Below it are the 25 guide cards — the object model, value categories and moves, initialisation
in its seven spellings, templates and the two-phase lookup that breaks them, overload resolution and ADL, exceptions
and their guarantees, the container complexities and the iterator-invalidation table, ranges, lambdas, the memory
model, and a catalogue of undefined behaviour that a sanitizer will find before your users do. The standard library
index closes the sheet with 444 more entries. Every entry carries a dot saying which standard it arrived in
— 171 of the 619 are newer than C++17.
C++ is C plus a compiler that writes code for you. Almost everything peculiar about the language follows from that one decision: constructors, copies, moves, destructors and template instantiations are generated on your behalf, and the rest of the language exists to say which ones, precisely enough that you can still predict the machine code. A C programmer reading C++ is usually not confused by the syntax — they are confused about what the compiler emitted that nobody wrote. That is the question this sheet keeps answering.
The consequence worth carrying: nothing here is free by default and nothing is expensive by default. A
virtual call is one indirection; a std::string copy is an allocation; a range view is
usually nothing at all. The cost is knowable in every case, and the guide cards below are largely a catalogue of which
is which.
How this page is ordered. The language reference comes first — keywords,
operators, fundamental types and attributes — because that is what you reach for with the editor open. Then the
working guide, 25 cards on the model and the costs. Then the
standard library index. Press / to jump to the filter box, which searches every row
on the page at once.
How to read a row. Every entry carries a coloured dot for the standard it arrived in — C++17 or earlier C++20 C++23 C++26 deprecated or removed — and the dot is repeated in words in the row’s own tooltip, so a bullet is never unexplained. In the Keywords card the names are set as keycaps; hovering one raises a compiled code example rather than a sentence. Hovering any other row shows the whole entry, which matters because long lines are clipped.
/DotsC++17 or earlierC++20C++23C++26deprecatedOne sentence explains most of the language: you do not pay for what you do not use, and what you do use you could not hand-code better. Every design decision that looks strange follows from defending that. Templates are compiled per instantiation rather than boxed, so generic code is as fast as hand-written code and your build is slow. Destructors run deterministically at scope exit, so there is no collector and no pause, and every resource bug becomes a lifetime bug instead. Exceptions cost nothing on the path that does not throw, so the throw path costs a table walk.
| Construct | Runtime cost | Hidden cost |
|---|---|---|
std::vector<T> | identical to a hand-rolled malloc'd array | growth reallocates and moves; capacity is not size |
std::unique_ptr<T> | zero — same code as a raw pointer plus a delete | none, if the deleter is stateless |
std::shared_ptr<T> | two pointers wide, atomic inc/dec on every copy | a second allocation unless you use make_shared |
std::function<R(A)> | an indirect call; heap allocation if the target is big | type erasure defeats inlining — prefer a template parameter |
| A lambda | zero — it is a struct with an operator() | none; it is the std::function around it that costs |
virtual call | one load of the vptr, one indexed load, one indirect call | blocks inlining and devirtualisation; +8 bytes per object |
throw | nothing until it fires; then unwinding tables | binary size; -fno-exceptions changes the library ABI |
std::string | SSO: short strings never touch the heap | libstdc++ 32 bytes / 15 chars, libc++ 24 bytes / 22 chars |
virtual, std::function,
a void* — you have bought an indirect call. That is the whole performance model.std::expected, std::print, mdspan-std=c++26reflection, contracts, std::execution; partial in GCC 15 / Clang 21-fno-exceptions -fno-rttithe embedded dialect; not standard C++ and not ABI-compatible with the restThe four phases are the same as C. What is different is that the compiler now emits far more than you wrote, and the linker has to sort out the duplicates.
nm output is unreadableC++ encodes the whole signature into the symbol so overloads can coexist in one object file. Both GCC and Clang use the Itanium C++ ABI on every platform except Windows/MSVC.
foo(int) — _Z, length-prefixed name, parameter codes_Z3fooPKcfoo(const char*) — P pointer, K const, c charnm -C progdemangle while listing (or c++filt)c++filt _Z3fooidemangle one symbol by handstd::string ABIs since GCC 5.
-D_GLIBCXX_USE_CXX11_ABI=0 selects the old one. Mixing them gives you link errors naming
__cxx11::basic_string — the symptom is an undefined reference to a function you can plainly see is defined.A non-inline function or variable may be defined once in the whole program. Headers are textually included into every TU, so anything a header defines needs an exemption:
| Exemption | Applies to | Mechanism |
|---|---|---|
inline | functions, and since C++17 variables | emitted weakly in each TU; linker keeps one |
| member functions defined in-class | implicitly inline | same |
| templates | implicitly, per instantiation | COMDAT sections, deduplicated at link |
constexpr | implicitly inline for functions | same |
| unnamed namespace | anything | internal linkage — a separate entity per TU |
-DNDEBUG, a different -std= — link
fine and then behave as if memory were corrupted, because the linker picked one definition arbitrarily. Build every TU
with the same flags. gold --detect-odr-violations and ASan's ODR checker catch the easy cases.The key function rule. Under the Itanium ABI the vtable is emitted in the TU that defines the class's first non-inline, non-pure virtual member. Declare all your virtuals inline and there is no key function, so the vtable is emitted everywhere (bigger binary). Declare one and never define it, and it is emitted nowhere — giving exactly the "undefined reference to vtable" that confuses everyone the first time.
A C++ object is a C struct plus, if it needs them, one or more vptrs. Nothing else is added. Everything about polymorphism follows from where those pointers sit.
offsetof is legaltrivially copyableno user copy/move/destructor → memcpy is a legal copyPODboth of the above; the term is deprecated in favour of the two halvestrivialtrivially copyable and trivially default-constructible| Class | sizeof on LP64 | Why |
|---|---|---|
struct E {}; | 1 | every object needs a distinct address |
struct A { char c; int i; }; | 8 | 3 bytes of padding before i |
struct B { int i; char c; }; | 8 | 3 bytes of tail padding — arrays must stay aligned |
struct V { virtual ~V(); int i; }; | 16 | vptr first, then i, then padding |
struct D : E { int i; }; | 4 | empty base optimisation — E takes no space |
struct M { [[no_unique_address]] E e; int i; }; | 4 | C++20: EBO for members too |
clang -Wpadded
tells you where it went; pahole on the object file draws the picture.Because Base's members sit at the front, a Derived* converts to a Base*
with no address change. A virtual call is load vptr; load vtable[n]; call — three instructions
and one unpredictable branch.
With struct D : A, B the B subobject cannot also be at offset 0. Converting
D* → B* adds a non-zero offset, and calling a B virtual through it must
subtract that offset back before this is right. The compiler emits a thunk to do the subtraction.
void* and back is undefined — the offset is lost. Comparing a D* with a
B* that point at the same object compares equal only because the compiler inserts the adjustment; do it
through void* and they differ. And delete through a base pointer requires a virtual
destructor, or the wrong offset is passed to operator delete.struct B : virtual A makes the A subobject shared, so its position is not known
until the most-derived class is known. The compiler adds a virtual base offset in the vtable and, during
construction, a VTT (virtual table table) to walk the partially-built object. The most-derived class —
not the intermediate ones — constructs the virtual base. Cost: an extra indirection on every access to a virtual
base member. Use it for interface mixins; never for data.
const std::type_info&; polymorphic if x has virtuals, else staticdynamic_cast<D*>(bp)→ nullptr on failure; walks the hierarchy at run time — not cheapdynamic_cast<D&>(br)throws std::bad_cast on failuredynamic_cast<void*>(p)→ address of the most-derived object; the one legitimate identity teststatic_cast downno check — UB if the object is not really a DA dynamic_cast in a hot loop is usually a design smell, but it is not forbidden; measure before
you contort the design to avoid it. -fno-rtti removes typeid and dynamic_cast
entirely and is not linkable against a library built with them.
Every expression has a type and a value category. Since C++11 there are three, defined by two independent questions: does it have identity (can you take its address), and can it be moved from.
| has identity | no identity | |
|---|---|---|
| cannot move | lvalue — a named variable, *p, a function call returning T& | — |
| can move | xvalue — std::move(x), a call returning T&&, a[i] on an rvalue array | prvalue — a literal, a+b, a call returning T by value |
"glvalue" = lvalue or xvalue (has identity). "rvalue" = xvalue or prvalue (movable). The names are awful; the distinction is not. It is what decides which overload you get and whether a temporary's storage is elided.
std::move on a const object still compiles (and copies)auto&&a forwarding reference in a deduced context — binds to anything, see the templates cardvoid f(T&& x), the parameter
x is an lvalue — it has a name and an address. Passing it on without
std::move(x) copies. This is the single most common move bug.Since C++17, a prvalue is not a temporary object that gets copied; it is an initialiser for whatever object it ends up in. There is no copy or move to elide because none was ever notionally there.
| Case | C++14 | C++17 |
|---|---|---|
return S{}; (prvalue) | copy elision permitted; copy ctor must exist | guaranteed; no copy/move ctor needed |
return local; (NRVO) | permitted, not guaranteed | still only permitted — falls back to a move |
return std::move(local); | defeats NRVO | defeats NRVO — do not write it |
return std::move(x) for a local. It turns a return that the compiler could
elide entirely into a guaranteed move, and -Wpessimizing-move will tell you so. The exception is returning
a member or a by-value parameter, where NRVO does not apply anyway.decltype reads value category back outT& for an lvaluedecltype(auto)deduce with decltype rules — preserves references, unlike plain autodecltype(f())T, T& or T&& exactly as f declares itA move is not a language operation. It is an ordinary overload — a constructor or assignment operator
taking T&& — that is allowed to gut its argument because the caller has promised not to use
the value again. std::move does not move anything; it is a cast.
The standard requires only that a moved-from standard-library object is in a valid but unspecified state:
you may destroy it, and you may assign to it. You may not assume it is empty. std::string usually is;
std::vector usually is; a moved-from unique_ptr is guaranteed null, because that is
specified separately.
noexcept on a move constructor is not decoration. std::vector
reallocation uses move_if_noexcept: if your move constructor is not noexcept, the vector
copies every element instead, to keep the strong exception guarantee. A missing noexcept silently
costs you the entire benefit of move semantics on your hottest path.std::forwardT&& is an rvalue reference except when T is a template parameter being
deduced in that position — then it is a forwarding reference and reference collapsing applies:
std::move on an rvalue reference, std::forward on a
forwarding reference, and each exactly once, on the last use. Moving twice is not a compile error and usually leaves
you with an empty string in production.Fix with a constraint: requires (!std::same_as<std::remove_cvref_t<T>, Greedy>),
or pre-C++20 an enable_if. This is the classic argument for constraining every forwarding-reference
constructor you write.
The compiler will synthesise six special members. Which ones it synthesises depends on what you declared, and the interactions are the least memorable part of the language — hence the table.
| You declare | default ctor | copy ctor | copy= | move ctor | move= | dtor |
|---|---|---|---|---|---|---|
| nothing | yes | yes | yes | yes | yes | yes |
| any constructor | no | yes | yes | yes | yes | yes |
| a destructor | yes | yes(d) | yes(d) | no | no | — |
| copy ctor or copy= | yes | the other is deprecated-but-generated | no | no | yes | |
| move ctor or move= | yes | deleted | deleted | the other is not generated | yes | |
(d) generated, but its generation is deprecated; -Wdeprecated-copy warns.
The whole table reduces to one line: declaring any of the five suppresses some of the others.
Write none of the five. Hold resources in members that manage themselves — vector,
string, unique_ptr — and the compiler-generated members are correct, optimal and free.
Every class you write should aim at this; a class that manages a resource is a separate, tiny class.
If you must write one, write all five, or = delete them deliberately.
One operator handles both copy- and move-assignment, is self-assignment-safe for free, and is strongly
exception safe. The cost is one extra move in the copy case, and it prevents assignment from reusing existing capacity
— which for a vector-like type is a real loss. Correct by construction, slower than a hand-written
pair. Use it when the type is not on a hot path.
= default is not the same as writing it outC++ has more initialisation syntaxes than it has meanings for them, and the mismatches are where the surprises live.
| Spelling | Name | What it does |
|---|---|---|
T t; | default-init | calls the default ctor; for a trivial type, leaves it uninitialised |
T t{}; | value-init | zero-initialises then default-constructs — the safe default |
T t(a, b); | direct-init | ordinary overload resolution over all constructors |
T t{a, b}; | list-init | initializer_list ctors are preferred; narrowing is an error |
T t = a; | copy-init | no explicit ctors considered |
T t = {a, b}; | copy-list-init | as list-init, but no explicit ctors |
T t = T(a); | — | since C++17, identical to T t(a) — no temporary |
initializer_list so hard it hurts.std::vector<int> v(3, 0); → three zeros.std::vector<int> v{3, 0}; → two elements, 3 and 0.initializer_list constructor, braces will find it even when another constructor is a
better match — and will only fall through if no conversion to the list's element type exists at all.Anything that can be parsed as a declaration is. Braces are the cure, which is the strongest
argument for the "almost always braces" style — with the initializer_list exception above as the
standing counter-example.
An aggregate has no user-declared constructors, no private or protected non-static data, no virtuals. It is initialised member-by-member from braces, and since C++17 base classes count as leading members.
Members are initialised in declaration order, never in the order written in the member-init list. Bases first, then members, then the constructor body. Destruction is exactly the reverse.
| Keyword | Question it answers | Since |
|---|---|---|
const | may I modify it through this name? | C++98 |
constexpr | may this be evaluated at compile time? | C++11 |
consteval | must this be evaluated at compile time? | C++20 |
constinit | must this static be initialised at compile time? (it stays mutable) | C++20 |
const is about the access path, not the object*pint* const pconst pointer — cannot rebind pconst int* const pbothread right-to-leftint const* p is the same as the first, and reads more consistentlyPhysical vs logical const. A const member function promises not to change the object's
observable state; mutable members (a cache, a mutex) are exempt. Since C++11, const also
means thread-safe to call concurrently — that is the contract the standard library relies on, so a
mutable cache in a const function needs its own synchronisation.
const_cast away and then write, and it is undefined if the object was
declared const. It is legal only for an object that was not originally const — which is to say, for
adapting to an old C API that forgot its consts.constexpr actually promises| Since | Newly allowed at compile time |
|---|---|
| C++11 | a single return statement |
| C++14 | loops, local variables, multiple statements, mutation |
| C++17 | if constexpr; constexpr lambdas |
| C++20 | new/delete (must be freed in the same evaluation), try,
virtual calls, constexpr unions, std::vector and std::string |
| C++23 | non-literal variables, goto, static in constexpr functions, constexpr cmath |
| C++26 | constexpr exceptions; constexpr placement new; static reflection |
constexpr on a variable is a much stronger claim than on a function. On a function
it is permission; on a variable it is a requirement, checked immediately. If you want the requirement on a call, assign
it to a constexpr variable or use consteval.A template is not code. It is a recipe the compiler runs once per distinct set of arguments, emitting a separate function or class each time. That is why C++ generics are as fast as hand-written code, why the error messages are enormous, and why templates must live in headers.
The compiler parses a template twice. At definition it checks everything that does not depend on the template parameters; at instantiation it checks the rest. Names are looked up in the corresponding phase, and a dependent name is not looked up until phase two.
this->.typename and template as disambiguatorsC++20 made typename implicit in the places where only a type could appear (return types,
member declarations, trailing return types, parameter declarations), which removes most of the noise but not all of it.
| Situation | Result |
|---|---|
template<class T> void f(T) with const int& | T = int — top-level const and references are stripped |
template<class T> void f(T&) with const int | T = const int — const is preserved |
template<class T> void f(T&&) with an lvalue | T = U& — forwarding reference |
array argument to f(T) | decays to a pointer; to f(T&) it does not, and T = U[N] |
| two parameters, two conflicting deductions | hard error — no conversions are applied to make them agree |
a non-deduced context (typename T::type) | not deduced; must be supplied or deduced elsewhere |
pair<int,double>; no make_pair neededstd::vector v{1,2,3};→ vector<int>std::lock_guard g{m};→ lock_guard<mutex>template<class T> Box(T) -> Box<T>;an explicit deduction guide, when the constructor is not enoughif constexpr, or constraints; reach for
template<> almost never."Substitution failure is not an error": if substituting deduced arguments produces an invalid signature, that candidate is silently removed rather than being a hard error. An error in the body is still an error.
Concepts move a template's requirements from the error message into the declaration. The practical win is not elegance — it is that a failed call now names the requirement it failed, and that constrained overloads can be ordered by how specific they are.
requires expressionrequires requires is not a typo. The first introduces a requires-clause, the
second a requires-expression: template<class T> requires requires(T a){ a.f(); } void g(T);.
It compiles, but naming the concept is nearly always clearer.The compiler decomposes constraints into atomic pieces and prefers the overload whose constraints subsume the other's. This gives ordered overloads without tag dispatch:
std::is_integral_v<T> is one opaque atom, while std::integral<T> decomposes. That
is the practical reason to build constraints out of named concepts rather than out of type traits.| Header | Concepts |
|---|---|
<concepts> | same_as, derived_from, convertible_to, integral, floating_point, assignable_from, swappable, destructible, constructible_from, copyable, movable, equality_comparable, totally_ordered, invocable, predicate |
<iterator> | input_iterator, forward_iterator, bidirectional_iterator, random_access_iterator, contiguous_iterator, sentinel_for, sized_sentinel_for |
<ranges> | range, view, borrowed_range, sized_range, viewable_range, common_range |
Three steps, in this order. Most surprises come from the fact that step 1 finishes before step 3 begins: a worse-matching function in a nearer scope beats a better-matching one further out, because the further one was never a candidate.
| Rank | Includes |
|---|---|
| Exact match | identity, lvalue-to-rvalue, array/function-to-pointer, qualification (T*→const T*) |
| Promotion | bool/char/short→int, float→double |
| Conversion | any other arithmetic conversion, derived→base pointer, anything→bool, →void* |
| User-defined | one converting constructor or one conversion operator — never two in a row |
| Ellipsis | ... — the last resort |
A non-template function beats a template on an equal-ranked match. Between two templates, the more specialised wins (partial ordering); between two constrained templates, the more constrained wins (subsumption).
For an unqualified call, the compiler also searches the namespaces of the argument types. This is why
std::cout << x finds std::operator<< without a using, and it is not
optional.
template<class T> void f(T&&) in an associated namespace is an exact match for everything,
so it wins over the overload you meant. This is exactly the Greedy constructor problem from the moves
card, at namespace scope. Constrain it.A friend defined in-class is only findable by ADL — it is not a member of the enclosing namespace at all. It therefore never pollutes overload sets it has no business being in, never needs to be a template, and cuts compile time. This is the modern default for operators.
Declaring any f in the derived class hides every f in the base
— overload resolution never sees them, because lookup stopped at the first scope that had the name. The
using-declaration pulls them back in.
-Woverloaded-virtual catches the variant of this that costs you a virtual
dispatch: a derived function that hides a base virtual instead of overriding it because the signature drifted. Marking
every override override catches it at the declaration instead.classstructidentical to class except the default is public, for members and for base classesAccess control is not a security boundary and does not affect layout. private members are still
there, still in sizeof, and still visible to a cast — the class definition is in the header either way.
B's constructor the
object is a B — the vptr has not been updated to D yet — so the call
dispatches to B::f, not the override. A pure virtual gets you the runtime's
pure virtual method called abort. The language is being consistent, not perverse: D's members
are not constructed yet, so D::f could not safely run.Never give a virtual function a default argument. If you need one, make the public non-virtual function carry the default and have it call a private virtual — the Non-Virtual Interface idiom, which also gives you a single place for pre- and post-conditions.
Pass polymorphic types by reference or pointer, never by value. Making the base class abstract, or its copy
constructor protected, makes the mistake a compile error.
explicit, and the C++20 formBox b = 42;, no silent conversion in a callexplicit operator bool() const;usable in if/while — a "contextual" conversion — but not in arithmeticexplicit(cond) Box(T);C++20: conditionally explicit, which is how std::pair is specifiedthis (C++23) — one function instead of fourThe base knows its derived type at compile time, so every call inlines. C++23's "deducing this" makes CRTP unnecessary for the common cases; it is still the tool for mixin interfaces.
= [] () -> and all conversions must be membersfree functionpreferred for symmetric binary operators, so the left operand converts toohidden friendthe modern default — free, ADL-only, no namespace pollutioncannot overload. .* :: ?: sizeof alignof typeid and the preprocessor's #| Return type | Meaning | Example |
|---|---|---|
std::strong_ordering | equal means substitutable | int, std::string |
std::weak_ordering | equivalent, but distinguishable | case-insensitive strings |
std::partial_ordering | some pairs are unordered | double — because of NaN |
a < b becomes (a <=> b) < 0;
a != b becomes !(a == b); and if the operands are the wrong way round the compiler will
reverse them rather than fail. Six operators from two declarations, and a heterogeneous comparison now works
in both directions from one definition.= default on <=> compares members in declaration order. That is
almost never the ordering you want for a type with a natural key. Write it out when the order matters:
return std::tie(a_,b_) <=> std::tie(o.a_,o.b_);1.5_m — the leading underscore is required for user codeoperator""s / ""sv / ""hthe standard's own, in std::literalstemplate<char…C> auto operator""_b()the literal's characters as template arguments — compile-time parsingThe implementation is table-driven and zero-cost on the non-throwing path: no flag is set on entry, no check is made on return. What you pay for is binary size (the unwind tables) and a slow throw — typically microseconds, because the runtime has to walk the stack consulting those tables.
exception_ptr; how you move one across a threadstd::rethrow_exception(p)the other end of that| Guarantee | Promise | Typical example |
|---|---|---|
| No-throw | will not throw at all | destructors, swap, move ctors |
| Strong | either it succeeded, or nothing changed | vector::push_back |
| Basic | invariants hold, no leaks; state unspecified | the default you should always meet |
| None | anything may have happened | a bug |
noexcept is a promise enforced by terminationstd::terminate — no unwinding, no catchnoexcept(expr)the operator: a compile-time bool, true if expr cannot throwvoid f() noexcept(noexcept(g()));conditional — propagate g's promise~T()implicitly noexcept since C++11; throwing from one during unwinding terminatesnoexcept genuinely matters: move constructors and move assignment (or
vector copies instead — see the moves card), swap, destructors, and the hash and
comparison functions of unordered containers. Elsewhere it is a constraint on your future self with little payoff,
because it cannot be relaxed without breaking callers.what() is the only member you get logic_errorinvalid_argument, domain_error, length_error, out_of_range runtime_errorrange_error, overflow_error, underflow_error, system_error bad_allocthrown by new; new(std::nothrow) returns null instead bad_cast / bad_typeidfrom dynamic_cast to a reference / typeid of a null bad_optional_access / bad_variant_accessoptional::value(), std::getand_then, transform, or_elsestd::error_codean OS error, where the caller decides whether it is exceptional-fno-exceptionsthe embedded/game dialect: throw becomes abort; the whole library must be rebuiltexpected; one that fails on malformed configuration files should throw.sizeof | Allocations | Copy | Use for | |
|---|---|---|---|---|
unique_ptr<T> | 8 | 1 (the object) | move only | the default — sole ownership |
unique_ptr<T,D> | 8 + sizeof(D) | 1 | move only | a custom deleter; stateless D costs nothing (EBO) |
shared_ptr<T> | 16 | 2, or 1 with make_shared | atomic refcount | genuinely shared ownership |
weak_ptr<T> | 16 | 0 | — | breaking cycles; caches; observers |
raw T* | 8 | 0 | — | non-owning observation — a perfectly good parameter type |
The object is destroyed when strong hits zero; the control block is freed when weak also hits
zero. make_shared puts the object and the control block in one allocation — faster, one cache line
— at the cost that a surviving weak_ptr keeps the object's storage (not the object) alive.
For a large object with long-lived weak references, that is a reason to prefer the two-allocation form.
shared_ptrs from the same raw pointer.
shared_ptr<T> a{p}; shared_ptr<T> b{p}; creates two control blocks and deletes the object
twice. If a class needs to hand out a shared_ptr to itself, derive from
std::enable_shared_from_this<T> and call shared_from_this() — and only ever after
a shared_ptr already owns it, or you get bad_weak_ptr.The member stays valid as long as any of these live. This is how you hand out a reference to part of a shared object without a second ownership scheme.
| Parameter | Says |
|---|---|
const T& / T* | I only look at it. The common case. |
unique_ptr<T> by value | I take ownership. Caller must std::move. |
shared_ptr<T> by value | I share ownership and may outlive you. Costs an atomic pair. |
const shared_ptr<T>& | I might copy it. Usually a sign the parameter should be const T&. |
unique_ptr<T>& | I may reseat it. Rare and worth a comment. |
shared_ptr is thread-safe about its refcount only. Copying a
shared_ptr from several threads is safe; writing through it, or reassigning the same
shared_ptr object from two threads, is not. The pointee needs its own synchronisation.| Container | Access | Insert | Erase | Find | Memory |
|---|---|---|---|---|---|
vector | O(1) | O(1) amortised at the back | O(n) in the middle | O(n) | contiguous — the default |
array<T,N> | O(1) | — | — | O(n) | on the stack, no indirection |
deque | O(1) | O(1) at both ends | O(n) middle | O(n) | chunked; pointer-stable at the ends |
list | O(n) | O(1) given an iterator | O(1) | O(n) | 2 pointers/node — almost never worth it |
forward_list | O(n) | O(1) after | O(1) after | O(n) | 1 pointer/node; no size() |
map/set | — | O(log n) | O(log n) | O(log n) | red-black tree; ordered, stable |
unordered_map/set | — | O(1) average | O(1) average | O(1) average | chained buckets — a pointer chase per lookup |
flat_map (C++23) | O(1) | O(n) | O(n) | O(log n) | two sorted vectors; cache-friendly, read-mostly |
priority_queue | top O(1) | O(log n) | O(log n) | — | a heap over a vector; max-heap by default |
vector until you have measured otherwise. A linear scan of a contiguous array
beats a tree or a hash for small n by a margin that surprises people — the crossover against map is
often in the hundreds of elements, because every node is a cache miss. std::list is almost never the right
answer; its O(1) splice is the one case that is.| Container | Insert invalidates | Erase invalidates |
|---|---|---|
vector | everything if it reallocates; else from the insertion point on | from the erase point on |
deque | all iterators; references survive if you insert at an end | all, unless at an end |
list/forward_list | nothing | only the erased element |
map/set (all four) | nothing | only the erased element |
unordered_* | iterators on rehash; references never | only the erased element |
deque insertion invalidates every iterator even though it
does not move the elements — iterators must know the chunk map. (2) Any vector operation that can
reallocate invalidates everything, so v.push_back(v[0]); is undefined unless the implementation
takes the copy first — which the standard does require for this case, but not for the general
v.push_back(f(v[0])). Reserve, or take the copy yourself.remove_if does not remove anything — it partitions and returns the new logical end.
Forgetting the second erase leaves the tail intact and the size unchanged. That is the erase-remove
idiom, and std::erase_if exists because everyone got it wrong.
emplacem.insert_or_assign(k, v)says which it did; operator[] default-constructs firstm.contains(k)C++20; better than count or find != endmap::operator[] inserts. if (m[k] == 0) on a missing key
default-constructs an entry and grows the map. It is also not available on a const map, which is how you
find out. Use at(), find() or contains() to read.istream_iteratoroutputwrite once, single pass — back_inserterforwardmulti-pass, ++ only — forward_listbidirectionaladds -- — list, maprandom accessadds +n, -, [], ordering — dequecontiguousC++17: and the elements are adjacent in memory — vector, array, string, spanC++20 respecified all six as concepts and decoupled the iterator from its sentinel, so a range's end need not be the same type as its begin — which is what makes an infinite range, or a null-terminated C string as a range, expressible at all.
The projection argument is the quiet win: it applies to each element before the comparator, so sorting by a member needs no lambda at all.
| View | Does |
|---|---|
filter, transform, take, drop | the basics |
take_while, drop_while | predicate-bounded |
reverse, join, split | restructuring |
iota, repeat (C++23) | generated; iota(0) is infinite |
enumerate, zip, adjacent, chunk, slide (C++23) | the ones people had been writing by hand |
elements, keys, values | tuple/pair projection — m | views::keys |
ranges::to<C>() (C++23) | materialise a view into a real container |
auto bad = make_vector() | views::filter(p);
leaves bad dangling — the temporary vector died at the end of the statement. Name the container
first. views::owning_view and the borrowed_range concept exist to make some of these a
compile error, but not all of them.filter's begin() is not O(1) and caches. It must scan for the first
match, so a filtered view is not a forward_range in the cheap sense and iterating it twice is not free.
It is also why filter | reverse often does not compile.reduce may reorder — only for associative ops; takes an execution policysort / stable_sort / partial_sort / nth_elementnth_element is O(n) — use it for a medianlower_bound / upper_bound / equal_range / binary_searchsorted input assumed, not checkedrotate / partition / stable_partition / shufflerearrangement without a temporaryuniqueadjacent duplicates only — sort firstset_union / set_intersection / set_differenceon sorted ranges, not on std::setlibstdc++ needs Intel TBB linked (-ltbb) for these to be anything but sequential; libc++
support is newer still. Check before you assume you got parallelism.
| Implementation | sizeof(std::string) | Inline capacity |
|---|---|---|
| libstdc++ (GCC) | 32 | 15 chars |
| libc++ (Clang/Apple) | 24 | 22 chars |
| MSVC | 32 | 15 chars |
Under the inline capacity there is no allocation at all — which is why passing short strings by
value is cheaper than people expect, and why a std::string member makes a class much bigger than a pointer.
string_view: a pointer and a lengthstring, literal, char*, all with no copys.substr(2,3)O(1) — no allocation, unlike string::substrs.data()not necessarily null-terminated — never hand it to a C APIstd::string{sv}the explicit copy, when you need to own itstring_view.
string_view member of a class is a lifetime contract you have to document. As a parameter it is
safe and excellent; as a return type or a member, think twice. -Wdangling-gsl catches the
easy cases.std::format (C++20) and std::print (C++23)The format string is checked at compile time — a wrong placeholder count or an inapplicable
specifier is a compile error, not a run-time surprise the way printf is. Specialise
std::formatter<T> to make your own type formattable; C++23 formats ranges and tuples out of the box.
invalid_argument / out_of_range; locale-dependentatoiundefined on overflow and silently returns 0 on garbage — do not use itstd::to_stringconvenient; locale-independent since C++17 but slower than to_charsstring and string_viewcontainsC++23s.resize_and_overwrite(n, op)C++23: fill a buffer without paying for the zeroing<charconv> / <string_view> / <format>the three headers this card lives inchar8_t / u8""C++20 made UTF-8 literals a distinct type — a breaking change from C++17A lambda is sugar for a unique unnamed class with an operator(). Knowing that answers every question
about what it costs and what it captures.
this as a raw pointer[&]everything used, by reference[this]the pointer — members are then accessed through it, by reference[*this]C++17: a copy of the whole object — what you want for an async callback[p = std::move(ptr)]C++14 init-capture: move into the closure[…args]pack capture; […args = std::move(args)] to move a pack in[=] does not capture members by value. It captures this, so
[=]{ return member_; } holds a raw pointer to the object and dangles the moment the object dies —
a classic use-after-free in any callback that outlives its owner. C++20 deprecates the implicit this
capture in [=]; write [*this] or [member_ = member_].operator() stops being const — you may modify by-value capturesconstexprC++17; implicit when the body qualifiesconstevalC++20staticC++23: no captures, and no implicit object parameter — a slightly cheaper callnoexceptas on any function-> Texplicit return type; needed when the deduced one is wrong (references, or two return statements)A captureless lambda converts implicitly to a plain function pointer — which is how you pass one to a C callback. Any capture at all and it cannot, because there is state to carry.
bind_back in C++23. Both beat std::bind, which you should not usestop_token. Use this one.std::thread t{f}; t.join();if a std::thread is destroyed still joinable, std::terminatestd::async(std::launch::async, f)→ future; whose destructor blocks — the notorious wartstd::packaged_task / promise / futurethe manual plumbingshared_mutexstd::call_once / once_flagexactly-once init; a function-local static already does thisstd::counting_semaphore / latch / barrierC++20cv.wait(lk) can return without a
notify — a spurious wakeup, and a real one on POSIX. Always cv.wait(lk, []{ return ready; });, and
always modify the predicate's state while holding the mutex, or you race with the wait.| Order | Guarantees | Use |
|---|---|---|
relaxed | atomicity only; no ordering with other variables | counters you only read at the end |
consume | data-dependent ordering — every implementation promotes it to acquire | do not use |
acquire | no later read/write moves before this load | the reader half of a handoff |
release | no earlier read/write moves after this store | the writer half |
acq_rel | both, for a read-modify-write | fetch_add on a lock |
seq_cst | a single total order across all seq_cst ops — the default | when you are not certain, which is most of the time |
seq_cst stores emit a fence
(an xchg or mfence). On AArch64 the difference is real and measurable. Write
seq_cst first, then relax only what a benchmark says is hot, and only with a model in your head you could
defend at a whiteboard.is_always_lock_free is the constexpr oneatomic_ref<T>C++20: atomic operations on an object you do not ownatomic<shared_ptr<T>>C++20; replaced the free atomic_load(shared_ptr*) overloadswait / notify_oneC++20: futex-style blocking on an atomic, no mutexthread_localone instance per thread; construction on first use, destruction at thread exitA function containing co_await, co_yield or co_return is a coroutine: the
compiler splits it into a state machine and heap-allocates the frame (elidable, and often elided). The standard ships
only the machinery — coroutine_handle, the promise-type protocol — and no usable types.
C++23 adds std::generator; for anything else you are using cppcoro, libunifex, or asio, until
std::execution lands in C++26.
UB is not "it might crash". The optimiser is permitted to assume UB never happens, and then to delete code that would only run if it did. That is why a UB bug so often manifests as a security hole rather than a fault.
| UB | Symptom | Caught by |
|---|---|---|
| use after free / after move | works until it doesn't | -fsanitize=address |
| reading an uninitialised value | nondeterminism, changes under -O2 | -fsanitize=memory (Clang), -Wmaybe-uninitialized |
| signed integer overflow | loops the optimiser proved could not end | -fsanitize=signed-integer-overflow |
| out-of-bounds index | silent corruption; v[i] is unchecked, v.at(i) is not | ASan; -D_GLIBCXX_ASSERTIONS |
| invalidated iterator | see the containers card | -D_GLIBCXX_DEBUG, _LIBCPP_HARDENING_MODE |
| data race | anything at all | -fsanitize=thread |
| strict-aliasing violation | a store the compiler reorders past a load | -Wstrict-aliasing=2; use std::bit_cast |
| misaligned load | fine on x86, a fault on some Arm | -fsanitize=alignment |
missing return in a non-void function | falls into whatever is next | -Wreturn-type — make it an error |
| dangling reference to a temporary | works in debug, fails in release | -Wdangling-gsl, ASan |
| deleting through a base without a virtual dtor | partial destruction, leak | -Wdelete-non-virtual-dtor |
| modifying an object twice in one expression | i = i++ | -Wsequence-point; C++17 fixed some of these |
| infinite loop with no side effects | deleted outright | nothing — know the rule |
-D_GLIBCXX_DEBUG is the heavyweight version-D_LIBCPP_HARDENING_MODE=_LIBCPP_HARDENING_MODE_EXTENSIVEthe libc++ equivalent-fstack-protector-strong -D_FORTIFY_SOURCE=2ship with theseA module is compiled once into a binary interface (BMI) and imported, so no textual re-parsing and no macro
leakage. The build-system problem is that modules must be built in dependency order, which the compiler can only
discover by scanning — hence -fmodules-ts era pain. CMake 3.28+ supports them for Ninja and MSVC;
GCC 15 and Clang 21 both build import std;, but it is still not something to reach for in a codebase you
need to ship this quarter.
<version>; test these, not __cplusplus.clang-format in the repo rootclang-tidythe linter — modernize-*, bugprone-*, performance-*, and it can --fixclangdthe language server; needs compile_commands.jsoninclude-what-you-useprunes the header graph, which is where your build time wentccache / sccachethe other place your build time wentgodbolt.orgCompiler Explorer — the fastest way to answer "what does this compile to"perf / Instruments / vtuneprofile before optimising, every timevalgrind --tool=memcheckslower than ASan, finds a different set; still worth a run| Standard | The things you actually use |
|---|---|
| C++11 | auto, lambdas, move semantics, nullptr, range-for, unique_ptr/shared_ptr, constexpr, variadic templates, <thread>, <atomic>, the memory model, enum class, override/final, = delete |
| C++14 | make_unique, generic lambdas, init-capture, relaxed constexpr, variable templates |
| C++17 | structured bindings, if constexpr, guaranteed copy elision, optional/variant/any, string_view, filesystem, CTAD, fold expressions, parallel algorithms, <charconv>, inline variables |
| C++20 | concepts, ranges, <format>, <=>, coroutines, modules, span, jthread, latch/barrier/semaphore, designated initialisers, consteval/constinit, <bit>, source_location, calendar/timezone <chrono> |
| C++23 | std::expected, std::print/println, deducing this, mdspan, flat_map/flat_set, generator, zip/enumerate/chunk, ranges::to, move_only_function, import std;, multidimensional operator[] |
| C++26 | static reflection, contracts, std::execution (senders/receivers), std::hive, function_ref, constexpr exceptions, erroneous (rather than undefined) uninitialised reads, #embed |
gcc -std=c++23 -x c++ -E -dM - < /dev/null | grep __cpp_lib
lists the library feature-test macros your toolchain actually implements, which is a very different list from what the
standard says. cppreference's compiler support table
is the other half of the answer.This is the one domain where C++ has no real competitor: it is the only language in wide use that will inline a type-safe abstraction over a memory-mapped register down to the same single store instruction you would have written by hand. Everything below is about keeping that property while giving up the parts of the runtime that assume an operating system.
The standard defines two implementation kinds. A freestanding one need only supply the headers that do not
require an OS — and until C++23 that list was almost useless. P1642 fixed it: the C++23 freestanding subset
includes most of <type_traits>, <concepts>, <utility>,
<array>, <span>, <bit>, <atomic>,
<ratio> and much of <algorithm>.
| Feature | Cost if you keep it | Verdict on a small MCU |
|---|---|---|
templates, constexpr, inline | zero | keep — this is the whole point |
| classes, references, overloading, namespaces | zero | keep |
| lambdas (captureless or small) | zero | keep |
virtual | +1 vptr per object, +vtable in flash, no inlining | fine in moderation; not in an ISR |
| exceptions | ~10–100 KB of unwind tables and personality routine | usually -fno-exceptions |
| RTTI | a type_info per polymorphic class | usually -fno-rtti |
<iostream> | ~200 KB and a static-init storm | never — and merely including it pulls it in |
std::string, std::vector, std::function | heap | avoid; see the fixed-capacity replacements below |
std::array, std::span, std::optional, std::bitset | zero | keep — all header-only, no allocation |
--gc-sections with the two -f*-sections flags is the big one: it discards every function
you did not call, which is what makes a template-heavy header library free.throw becomes a call to abort; try is a syntax error. Not standard C++, and the whole libstdc++ you link must match-fno-rttiremoves typeid and dynamic_cast; static_cast down is your only option and is unchecked-fno-threadsafe-staticsfunction-local statics lose their __cxa_guard_acquire lock — safe only if you never initialise one from two contexts-fno-use-cxa-atexitstops registering destructors for globals; on an MCU nothing ever exits, so the table is dead weightmain, from the startup code. On a bare-metal target
that is __libc_init_array() walking .init_array. If your linker script omits that section, or
your startup file never calls it, your global objects are never constructed and every field is zero — a
failure that looks exactly like a hardware problem. Check the map file for .init_array before blaming the
peripheral.The objection to new on an MCU is not speed, it is fragmentation: a long-running device with a
few-kilobyte heap will eventually fail an allocation it made successfully a thousand times before, and there is nobody
to restart it. Allocate everything statically, at startup, and never free.
| Instead of | Use |
|---|---|
std::vector<T> | std::array<T,N> + a size, or etl::vector<T,N>, or C++26 std::inplace_vector |
std::string | std::array<char,N> + std::string_view, or etl::string<N> |
std::function | a template parameter, a function pointer, or etl::delegate / std::function_ref (C++26) |
std::map | a sorted std::array + std::lower_bound, built constexpr |
new/delete | placement new into a static buffer, or a fixed-block pool |
| the heap generally | override operator new to call abort(), and let the linker prove nothing calls it |
The reason to write firmware in C++ rather than C is this card: you can wrap a peripheral in a type that makes the wrong call a compile error, and still emit the same instruction.
volatile — what it does and what it does notstd::atomic, which on Cortex-M is still a plain load or storeThe reinterpret_cast is technically not constexpr, which is why the pointer is
const rather than constexpr. Every embedded codebase does this; the standard has never quite
blessed it, and C++26's reflection does not change it either.
arm-none-eabi-g++ -Os -S, or paste it into
Compiler Explorer with an arm-none-eabi compiler selected. "Zero-cost"
is a claim about a specific compiler at a specific optimisation level, and -O0 will make a liar of you.A constexpr table with no runtime initialiser goes in .rodata and is executed in
place from flash — it costs no RAM at all. Drop the constexpr and the same array becomes a
.data object that must be copied from flash to RAM at startup. On a 32 KB part, that distinction is the
difference between fitting and not. Check with arm-none-eabi-size and the map file.
extern "C", or name
mangling means the linker quietly leaves the weak default handler in the vector table and your interrupt does nothing.
(2) No allocation, no exceptions, no printf, no blocking. (3) Share data with std::atomic,
never with volatile alone — on Cortex-M a std::atomic<uint32_t> is a plain
ldr/str, so it is free, and it is the only thing that actually orders the compiler.Saving and restoring PRIMASK rather than unconditionally re-enabling is what makes it safe to
nest — the naive version silently enables interrupts inside an outer critical section.
| Platform | Toolchain | C++ level | Reality |
|---|---|---|---|
| Arduino AVR (UNO R3, Nano) | avr-gcc | C++17 | no <thread>, no exceptions, tiny libstdc++; 2 KB RAM. String fragments the heap — use char[] |
| Arduino UNO R4 (RA4M1 + ESP32-S3) | arm-none-eabi-gcc | C++17 | Cortex-M4F, 32 KB RAM; comfortable for real C++. See the UNO R4 sheet |
| ESP32 / S3 / C3 | ESP-IDF (xtensa- or riscv32-esp-elf gcc) | C++23 (IDF 5.x) | FreeRTOS underneath, so std::thread and std::mutex work; exceptions off by default, switchable in menuconfig. See the ESP32 sheet |
| Raspberry Pi Pico | pico-sdk + arm-none-eabi | C++17/20 | CMake-native; two cores, and the SDK is C with C++ wrappers |
| Zephyr RTOS | west + Zephyr SDK | C++20 | C++ is a first-class option but the kernel API is C; no exceptions by default |
| Raspberry Pi 5 / Orange Pi | the distro's g++ | whatever GCC ships | full Linux — this is hosted C++, not embedded C++. The whole standard library is yours |
| Mbed / STM32Cube | arm-none-eabi | C++14–17 | HAL is C; Cube generates C. Wrap it, do not fight it |
platform.local.txt, to raise itrename .ino to .cppand you have ordinary C++ with none of the preprocessing magicprintf family-Wl,-Map=out.mapand grep it for .init_array, malloc, _ZTV (vtables)-Wl,--print-memory-usagea percentage-of-region summary at every linkarm-none-eabi-objdump -dwhen you need to see whether the template really did inlineprintf("%f") pulls in the whole soft-float formatting engine — typically
10–25 KB, and it is the single most common reason a firmware image will not fit. Use -u _printf_float
only when you truly need it, prefer integer formatting or fixed-point, and on newlib-nano keep
--specs=nano.specs. std::to_chars is a much cheaper float formatter where it is available.std::format before your toolchain has it; compiles small with FMT_STATIC_THOUSANDS_SEPARATORCMSIS / CMSIS-DSPArm's own headers: __disable_irq, atomics intrinsics, optimised DSP kernelsKvasir / modm / libopencm3constexpr register abstraction taken seriouslyCatch2 / doctest / GoogleTestrun the logic half of your firmware on the host, where a debugger is pleasant/DotsC++17 or earlierC++20C++23C++26deprecated