>_devkit
catalogue
Stablev1.0.0

modern-cpp

by @chempa

Use when writing, reviewing, or structuring C++ - ownership and lifetime, concepts, error handling, compile-time pipelines, concurrency, senders, and build hygiene.

Modern C++

Opinionated practices for C++ that has to be read, changed, and shipped by more than one person.

C++ hands you every mechanism and no opinion about which one to reach for. That is the whole difficulty. Almost every problem in a mature C++ codebase traces back to one of five things, so those are what this skill is about:

  1. A lifetime nobody owns: a reference, view, or pointer outliving its referent
  2. Inheritance and new used where a value and an algorithm would do
  3. Templates with no constraints, so errors land forty frames deep and builds crawl
  4. Undefined behaviour treated as a portability question
  5. A build with no warnings, no sanitizers, and no pinned toolchain

The baseline

Target C++23. Floor at C++20. Set it on the target, never globally:

target_compile_features(app PRIVATE cxx_std_23)

C++20 is the floor because concepts, ranges, std::span, std::jthread, and std::format each delete a category of hand-rolled workaround. C++23 is the target because std::expected and std::print delete two more, and because deducing this collapses the const/non-const overload duplication that bloats every accessor-heavy class.

StandardThe features that change daily code
C++20concepts, ranges, span, jthread/stop_token, format, constinit/consteval, designated initializers, <=>, source_location
C++23expected, print/println, deducing this, if consteval, mdspan, generator, ranges::to, flat_map, stacktrace, to_underlying
C++26std::execution (senders), reflection, contracts, pack indexing, inplace_vector, = delete("reason")

C++26 is feature frozen but not published, and compiler support for reflection and modules is uneven. Know what is coming; do not build the house on it yet. Senders are the exception, because a production reference implementation already ships (see below).

Ownership and lifetime

This is the one that costs money. The rest of the list is readability and build time; this one is a crash, a silent wrong answer, or a CVE.

Every object has exactly one owner, and the type says who.

IntentTypeNotes
Owns itvalue member, std::unique_ptr<T>The default. Try the plain value first.
Owns it, genuinely sharedstd::shared_ptr<T>A last resort, not a convenience. Shared ownership means nobody can say when it dies.
Observes it, must existT&Cannot be null, cannot be reseated.
Observes it, may be absentT*A raw pointer is fine, and is now documentation: it means non-owning.
Observes a sequencestd::span<T>, std::string_viewNever owns. Never store one without proving the owner outlives it.

Rule of zero. A class that manages no resource declares no destructor, no copy, and no move. Declaring one of the five forces you to reason about all five; declaring none means the compiler gets them right for free and keeps getting them right when a member changes. If you are writing a destructor, ask which member should have been a unique_ptr or a vector instead.

new and delete do not appear in application code. make_unique, make_shared, or a container. A bare new is a leak waiting for the next early return or thrown exception.

The specific ways this goes wrong, with fixes, are catalogued in references/lifetime.md. Read it before writing anything that returns a string_view, stores a lambda, or holds a reference across a coroutine suspension.

Let types carry the rules

A comment saying "must be sorted" is a comment. A type that can only be built sorted is a proof.

Constrain every template. An unconstrained template accepts everything and fails inside itself, which is where C++ template errors got their reputation. A concept moves the failure to the call site and names it:

// fails deep inside, naming types the caller never wrote
template <class R> auto total(R&& rows);

// fails at the call site, naming the requirement that was not met
template <std::ranges::input_range R>
  requires std::integral<std::ranges::range_value_t<R>>
auto total(R&& rows) -> std::ranges::range_value_t<R>;

This is not only ergonomics. Constrained overload sets are smaller, so overload resolution does less work, so the build is faster. It is the cheapest compile-time win available.

A closed set of types is a variant; an open set is an interface. std::variant plus std::visit gives exhaustiveness checking and value semantics. Virtual dispatch gives extensibility to a caller you have not met. If the set of shapes is fixed and lives in one file, the variant is almost always right and inheritance is almost always reflex.

Units live in the name, because the type system does not capture them: timeoutMs, priceMinor, sizeBytes. A strong type is better still once the value crosses a boundary. House standard: see the naming-conventions skill.

Special members, explicit, [[nodiscard]], enum class, and deducing this are in references/types.md.

Errors

Three mechanisms, three jobs. Confusing them is how a codebase ends up with bool-returning functions and out-parameters everywhere.

The failure isUseBecause
A bug in the calling codeassert, or a C++26 contractNot recoverable. Fail where it happened, not three frames later.
Expected, and the caller will handle itstd::expected<T, E>The signature says it can fail, and [[nodiscard]] makes you look.
Rare, and most callers can do nothingan exceptionIt unwinds past the frames that have no opinion.

Parsing a user-supplied date fails routinely, so std::expected. Running out of memory does not, so an exception. An index past the end of your own loop is neither: it is a bug, and hiding it behind either mechanism costs you the stack trace that would have found it.

noexcept where it is true, above all on moves. std::vector growth uses move_if_noexcept: a move constructor that is not noexcept makes reallocation copy every element to preserve the strong guarantee. One keyword, sometimes an order of magnitude.

std::expected's monadic operations, exception cost, and the -fno-exceptions case are in references/errors.md.

Compile-time pipelines

Ranges, expression templates, constexpr, and senders are all the same idea: fix the structure at compile time so runtime has nothing left to decide. The stages are types, composing them builds a type, and running it is one inlined call tree.

auto v = rows | std::views::filter(is_settled) | std::views::transform(to_minor);
// type is transform_view<filter_view<R, F>, G>; no indirect call survives inlining

Runtime overhead after inlining is roughly zero. Nothing else is, and you pay per stage:

CostDetail
Compile timeEach stage adds overload resolution and instantiation. Deep chains multiply.
Debug buildsWithout inlining every adaptor layer is a real call. A ranges pipeline at -O0 can be an order of magnitude slower than the hand loop.
Error messagesA constraint violation is reported inside the adaptor, not at your call site.
Binary sizeOne instantiation per unique type combination.

Three rules that keep the trade favourable:

  1. Type-erase at the component boundary. A pipeline is compile-time inside a

component and hidden behind a stable signature at its edge. Otherwise every caller re-instantiates it and every touch rebuilds the world. Same argument as the maintainability skill's blast radius, and it is why stdexec ships exec::any_sender_of.

  1. Name intermediate stages. Three named views beat one fifteen-stage

expression, for the compiler and for the next reader.

  1. Measure before guessing. clang -ftime-trace plus ClangBuildAnalyzer

tells you which template actually costs the build. It is usually one over-generic thing, and the fix is usually a concept.

The five techniques, when each applies, the expression-template dangling trap, and constexpr versus consteval are in references/compile-time.md.

Concurrency

std::jthread, never std::thread. It joins in its destructor and carries a stop_token. A bare std::thread going out of scope unjoined calls std::terminate, which is a production outage caused by an early return.

A data race is undefined behaviour, not a wrong answer. The optimiser is allowed to assume it cannot happen, so the symptom surfaces somewhere unrelated to the bug. Run a ThreadSanitizer build in CI.

Never write your own lock-free structure. Use a reviewed one. The memory model is subtle enough that the people who wrote it get it wrong, and a broken lock-free queue fails as data corruption rather than a crash.

Locks, atomics, memory orders, latch/barrier/semaphore, and false sharing are in references/concurrency.md.

Senders and receivers

std::execution (P2300) was adopted into C++26 and is the standard model for async and parallel work. It is also the most elaborate compile-time pipeline in the language: connect folds the whole graph into one nested operation state, with no allocation, no reference counting, and no vtables.

Use NVIDIA/stdexec, the reference implementation: header only, Apache 2.0, C++20 minimum, no dependencies. Do not assume #include <execution> gives you senders yet.

Reach for senders when there is real asynchrony to compose: work spanning several execution contexts, structured cancellation, fan-out and fan-in. Not to run one function on a thread pool.

The three things to know before writing any:

  1. then with a sender-returning function is almost always a bug. It

forwards the sender as a value instead of running it. You want let_value.

  1. when_all without starts_on per branch is concurrent, not parallel.

Every branch runs inline inside the caller's start.

  1. Nothing runs until a consumer runs it. A sender you build and drop is a

no-op, and nothing warns you.

The model, the algorithm catalogue, structured concurrency and scopes, writing your own sender, scheduler, and domain, and fifteen gotchas are in references/senders.md, sourced from the implementation's own docs and headers rather than from blog posts.

Testing

Catch2 or GoogleTest, run through CTest, wired into the presets. The parts specific to C++, because they are where suites are weakest:

  • A sanitizer build is a test type, not a debugging tool. An ASan and UBSan

run of the whole suite catches what assertions cannot: use-after-free, overflow, misalignment. Add TSan for anything threaded. These find real bugs in code whose tests are green.

  • Fuzz every parser. Anything reading bytes you did not produce gets a

libFuzzer target. It is about thirty lines and it finds the crash before somebody else does.

  • Test the compile-time part at compile time. static_assert over a

constexpr function is a test that cannot be skipped and costs nothing to run.

Maintainability

The C++ version of change cost, because the language has two coupling surfaces no other language has.

A header is an API and a build dependency at once. Every include is a recompile trigger for everyone downstream. Include what you use, forward declare in headers, keep the implementation in the .cpp. Adding one convenience include to a header that fifty translation units pull in is not a small change.

Templates put the blast radius in the type system. A template in a header is instantiated in every translation unit that touches it, so changing it rebuilds all of them and changing its interface breaks all of them at once. Type erasure at boundaries is what bounds this, and it is worth a virtual call to get.

PIMPL and modules fix this differently. PIMPL works today and costs an indirection plus an allocation. Modules fix it properly and cost a toolchain investigation first. Decide per project, not per file.

The test that settles most design arguments: could you delete this component in an afternoon? A well-bounded one is a directory, a CMake target, its tests, and one target_link_libraries line. If the answer involves untangling templates out of shared headers, the boundary is wrong.

See the maintainability skill for the general versions.

Copy: no em-dashes

Never use an em-dash (U+2014) in anything that ships: log lines, exception messages, CLI help, comments. A full stop or a colon is almost always the better edit.

Build and toolchain

Warning flags, sanitizer builds, CMake target hygiene, presets, package management, clang-tidy, and build-time measurement are in references/build.md. The one-line version: warnings as errors in CI, a sanitizer build in CI, and a checked-in .clang-format so formatting is never a review comment.

Sources

Reading list and provenance in references/sources.md.