C++: Modern C++
cpp, c++17, c++20, c++23, auto, structured bindings, optional, variant, ranges, views, coroutines, modules
Introduction
An engineer who’d written C++ in 2008 and spent the intervening years in other languages opened a modern codebase expecting the language she remembered: raw new paired with a delete somewhere far away, functions that returned a status code and filled in their result through a pointer argument, NULL sprinkled everywhere, and the boilerplate of an explicit iterator loop spelled out in full. What she found instead read like a different, calmer language. Ownership was declared in the type, not enforced by a comment. A function that might not have an answer said so in its return type. A transformation that used to be a fifteen-line loop was a three-line pipeline. The semicolons were familiar; almost nothing else was. She had the disorienting sense of meeting an old colleague who’d quietly become a better version of themselves.
The reputation of C++ as a minefield of dangling pointers and cryptic template errors was earned — by old C++. But the language reinvented itself starting with C++11, and each standard since has pushed in one direction: make the safe, expressive thing the default, without giving up the performance that is the only reason to choose C++ in the first place. Consider one bug class modern C++ simply deletes. The classic signature bool tryGetUser(int id, User* out) returns true/false and writes the answer through out — except when a caller forgets to check the return and reads *out anyway, or passes a null out, or two callers disagree on who owns it. None of those mistakes is possible with std::optional<User> getUser(int id): the absence of a value is now part of the type, and the compiler is on your side. The danger didn’t vanish — it moved, from the language itself to the legacy style people still write in it.
The Core Insight
The throughline of modern C++ is a single design goal: put correctness and expressiveness into the type system and the standard library, so the right thing is the easy thing — and do it at zero cost, the abstraction compiling down to exactly the code you’d have written by hand. Four families of features carry most of the weight.
The first is type deduction — auto and structured bindings — which removes the noise of spelling out types the compiler already knows, so code reads at the level of intent. The second, and the safety story, is the vocabulary types: std::optional puts absence in the type, std::variant puts a closed set of alternatives in the type, and std::expected (C++23) puts errors as values in the type — each replacing a fragile convention (a sentinel, a tag enum, an out-parameter plus error code) with something the compiler checks. The third is ranges and views: lazy, composable, pipe-able algorithms that turn raw loops into readable pipelines. The fourth is the newer machinery — coroutines for generators and async, modules for replacing the textual #include model — that modernizes how programs are structured and built.
What unifies them is the old C++ promise, kept: you don’t pay for what you don’t use, and what you do use is as efficient as hand-written code. std::optional costs about one extra byte over the value it wraps; std::variant is a union plus a tag, no more; a ranges pipeline fuses into a single loop with no intermediate containers. The abstraction is free, and the safety is not baggage you bolt on but the cheapest way to express the program.
A mental model
Think of modern C++ as a set of zero-cost abstractions layered over the metal. A useful image is a stencil: you express the idea at a high level — “the even numbers, squared” — and the compiler presses that stencil flat against the machine, leaving behind the same instructions a careful assembly programmer would have emitted. The expressive layer exists at compile time and evaporates by runtime. This is what lets C++ have both readable pipelines and the performance of a hand-rolled loop; the two are not in tension, because the pipeline is the loop once the compiler is done.
Ranges and views deserve their own picture: a conveyor belt that doesn’t run until something at the end pulls. Each view — a filter, a transform — is a station bolted onto the belt that describes a transformation but performs none of it. Assembling source | filter | transform builds the belt; it moves nothing. Only when a terminal action at the far end — a range-based for, an accumulate, a materialization into a container — starts pulling does an element travel down the belt, through every station, in one pass. No element is ever copied into an intermediate bin between stations. Figure 23.1 shows this shape.
When to reach for which feature
Modern features are tools, not obligations, and the judgment is in when each one earns its place.
Reach for std::optional when a function may legitimately have no answer — “find,” “lookup,” “parse” — instead of a sentinel like -1 or nullptr a caller can forget to check. Reach for std::expected (C++23) when the reason for failure matters and you don’t want exceptions in the hot path: it carries either the value or a typed error and forces the caller to confront both. Use std::variant when you have a closed set of alternatives — a token that is a number or a string or an operator — and want the compiler to make you handle every case; reach for inheritance when the set is open and grows by subclassing.
Reach for ranges and views when a transformation is a pipeline — filter, then map, then take — and the syntax clarifies intent over a hand-written loop; a single trivial operation is often clearer as a plain loop, so don’t force it. Reach for coroutines for generators and async I/O, where suspend-and-resume is the natural shape, but spend the complexity budget deliberately. And resist over-modernizing: auto that hides a load-bearing type hurts readers, and a pipeline contorted around an operation it doesn’t suit is worse than the loop it replaced. The goal is clarity and safety, not a feature checklist.
What you’ll learn
- How
autodeduces types and how structured bindings unpack pairs, tuples, and structs into named variables — and the one placeautosilently bites - How the vocabulary types —
std::optional,std::variant,std::expected— move absence, alternatives, and errors into the type system, deleting whole bug classes - How to handle a
std::variantexhaustively withstd::visit, and why the compiler can hold you to every case - How ranges and views compose lazily into pipelines that allocate nothing and run only when consumed — and why that costs no more than a raw loop
- What coroutines and modules are for, and the shape of the problems they solve
- How concepts fit as the modern constraint mechanism, and where the deeper template machinery lives
- The judgment to reach for each feature where it clarifies, and to leave it where it doesn’t
Prerequisites
- C++: Fundamentals: classes, templates as users (not authors), the STL containers and iterators, and how to compile with a chosen standard (
-std=c++20) - Comfort reading C++ syntax and a working toolchain: GCC 10+ or Clang 12+ for C++20, GCC 13+ or Clang 16+ for the C++23 features
- A working sense of why memory safety and reproducibility matter — the failure modes these features prevent
auto and structured bindings
The smallest modern conveniences are also the most pervasive, and they share a theme: let the compiler write down the types it already knows. auto asks the compiler to deduce a variable’s type from its initializer. The win is not laziness; it is that the code now reads at the level of intent, and that it can’t go subtly wrong when a type changes upstream. The canonical example is an iterator type so verbose that spelling it out obscures the one thing that matters — that you’re walking a container.
// The type is obvious from the call; auto lets the reader see the intent.
auto user = make_unique<Widget>(); // a unique_ptr<Widget>
auto it = scores.find("Alice"); // a map iterator — spelling it out adds nothingThe trap is that auto strips references and const by default. Writing auto config = getConfig(); where getConfig returns a reference silently copies the whole object; in a hot loop that one missing & turns a cheap binding into an expensive copy every iteration. The discipline is to reach for const auto& when you mean to bind, not own — especially in range-based for loops, where for (auto s : strings) copies every string and for (const auto& s : strings) copies none.
Structured bindings are the natural partner: they decompose an aggregate into named variables in one move, so the parts get meaningful names instead of .first and .second. Iterating a map yields a pair per element; binding it to [name, score] turns opaque positional access into readable code.
// Each element is a (key, value) pair; the binding gives the parts real names.
for (const auto& [name, score] : scores) {
std::cout << name << ": " << score << '\n';
}The same mechanism unpacks tuples, the result of map.insert, and your own structs. Paired with the if-with-initializer form — if (auto it = m.find(k); it != m.end()) — it keeps a variable scoped tightly to the block that uses it, which is a small, constant pressure toward code that’s easier to reason about.
The vocabulary types: putting absence, alternatives, and errors in the type
Here is where modern C++ buys the most safety per line. The vocabulary types are standard-library types whose entire job is to make a fact about your data explicit in its type, so the compiler can enforce what a convention only hoped for. There are three, and each replaces a specific, error-prone idiom from older C++.
std::optional<T> represents a value that might not be there. It replaces the trio of bad habits for “no result”: a sentinel like -1 or an empty string a caller might mistake for real data; a raw pointer that may be null and muddies ownership; or an out-parameter plus a separate boolean. With optional, the absence is in the type, and the caller can’t read a value that isn’t there without first asking.
// The signature itself tells the caller a lookup can fail.
std::optional<User> getUser(int id);
if (auto user = getUser(42)) { // contextually converts to bool
use(*user); // safe: we only deref inside the check
}
auto name = getUser(7).value_or(guest); // or supply a default in one stepstd::variant<A, B, C> represents exactly one of a closed set of types — a type-safe union that always knows which alternative it currently holds. It’s the tool for modeling a fixed set of alternatives without an inheritance hierarchy: a configuration value that’s an int, a double, or a string; a parser token; a result that’s either a payload or a status. The companion std::visit applies a callable to whichever alternative is active, and this is where the safety bites — written with the if constexpr idiom over the active type, a visitor that omits a case fails to compile, which is exhaustiveness checking the compiler performs for you.
using Value = std::variant<int, double, std::string>;
std::visit([](auto&& v) {
using T = std::decay_t<decltype(v)>;
if constexpr (std::is_same_v<T, int>) handle_int(v);
else if constexpr (std::is_same_v<T, double>) handle_double(v);
else handle_string(v); // every case covered
}, value);std::expected<T, E> (C++23) closes the trio: it holds either a value of type T or an error of type E, making errors-as-values a first-class return type. It’s the answer to the “optional can’t say why” problem and a lighter alternative to exceptions where the failure is expected and the hot path can’t afford to throw. A function returns std::expected<Config, ParseError>; the caller gets the config or a typed explanation of what went wrong, and the type forces them to acknowledge both. Together these three types encode “might be absent,” “is one of these,” and “succeeded or failed with a reason” directly in signatures — and a bug the type makes unrepresentable is a bug you will never debug.
Ranges and views: lazy, composable pipelines
The centerpiece of C++20’s expressiveness is the ranges library. A range is simply anything with a begin and an end — every standard container already is one — and a view is a lightweight, non-owning adaptor that lazily presents a transformed version of a range without copying it. The breakthrough is that views compose with the pipe operator, so a data transformation reads as a left-to-right pipeline instead of a nest of loops and temporary vectors.
// "The even numbers, squared" — read left to right, computed on demand.
auto even_squares = numbers
| std::views::filter([](int n) { return n % 2 == 0; })
| std::views::transform([](int n) { return n * n; });
for (int n : even_squares) { /* 4 16 36 ... */ }The property that makes this more than syntactic sugar is laziness. Building the pipeline computes nothing and allocates no intermediate container. Each view is a description of work wired to the next; the chain springs to life only when a terminal action — the range-based for above, a std::ranges::to, an accumulate — begins pulling elements through. When it does, one element flows through filter and transform and out, then the next, in a single fused pass. The contrast with the eager equivalent is stark: a std::copy_if into a temporary vector followed by a std::transform into another allocates two intermediate containers and walks the data twice; the view chain allocates zero and walks once. This is Figure 23.1 — the conveyor that doesn’t run until something pulls.
And it is zero-cost: the compiler inlines the view adaptors and fuses them into a single loop, so the pipeline compiles to the same instructions as a careful manual loop. You get the readability of a functional pipeline with the performance of hand-written C — which is the whole modern-C++ bargain in one feature. The price of admission is one new hazard, covered in the war story below: because a view borrows its source rather than owning it, a view that outlives the range it points into is a dangling reference.
Coroutines: generators and async, briefly
C++20 added coroutines — functions that can suspend execution, hand control back to the caller, and later resume exactly where they left off, with their local state preserved across the gap. Two problems take this shape naturally. The first is generators: a function that produces a sequence lazily, yielding one value at a time with co_yield, so an infinite sequence like the Fibonacci numbers becomes a plain loop with a co_yield in the middle, consumed one element at a time and computed only as far as the consumer asks. The second is asynchronous I/O: co_await lets a function pause while an operation is in flight and resume when it completes, so async code reads like sequential code instead of a tangle of callbacks.
The catch is that C++ coroutines are low-level machinery. The language provides the co_yield / co_await / co_return keywords and the suspend/resume mechanism, but you (or a library) must supply the promise_type plumbing that defines how a coroutine starts, suspends, and produces values. That scaffolding is genuinely advanced; for most code the right move is to use a coroutine type from a library rather than hand-roll one. What matters here is the mental model: a coroutine is a function with a pause button, and that single capability is what makes lazy generators and readable async possible.
Modules: replacing the textual #include
For its entire history C++ built programs through textual inclusion: #include literally pastes a header’s text into every file that uses it, so a header used by a thousand translation units is parsed a thousand times, and a macro defined before an #include can silently change what the header means. This is the root of C++’s legendary build times and a class of fragility all its own. Modules (C++20) replace that model. A module is compiled once into a binary interface and imported — import std; rather than dozens of #include lines — so the compiler reuses the parsed result instead of re-parsing source, builds get dramatically faster, and a module’s internals are genuinely encapsulated rather than leaking through the preprocessor. The cost today is maturity: toolchain and build-system support is still settling, and mixing modules with a large legacy header codebase is the awkward part. The interaction with how projects are compiled and linked is the subject of the Build Systems chapter; the point here is that modules are the long-arc replacement for the #include model that has defined C++ builds from the start.
Concepts: the modern constraint mechanism
One more C++20 feature rounds out the picture: concepts, named, readable constraints on template parameters. Where old generic code constrained types with SFINAE and std::enable_if — a technique whose error messages are a punchline — a concept states the requirement in plain terms (template <std::integral T>), and a type that fails to satisfy it produces an error naming which constraint was not met rather than pages of substitution failures. Concepts also let you overload on a type’s capabilities, and they are what makes the ranges library’s requirements expressible at all. They are mentioned here because every C++ programmer now reads them, but they are fundamentally a templates feature; the comparative treatment of generics and constraints across languages lives in Type Systems and Generics, while the C++-specific depth — defining concepts, requires expressions, constraint subsumption, and the template machinery underneath — is carried in this track.
A team replaced a tangle of filtering loops with a tidy ranges pipeline and shipped it. In review it looked perfect. In production it returned garbage intermittently — the kind of bug that passes every test and fails only under load. The cause was a helper that built a view over a temporary: auto active = make_users() | std::views::filter(is_active);. make_users() returned a std::vector by value, the pipeline borrowed it, and at the end of the full expression that temporary vector was destroyed — leaving active a view into freed memory. Every later read was undefined behavior, which on a quiet machine still held the old bytes and on a busy one did not. This is the one genuine footgun the ranges library introduces, the dark side of the borrow-don’t-own property that makes views cheap: a view never extends the lifetime of what it points at. The fix is to keep the source alive as long as the view — bind the owning range to a named variable first (auto users = make_users(); auto active = users | std::views::filter(is_active);), or materialize eagerly into a container the moment you cross a lifetime boundary. The same property that deletes the copy you didn’t want hands you a dangle you didn’t expect, if you forget who owns the data.
Practical exercise
Difficulty: Level I · Level II · Level III
- Level I — Replace sentinels and out-params with vocabulary types. Take a small C-style API: a
bool tryParse(const std::string&, int* out)that signals success through a boolean and writes the result through a pointer, plus a lookup returning-1on “not found.” Rewrite the lookup to returnstd::optional<int>and the parser to returnstd::expected<int, ParseError>(orstd::optional<int>on C++20). Update a caller to consume each with structured bindings orvalue_or, and write one sentence per function naming the bug class your new signature makes impossible. - Level II — Rewrite a hand-rolled loop as a ranges pipeline, and explain its laziness. Start with an explicit loop that filters a
std::vector<int>to the even numbers, squares them, and keeps the first five. Rewrite it as aviews::filter | views::transform | views::take(5)pipeline. Then explain, in a short paragraph, exactly how many elements are actually filtered and transformed when you consume the result — and why the answer is not “all of them” — contrasting the allocation and traversal count against the eagercopy_if-then-transformversion. - Level III — Model a closed state set with
variant+visit, and defend against the dangling-view pitfall. Model a small protocol message as astd::variantof a fixed set of state structs (sayConnecting,Connected,Disconnected), and write astd::visithandler for each. Show that omitting a case fails to compile, and explain why that exhaustiveness is the safety win over an inheritance hierarchy. Then, in the same program, build a view over a temporary range, demonstrate (or describe precisely) the dangling-view bug it causes, and rewrite it two ways — by naming the owning range and by materializing eagerly — articulating the lifetime rule that makes both fixes correct.
Summary
Modern C++ — the accumulated feature set of C++17, C++20, and C++23 — is close to a different language from the C++ of its reputation, with a singular design goal: make the safe, expressive thing the default without surrendering zero-overhead performance. Type deduction (auto, structured bindings) lets code read at the level of intent. The vocabulary types move absence (std::optional), closed alternatives (std::variant), and errors-as-values (std::expected) into the type system, deleting whole classes of sentinel, out-param, and null bugs the compiler now catches for you. Ranges and views turn loops into lazy, composable pipelines that allocate nothing, run only when consumed, and fuse at compile time into the same instructions a hand loop would emit. Coroutines bring lazy generators and readable async; modules replace the textual #include model for faster, better-encapsulated builds; concepts make template constraints legible. The danger in modern C++ has not disappeared so much as relocated — from the language to the legacy style still written in it, and to a few new footguns like the dangling view.
Key takeaways
- The vocabulary types put a fact about your data in its type — might be absent, is one of these, succeeded or failed with a reason — so the compiler enforces what a convention only hoped for; an unrepresentable bug is one you never debug.
- Ranges and views are lazy and composable: building a pipeline computes nothing, consuming it runs one fused pass with no intermediate containers, and it costs no more than the loop it replaces.
autostrips references andconstby default; reach forconst auto&to bind rather than copy, especially in range-basedfor.std::visitover astd::variantgives compiler-checked exhaustiveness over a closed set — the safety advantage over open inheritance hierarchies.- A view borrows, never owns: keep the source alive at least as long as the view, or materialize eagerly across a lifetime boundary, or you’ll dangle.
- Don’t over-modernize — every feature is a tool for clarity and safety, not a box to tick; the loop that’s clearer than the pipeline is the better code.
Connections to other chapters
- C++: Fundamentals (prerequisite): the classes, templates, containers, and iterators introduced there are the substrate every feature here builds on — ranges are an abstraction over iterators, and the vocabulary types are ordinary library types you use exactly as you learned to use the STL.
- Memory and Resource Management (sibling): move semantics and smart pointers are the reason returning a
std::optional<BigThing>or astd::vectorby value is cheap rather than a copy — value semantics throughout this chapter rely on the move machinery, and the dangling-view footgun is the same lifetime reasoning as a dangling reference. That chapter places C++’s model beside how the other languages manage resources; the move-semantics and smart-pointer depth lives in this C++ track. - Type Systems and Generics (extension): concepts and the ranges library are built on templates; this chapter uses them as a consumer. That chapter compares generics and constraint mechanisms across the six languages, with the C++-specific authoring of concepts,
requiresexpressions, and the template machinery underneath developed in this track. - Java: Streams & Functional and the Rust chapters (contrast): C++ ranges and views are the close cousin of Java’s lazy
Streampipelines and Rust’s iterator adaptors, andstd::optional/std::expectedmirror Java’sOptionaland Rust’sOption/Resultalmost exactly — the instructive differences are ownership (a C++ view borrows and can dangle where a Java stream cannot) and cost model (C++ fuses to zero overhead where the JVM relies on the JIT).
Further reading
Essential
- Scott Meyers, Effective Modern C++ — 42 items on using C++11/14 well; the canonical guide to the habits (
auto, smart pointers, move) that this chapter assumes. - Bjarne Stroustrup, A Tour of C++ (3rd ed.) — a fast, authoritative pass over the modern language from its designer, current through C++20.
Deep dives
- Nicolai Josuttis, C++20 — The Complete Guide — concepts, ranges, coroutines, and modules explained in depth, the reference for the C++20 surface taught here.
- Eric Niebler’s writing and talks on ranges — the designer’s account of the laziness, composition, and view-lifetime model behind the library.
Historical context
- The C++20 and C++23 standard overviews (the official feature lists and cppreference summaries) — what each standard actually added, and the compiler support matrix for adopting it.
- The Coroutines TS background — the technical-specification work that became C++20 coroutines, useful for understanding why the
promise_typemachinery looks the way it does.