Modern C++ (C++20/23)
C++ in 2026 is genuinely a different language from what was taught in universities through most of the 2010s. The additions accumulated across C++11, C++14, C++17, C++20, and C++23 have reshaped the idioms, the error model, and the library surface to a degree that a careful C++23 codebase bears only superficial resemblance to the raw-pointer, std::enable_if-riddled templates and printf-formatted output of legacy codebases. That transformation is real and worth understanding. But so is the honest caveat: the language accreted all of this on top of a 40-year substrate. Complexity is not shrinking. The build tooling and packaging ecosystem is still catching up to what Go and Rust shipped from day one. And the safety model — for all its RAII discipline and smart-pointer conventions — remains fundamentally opt-in in ways that Rust’s borrow checker is not.
The point of this post is to map the modern feature set with technical precision: what each major C++20/23 feature actually does at a mechanical level, what problem it solves, which compilers support it as of mid-2026, and where the gotchas live. Then an honest comparison against Rust — not a tribal war, a pragmatic scorecard.
Concepts: Retiring SFINAE
Before C++20, constraining a template to only accept types with certain properties required SFINAE — Substitution Failure Is Not An Error. In practice, SFINAE meant writing std::enable_if_t in trailing return types or non-type template parameters, relying on the compiler’s willingness to silently discard substitution failures during overload resolution. It worked. It also produced error messages spanning hundreds of lines tracing through template instantiation stacks, with the actual constraint violation buried somewhere in the middle.
Concepts replace this with first-class language support. A concept is a named Boolean predicate over template parameters, evaluated at compile time. The compiler checks it before instantiation, not during it, and emits a targeted diagnostic when the check fails.
|
|
The standard library ships a rich set of pre-built concepts in <concepts> — std::integral, std::floating_point, std::regular, std::totally_ordered, std::invocable, and more. The requires clause and requires expression are distinct: the clause applies a constraint, the expression tests whether a set of operations are syntactically valid. Nesting them (requires requires(T x) { ... }) is legal but a code smell; name the concept instead.
Compiler support for concepts is complete across GCC 10+, Clang 10+, and MSVC 19.23+. As of GCC 14/15 and Clang 18/19, concept error messages are considerably more readable than their predecessors, though “readable” remains relative when deep template hierarchies are involved.
Ranges and Views: Lazy Pipeline Composition
The ranges library (C++20) reorganized the STL around two abstractions: a range (anything with begin()/end() semantics) and a view (a lazy, non-owning range adaptor). The key insight is composability: views pipe together with | to build a transformation pipeline that allocates nothing and computes on demand.
|
|
Nothing in that pipeline allocates. No intermediate vector is created. Elements flow through the composed adaptors one at a time as the range-for loop advances the iterator. The compiler typically inlines and optimizes the chain to assembly equivalent to a hand-written loop.
Data source (vector)
|
[filter: even] <-- no copy, lazy predicate per element
|
[transform: x*x] <-- no copy, lazy function per element
|
[take: first 3] <-- halts iteration after 3 elements
|
range-for loop <-- the only code that "pulls" values
C++23 expanded the ranges vocabulary significantly. std::ranges::to<std::vector>() materializes a view into a concrete container — the missing “sink” that C++20 omitted and forced workarounds for. New views in C++23 include std::views::zip, std::views::enumerate, std::views::chunk, std::views::slide, std::views::join_with, and std::views::repeat. The std::generator<T> type (also C++23) brings coroutine-based range generation into the standard library, filling a gap that previously required third-party libraries.
One practical caution: views are lazy, which means re-traversing an expensive pipeline re-evaluates every element. There is no memoization. For pipelines where each element’s computation is non-trivial, materialize with std::ranges::to or populate a container explicitly rather than iterating the view multiple times.
Coroutines: Powerful Machinery, Thin Standard Library
C++20’s coroutine support is a compiler feature, not a library feature. The standard defines the transformation rules for functions containing co_await, co_yield, or co_return, and the protocol types (promise_type, awaitable concepts) a library must implement. It does not ship ready-to-use coroutine types for async tasks, generators, or channels. That gap was intentional — the committee wanted to standardize the mechanism and let the ecosystem converge on idioms — but in practice it left users either writing their own coroutine framework or pulling in a library.
The transformation is roughly: a coroutine function’s body is split into a state machine stored on the heap (the coroutine frame), with suspension points at each co_await or co_yield. The caller receives a handle to the frame and can resume or destroy it. The machinery is zero-overhead when the optimizer can elide the heap allocation (HALO — Heap Allocation eLision Optimization), which GCC and Clang do in many straightforward cases.
|
|
This is roughly 30 lines to get a generator that in Python is four. Writing correct promise_type implementations is genuinely tricky; lifetime interactions between the coroutine frame and the objects it references are a common source of bugs. C++23 added std::generator<T> to the standard library, eliminating the need to write the above boilerplate for the simple generator use case.
For async I/O coroutines, the ecosystem relies on libraries: Asio (both standalone and Boost), libcoro, async_simple (from Alibaba), and others. The original cppcoro by Lewis Baker — the reference implementation that inspired much of the C++20 design — is unmaintained, with its author having moved focus toward std::execution (which landed in C++26 as part of the sender/receiver model). The async story in C++ is still fragmenting across frameworks in 2026.
Modules: The Rocky Road to import std;
C++ modules are the most ambitious structural change since templates. The promise: replace the textual inclusion model (which re-parses headers on every translation unit) with a binary interface format, enabling faster builds, proper encapsulation of implementation details, and elimination of macro leakage between compilation units.
The reality in 2026 is more complicated. Compiler support for modules is technically present in GCC 11+, Clang 16+, and MSVC 2019+. The standard library itself can be imported via import std; (a C++23 feature) and import std.compat;. In isolation, with a single compiler, modules largely work. The problems are systemic.
Build system integration is still painful. CMake 3.28 added native module support, but using it requires careful configuration of FILE_SET CXX_MODULES and, for import std;, the CMAKE_CXX_MODULE_STD flag — which as of CMake 4.0 still needed the CMAKE_EXPERIMENTAL_CXX_IMPORT_STD guard key. Ninja and MSBuild handle the dependency graph differently. Clang, GCC, and MSVC emit different binary module formats (.pcm, .gcm, .ifc) with no interoperability.
IDE tooling lags. Visual Studio 2026 ships IntelliSense support for modules labeled “experimental.” Clangd support has improved but remains incomplete in some configurations. Code navigation, refactoring, and include-what-you-use equivalents for modules do not yet exist in polished form.
Third-party library adoption is near zero. Six years after C++20’s ratification, essentially no popular C++ libraries ship module interface units. Mixing header includes with module imports in the same translation unit requires careful handling of the global module fragment (module; before the module declaration), and intermixing STL headers with import std; has caused ODR (One Definition Rule) violations on MSVC in the past.
A minimal module looks like this:
|
|
Building this with CMake requires:
|
|
The module story will improve. C++26 tooling investment is real. But if you are starting a new project today and weighing modules against well-structured headers with #pragma once, the honest answer is: headers are still less friction unless your build times are so painful that the complexity trade-off is worth it.
Error Handling: std::expected and the Exception Alternative
C++ exceptions have always had a fundamental tension in systems programming: they are zero-cost when not thrown but impose ABI overhead on all code compiled with them, can be globally disabled (-fno-exceptions) in embedded or latency-sensitive contexts, and make error propagation invisible at call sites. The traditional alternative — returning error codes — requires disciplined checking and loses the error context unless you roll your own result type.
C++23’s std::expected<T, E> is the standardized result type, modeled explicitly on Rust’s Result<T, E>. A function returns either the expected value or an unexpected error:
|
|
The monadic methods (.and_then, .transform, .or_else, .transform_error) enable chaining operations without manual error checks at each step. This is the C++ approximation of Rust’s ? operator, but without language-level sugar — you write out the chain explicitly. The ergonomics are serviceable and substantially better than cascading if (err) return err; patterns.
Formatting: std::format and std::print
std::format (C++20) is printf with type safety and extensibility. The format string uses {} placeholders with optional format specs, checked at compile time in C++20 and fully consteval-friendly. No more %d vs %ld vs %zu mismatches silently corrupting output.
|
|
std::print and std::println (C++23) combine formatting with output, replacing the std::cout << stream syntax and the printf family in new code. They correctly handle Unicode on Windows (writing to the native console API rather than the C runtime), something printf has historically botched.
Compiler support: std::format is complete in GCC 13+, Clang 17+ (libc++), and MSVC 19.29+. std::print landed in GCC 14, Clang 18, and MSVC 19.38. libstdc++ (GCC’s standard library) and libc++ (Clang’s) both have full C++23 library coverage in their current releases.
The Spaceship Operator and Defaulted Comparisons
Pre-C++20, a type needing a full set of comparison operators required six separate definitions, all boilerplate, all opportunities for subtle inconsistencies. The three-way comparison operator <=> compresses this:
|
|
The return type of <=> is one of three ordering categories: std::strong_ordering (substitutable equivalents are equal, integers), std::weak_ordering (equivalents may not be identical, case-insensitive strings), or std::partial_ordering (some pairs are unordered, floats with NaN). = default deduces the category from the members.
One gotcha: if you write a custom operator<=> rather than defaulting it, the compiler does not automatically synthesize operator==. You must provide operator== separately. Forgetting this produces code where a == b and !(a != b) may disagree with (a <=> b) == 0. The fix is mechanical — always pair a custom <=> with an explicit == — but it is easy to miss.
Smart Pointers and RAII: The Actual Safety Model
The C++ safety model is not built into the language grammar the way Rust’s borrow checker is. It is a convention enforced by idioms. The central idiom is RAII — Resource Acquisition Is Initialization: tie the lifetime of every resource (heap memory, file handles, locks, network connections) to a stack-allocated object whose destructor releases it. When the object goes out of scope, the resource is freed, regardless of how the function exits (normal return, exception, early return).
Stack frame
┌──────────────────────────────────────────────────┐
│ unique_ptr<Foo> p = make_unique<Foo>(...); │
│ ↕ owns │
│ [heap: Foo object] <-- freed when p destroyed │
│ │
│ shared_ptr<Bar> sp1 = make_shared<Bar>(...); │
│ shared_ptr<Bar> sp2 = sp1; // refcount = 2 │
│ ↕ shared ownership │
│ [heap: Bar + control block (refcount=2)] │
│ freed when last shared_ptr destroyed │
│ │
│ weak_ptr<Bar> wp = sp2; // does not own │
│ wp.lock() -> returns shared_ptr or nullptr │
└──────────────────────────────────────────────────┘
std::unique_ptr is the default: single-owner, zero overhead over a raw pointer, move-only. std::shared_ptr adds reference counting for shared ownership — use it when ownership genuinely needs to be shared, not as a default, because the atomic refcount has cost and shared_ptr cycles are a leak waiting to happen. std::weak_ptr breaks cycles: it holds a non-owning reference that can be upgraded to a shared_ptr only if the object still lives.
The Rule of Zero: if your class only holds RAII members (smart pointers, containers, std::string), declare no destructor, no copy constructor, no copy assignment operator, no move constructor, no move assignment operator. The compiler generates correct defaults. The moment you manually manage a resource, you need all five (or explicitly = delete the ones that shouldn’t exist). Raw new and delete in modern C++ are code smells. They belong in custom allocators and data structure internals, not in application logic.
|
|
The discipline works — enormous production codebases (Chrome’s renderer, LLVM itself, AAA game engines) are written this way with minimal memory safety incidents. But “works when followed” is different from “enforced by the compiler.” A junior engineer can still call .get() on a unique_ptr, store the raw pointer, and use it after the unique_ptr is destroyed. The compiler will not stop them. Sanitizers (ASan, UBSan, ThreadSanitizer) catch these at runtime during testing. Rust’s borrow checker catches them at compile time, always.
Standard Library Highlights
A few other additions that belong in every modern C++ engineer’s toolkit:
std::span<T> and std::string_view — non-owning views over contiguous memory and character data respectively. Pass these instead of const std::vector<T>& or const std::string& when you don’t need ownership. They compose with the ranges machinery and avoid unnecessary copies.
std::jthread and std::stop_token (C++20) — std::thread with cooperative cancellation built in. The stop token mechanism threads (pun intended) through blocking waits (std::condition_variable_any::wait) so you can interrupt a waiting thread without ad-hoc flags and racy signal patterns.
std::mdspan (C++23) — a multidimensional non-owning view over contiguous data. Zero overhead, compile-time or runtime extents, pluggable layout policies (row-major, column-major, strided). This is what NumPy’s array view is, but in C++ with layout customization points. Critical for HPC and scientific computing code that previously relied on raw pointer arithmetic or third-party array libraries.
constexpr and consteval — constexpr has expanded from C++11 through C++23 to the point where full std::vector and std::string operations are constexpr (C++20). consteval (C++20) forces a function to only ever execute at compile time — useful for validated literal factories and compile-time parse tables.
Designated initializers (C++20) — aggregate initialization with named fields: Config c = {.host = "localhost", .port = 8080};. Less fragile than positional initialization when structs gain new fields.
| Feature | Standard | GCC | Clang | MSVC |
|---|---|---|---|---|
| Concepts | C++20 | 10 | 10 | 19.23 |
| Ranges / Views | C++20 | 10 | 13 | 19.29 |
| Coroutines | C++20 | 10 | 8 | 19.25 |
| Modules (basic) | C++20 | 11 | 16 | 19.28 |
std::format |
C++20 | 13 | 17 | 19.29 |
import std; |
C++23 | 15 | 18 | 19.36 |
std::expected |
C++23 | 12 | 16 | 19.36 |
std::print |
C++23 | 14 | 18 | 19.38 |
std::mdspan |
C++23 | 13 | 17 | 19.36 |
std::generator |
C++23 | 14 | 17 | 19.38 |
std::ranges::to |
C++23 | 14 | 17 | 19.38 |
Build Tooling and the Ecosystem Reality
The C++ build ecosystem is a historical accident. CMake became the de facto standard not because it is good but because it is universal. It generates build files for Ninja, Make, Visual Studio, Xcode, and others. A project using CMake with target_include_directories, target_link_libraries, and target_compile_features in a modern target-based style is portable and composable. Legacy CMake with global include_directories and add_definitions is an archaeology project.
Package management remains fragmented. vcpkg (Microsoft) and Conan 2.x are the dominant options. Both integrate with CMake. Neither is Cargo. Versioning, dependency resolution, and reproducible builds require significantly more ceremony than cargo add or go get. The fuzzing-for-developers and sanitizer workflows integrate naturally via CMake presets — AddressSanitizer, UndefinedBehaviorSanitizer, ThreadSanitizer, and MemorySanitizer are enabled by -fsanitize= flags and belong in your debug/test build configurations permanently, not as something you turn on when a bug is reported.
clang-tidy is the static analysis workhorse. With modernize-*, cppcoreguidelines-*, and readability-* checks enabled in a .clang-tidy file and integrated into CI, it catches a large class of issues before they reach review — cppcoreguidelines-owning-memory flags raw new/delete, modernize-use-nullptr catches NULL and 0 as null pointers, readability-identifier-naming enforces naming conventions.
See also the debugging-strategies post for a systematic approach to diagnosing runtime failures in C++ systems — the tools (gdb, rr, sanitizers, core dumps) are unchanged by language version but the modern idioms reduce the frequency of the nastiest classes of bugs.
C++26: What Is Landing Next
C++26 was finalized at the ISO meeting in London in March 2026. The headline features are genuine step-changes.
Static reflection (std::meta) gives compile-time introspection: iterate over a struct’s members, query a function’s parameters, generate code from type metadata. This enables serialization, RPC stubs, ORM-style binding, and compile-time assertions about ABI layout — all without macros or external code generators. GCC trunk has reflection merged and partially working; Clang is catching up.
Contracts add language-supported preconditions, postconditions, and assertions: [pre: x > 0], [post r: r >= 0], contract_assert(ptr != nullptr). Four enforcement modes (ignore, observe, enforce, quick-enforce) allow gradual adoption. The feature passed with a non-unanimous vote — concerns about the semantics of “observe” mode and the interaction with undefined behavior remain in the community — but it is in the standard.
std::execution (the P2300 sender/receiver model) is the async framework that should eventually unify coroutines, thread pools, and I/O. It is ambitious. It is also the reason cppcoro’s author stopped maintaining it.
Where Rust Pulls Ahead: An Honest Scorecard
The Rust for systems programming post covers Rust’s model in depth. The condensed comparison for engineers evaluating C++ vs Rust for a new systems project:
| Dimension | C++ | Rust |
|---|---|---|
| Memory safety | By convention (RAII, smart ptrs, sanitizers) | By construction (borrow checker, compile-time) |
| Thread safety | By discipline (locks, atomics, ThreadSanitizer) | By construction (Send/Sync, data races = compile error) |
| Use-after-free | Possible; caught by ASan at test time | Impossible; rejected at compile time |
| Error handling | std::expected (C++23) or exceptions |
Result<T,E> + ? operator, idiomatic |
| Package management | vcpkg / Conan (functional, not seamless) | Cargo (excellent, first-class) |
| Build system | CMake (universal, baroque) | Cargo (integrated, reproducible) |
| Compile-time metaprogramming | Templates + concepts + reflection (C++26) | Macros + const generics (capable, less powerful) |
| Ecosystem depth | Unmatched (games, HPC, finance, embedded, 40yr) | Rapidly growing, gaps in niche domains |
| Interop with existing C++ | Native (same ABI, same translation unit) | Requires FFI, bindgen, careful effort |
| Learning curve | Very high (undefined behavior, template errors) | High (borrow checker) |
Rust’s borrow checker is not just RAII with extra steps. It statically proves at compile time that no two pieces of code can simultaneously hold a mutable reference to the same data — eliminating an entire class of concurrency bugs that C++ can only find at test time with ThreadSanitizer, if your test suite exercises the race condition. For new code in security-critical domains (OS components, network services, cryptography), the Rust safety guarantee has genuine value that C++ discipline cannot replicate.
Where C++ still wins: if your project has ten million lines of existing C++ code, you are not rewriting it. If you need a library that only exists in C++ (a game engine, an HFT trading framework, a genomics pipeline built on BLAST or HTSlib), you are staying in C++. If you need compile-time metaprogramming with the power of C++ templates and C++26 reflection, Rust is not there yet. And for developers already expert in C++, the productivity differential is smaller than advocates on either side admit.
Google’s Carbon language — covered in Carbon, Google’s C++ successor — represents one attempt to address C++’s structural problems (ABI, build model, syntax) without abandoning the existing ecosystem. It remains experimental as of mid-2026, but it illustrates the degree to which C++’s problems are recognized internally by its largest users.
The Rust for Systems Engineers: Practical Onramp post is the right next read if Rust’s safety guarantees are relevant to your threat model and you are evaluating whether the learning investment pays off.
Verdict
Modern C++ — meaning C++20 and C++23 written with concepts, ranges, std::expected, std::format, smart-pointer discipline, and sanitizers in CI — is substantially safer and more expressive than C++17 and everything before it. The quality of life improvements are real. Concepts produce comprehensible template errors. Ranges eliminate the intermediate-allocation boilerplate of algorithm chains. std::expected makes error paths visible. std::format ends a decades-long string-formatting embarrassment.
The caveats are also real. Modules are technically present but practically immature in 2026 — toolchain support exists, ecosystem adoption does not, and the build system story requires careful navigation. The coroutine machinery is powerful but the standard library provides almost no ready-to-use types; you are still library-dependent for any real async work. The language’s complexity has not decreased; it has grown. The safety model remains convention-based, not enforced.
For greenfield systems work where safety and correctness are paramount, Rust deserves serious evaluation. For projects deep in existing C++ codebases, for domains where C++ library ecosystems are irreplaceable, or for teams where compile-time metaprogramming is a first-order concern, C++23 with modern idioms is a strong foundation. The tools are good enough to write safe, fast, maintainable code — provided the team has the discipline to use them.
C++26, with static reflection and contracts landing in compilers through 2026-2027, is a language that continues to evolve in meaningful ways. Whether that evolution outpaces the accumulating complexity is a question the next few years will answer.
Sources
- cppreference.com — Compiler support for C++20/23
- cppreference.com — Compiler support for C++23
- C++23 Support in MSVC Build Tools 14.51 — Microsoft C++ Team Blog
- Clang — C++ Programming Language Status
- Can we finally use C++ Modules in 2026? — Mathieu Ropert
- C++ Modules in 2026: Game-Changer or Overhyped? — Visual Assist / Whole Tomato
- Are C++ modules there yet? — Jakub Neruda, Medium
- C++26 Draft Finalized with Static Reflection, Contracts, and Sender/Receiver Types — InfoQ
- C++26 is done! Trip report: March 2026 ISO C++ standards meeting — Herb Sutter
- C++26: Reflection, Memory Safety, Contracts, and a New Async Model — InfoQ
- C++23 mdspan — The Complete Deep Dive
- C++23: Ranges Improvements and std::generator — Modernes C++
- Rust VS C++ Comparison for 2026 — JetBrains RustRover Blog
- Rust vs C++: Memory Safety & Performance in 2026 — dasroot.net
- C++20 Three-Way Comparison Operator — Modernes C++
- GitHub — jbaldwin/libcoro: C++20 coroutine library
- C++20 Template Constraints: SFINAE to Concepts — Solidean
Comments