C++23 Features
"Complete C++23 guide: std::print, std::expected, deducing this, std::generator, std::mdspan, flat containers, ranges, import std, and more."
C++23since C++23C++23 is the ISO C++ standard ratified in 2023, delivering practical quality-of-life improvements β std::print, std::expected, deducing this, std::generator, std::mdspan, flat containers, import std, and a major expansion of the ranges library β on top of C++20's foundation.
Overview
C++23 is a deliberately focused release. After C++20's sweeping changes (concepts, coroutines, modules, ranges), C++23 polishes the rough edges and fills the gaps. Almost every feature answers a concrete daily annoyance: printf-style safety without a string allocation, error returns without exceptions, collapsing three ref-qualifier overloads into one template, generators without boilerplate.
Compiler support as of 2025:
g++ -std=c++23 # GCC 13+ (most features), GCC 14 (fuller support)
clang++ -std=c++23 # Clang 17+ (most), Clang 18 (fuller)
cl /std:c++latest # MSVC 2022 17.6+ (partial), 17.8+ (better)Language Features
Deducing this β Explicit Object Parameter (P0847)
The single most impactful language change. Before C++23, a single member function with different value categories required three overloads (const&, &, &&). C++23 lets you write one template where this is an explicit, named parameter:
struct Buffer {
std::vector<std::byte> data_;
// C++23: one template replaces three overloads
template <typename Self>
auto& bytes(this Self&& self) {
return std::forward<Self>(self).data_;
}
// CRTP-style without CRTP boilerplate
template <typename Self>
void append(this Self&& self, std::span<const std::byte> src) {
std::forward<Self>(self).data_.insert(
std::forward<Self>(self).data_.end(), src.begin(), src.end());
}
};The explicit object parameter also enables recursive lambdas β previously impossible without std::function overhead:
// C++23: recursive lambda with no std::function
auto fib = [](this auto self, int n) -> int {
return n <= 1 ? n : self(n - 1) + self(n - 2);
};
// Memoized variant β captures a cache by reference
std::unordered_map<int, long long> memo;
auto fib_memo = [&memo](this auto& self, int n) -> long long {
if (n <= 1) return n;
auto [it, inserted] = memo.emplace(n, 0LL);
if (!inserted) return it->second;
return it->second = self(n - 1) + self(n - 2);
};if consteval (P1938)
C++20's std::is_constant_evaluated() was a function call, which created a subtle footgun: putting it inside an if constexpr always returned true because if constexpr operands are constant expressions. C++23 replaces it with a proper keyword:
constexpr double safe_sqrt(double x) {
if consteval {
// Only reachable at compile time
// Can call other immediate functions (consteval)
return ct_newton_sqrt(x); // consteval function
} else {
// Runtime path β can use hardware intrinsics
return __builtin_sqrt(x);
}
}
// `not consteval` is also valid
constexpr void debug_trace(std::string_view msg) {
if not consteval {
std::print(stderr, "[trace] {}\n", msg); // only at runtime
}
}Multidimensional operator[] (P2128)
operator[] was restricted to exactly one parameter. C++23 removes this restriction, enabling the natural m[r, c] syntax that std::mdspan depends on:
template <typename T>
class Matrix2D {
std::vector<T> buf_;
std::size_t cols_;
public:
Matrix2D(std::size_t r, std::size_t c) : buf_(r * c), cols_(c) {}
T& operator[](std::size_t r, std::size_t c) { // C++23
return buf_[r * cols_ + c];
}
const T& operator[](std::size_t r, std::size_t c) const {
return buf_[r * cols_ + c];
}
};
Matrix2D<float> m(4, 4);
m[0, 0] = 1.0f; // before C++23: m(0,0) or m[0][0]
m[1, 3] = 7.5f;auto(x) β Explicit Decay-Copy (P0849)
Forces a decay-copy of an expression. The classic trap is passing a reference to an element into an algorithm that modifies the container:
std::vector<std::string> words = {"foo", "bar", "foo", "baz"};
// BUG (C++20 and earlier): `words.front()` is a reference;
// after the first erasure the reference dangles.
std::erase_if(words, [&first = words.front()](const auto& w) {
return w == first; // UB after erase
});
// CORRECT (C++23): decay-copy captures a value, not a reference
std::erase_if(words, [first = auto(words.front())](const auto& w) {
return w == first; // safe β owns its own copy
});[[assume(expr)]] Attribute (P1774)
A portable way to provide optimizer hints that were previously written as __builtin_assume() (GCC/Clang) or __assume() (MSVC). The expression is never evaluated at runtime; violating it is UB:
double reciprocal(double x) {
[[assume(x != 0.0)]];
[[assume(std::isfinite(x))]];
return 1.0 / x; // optimizer can elide the zero check
}
void process_aligned(float* __restrict__ p, std::ptrdiff_t n) {
[[assume(n % 8 == 0)]]; // enables vectorization
[[assume(reinterpret_cast<std::uintptr_t>(p) % 32 == 0)]]; // aligned
for (std::ptrdiff_t i = 0; i < n; ++i) p[i] *= 2.0f;
}Library Features
std::print / std::println (P2093)
Type-safe, format-stringβbased I/O that writes directly to a file descriptor. No intermediate std::string allocation like std::format + std::cout:
#include <print>
// Direct to stdout β no allocation
std::println("Processing {} records in {:.3f}s", count, elapsed);
std::print(stderr, "error: {}\n", ec.message());
// To any FILE* or std::ostream
std::println(log_file, "[{}] {}", timestamp, message);
// Debug format for escaping non-printable chars
std::println("{:?}", "line1\nline2\ttab"); // "line1\nline2\ttab"std::expected<T, E> (P0323)
A sum type holding either a T (success) or an E (error), with a monadic interface for chaining operations without nested if checks:
#include <expected>
enum class ParseError { empty_input, overflow, invalid_char };
std::expected<long long, ParseError> parse_int(std::string_view s) {
if (s.empty()) return std::unexpected(ParseError::empty_input);
long long result = 0;
for (char c : s) {
if (c < '0' || c > '9') return std::unexpected(ParseError::invalid_char);
if (__builtin_mul_overflow(result, 10, &result) ||
__builtin_add_overflow(result, c - '0', &result))
return std::unexpected(ParseError::overflow);
}
return result;
}
// Monadic chaining β error short-circuits the chain
auto value = parse_int(input)
.and_then([](long long n) -> std::expected<long long, ParseError> {
if (n > 1'000'000) return std::unexpected(ParseError::overflow);
return n * 2;
})
.transform([](long long n) { return std::to_string(n); })
.value_or("(error)");std::expected is not a replacement for exceptions in all contexts β it shines for functions where failure is expected and frequent (parsing, I/O), not for truly exceptional conditions.
std::generator<T> (P2168)
A coroutine type for lazy sequences, composable with ranges:
#include <generator>
std::generator<std::pair<int, int>> prime_pairs(int limit) {
auto is_prime = [](int n) {
if (n < 2) return false;
for (int i = 2; i * i <= n; ++i) if (n % i == 0) return false;
return true;
};
for (int n = 2; n < limit; ++n)
if (is_prime(n) && is_prime(n + 2))
co_yield {n, n + 2};
}
for (auto [p, q] : prime_pairs(100) | std::views::take(5))
std::println("({}, {})", p, q); // (3,5) (5,7) (11,13) (17,19) (29,31)std::mdspan (P0009)
A non-owning, multi-dimensional view over contiguous storage. Replaces ad-hoc data[r * cols + c] indexing and provides layout policies for interoperability with Fortran, BLAS, and GPU APIs:
#include <mdspan>
// Row-major (default) 3Γ4 view
std::vector<double> buf(3 * 4, 0.0);
std::mdspan<double, std::extents<std::size_t, 3, 4>> mat(buf.data());
mat[1, 2] = 3.14;
std::println("rows={} cols={}", mat.extent(0), mat.extent(1));
// Column-major layout (Fortran/BLAS order)
std::mdspan<double, std::extents<std::size_t, 3, 4>,
std::layout_left> col_major(buf.data());
// Dynamic extents β sizes known at runtime
std::mdspan<float, std::dextents<std::size_t, 2>> dyn(
buf.data(), rows, cols);std::flat_map / std::flat_set (P1222)
Sorted-vectorβbacked associative containers. The tree-based std::map has poor cache locality; flat_map stores keys and values in two separate contiguous arrays, making range iteration and binary search significantly faster:
#include <flat_map>
std::flat_map<std::string, int> registry;
registry.insert_or_assign("alpha", 1);
registry.insert_or_assign("beta", 2);
// O(n) insert (shift), O(log n) lookup, O(n) iteration with good cache behavior
auto it = registry.find("alpha"); // binary search on sorted key vector
// Construct from existing sorted data β O(n), avoids repeated inserts
std::vector<std::string> keys = {"a", "b", "c"};
std::vector<int> vals = {1, 2, 3};
std::flat_map<std::string, int> fm(std::sorted_unique, std::move(keys), std::move(vals));Use flat_map when reads dominate inserts. Under heavy insertion workloads, std::map or std::unordered_map will outperform it.
import std; β Modularized Standard Library (P2465)
C++23 standardizes importing the entire standard library as a module, eliminating hundreds of #include directives and cutting preprocessing overhead:
import std; // replaces every #include <...> for the standard library
int main() {
std::println("hello from imported std");
std::vector<int> v = {3, 1, 4, 1, 5};
std::ranges::sort(v);
}Compiler support for import std; is still uneven as of 2025. GCC 15 and Clang 18+ have improving support; MSVC 2022 17.5+ has the most complete implementation. Check your build system's module support before adopting.
Ranges Library Additions
C++23 significantly expands the ranges views and algorithms introduced in C++20:
// New views (C++23)
auto pairs = std::views::zip(keys, values); // zip two ranges
auto chunks = data | std::views::chunk(4); // groups of 4
auto wins = data | std::views::slide(3); // sliding windows of width 3
auto every3 = data | std::views::stride(3); // every 3rd element
auto rep = std::views::repeat(0, 8); // [0,0,0,0,0,0,0,0]
auto idx = data | std::views::enumerate; // (index, value) pairs
auto cross = std::views::cartesian_product(rows, cols); // cross product
// New algorithms (C++23)
auto sum = std::ranges::fold_left(v, 0, std::plus{});
auto rsum = std::ranges::fold_right(v, 0, std::plus{});
bool has = std::ranges::contains(v, 42);
bool sw = std::ranges::starts_with(v, prefix);
bool ew = std::ranges::ends_with(v, suffix);
auto it = std::ranges::find_last(v, 42).begin();Best Practices
- Prefer
std::printlnoverstd::coutfor new code; it is faster to type, harder to misformat, and avoids the<<chain verbosity. - Use
std::expectedat API boundaries where callers must handle failures explicitly. Don't sprinkle it inside implementation details β internal errors are still best handled with exceptions or assertions. - Collapse ref-qualifier overloads with deducing
thiswhenever you haveconst&and non-constversions doing the same thing; three functions become one, and perfect forwarding is automatic. - Prefer
std::mdspanover raw pointer + stride math. The layout policy is explicit in the type, eliminating silent row-vs-column order bugs when passing arrays to BLAS or GPU kernels. [[assume]]only for genuinely invariant conditions. A wrong assumption produces silent UB. If the condition could plausibly be violated at runtime, use an assertion instead.
Common Pitfalls
std::expected vs. std::optional: optional signals absence; expected signals failure with a reason. Don't use expected<T, bool> β that defeats the purpose. If you don't need the error value, optional is cleaner.
Deducing this breaks virtual: An explicit object parameter function cannot be virtual. The feature is for templates and CRTP replacements, not polymorphic dispatch.
std::flat_map invalidation: Because keys and values live in vectors, any insert or erase invalidates all iterators. std::map iterators are stable. Don't store iterators into a flat_map across mutations.
import std; is not #include <...>: Module imports do not inject macros. If third-party code uses standard library macros (e.g., assert from <cassert>, or errno from <cerrno>), you still need to #include those headers.
if consteval β if constexpr: if consteval branches on whether the current evaluation is manifestly constant; if constexpr branches on a compile-time boolean. They compose but serve different purposes.
std::print / std::println β Direct Formatted Output (P2093)
std::print is the printf you always wanted: type-safe, Unicode-aware, and zero-allocation. It writes a formatted string directly to a file descriptor (or FILE*) using the same {} format spec as std::format, but without allocating an intermediate std::string just to hand it to std::cout. The std::println variant appends a newline automatically, which eliminates the trailing \n in almost every call.
// C++23
#include <print>
// Basic usage β replaces std::cout << std::format(...)
std::println("Processing {} records in {:.3f}s", count, elapsed);
// Write to stderr without touching std::cerr
std::print(stderr, "error: {}\n", ec.message());
// Write to any FILE* (log files, pipes, etc.)
std::println(log_file, "[{:%H:%M:%S}] {}", timestamp, message);
// Debug (?:) format β escapes non-printable characters visibly
std::println("{:?}", "two\nlines"); // prints: "two\nlines"
std::println("{:?}", '\t'); // prints: '\t'Unicode support goes through the platform's native encoding on Windows and UTF-8 everywhere else; the implementation is required to transcode correctly, unlike std::cout which simply passes bytes through. For pure text formatting without I/O, std::format (C++20) remains the right tool; std::print is the preferred output primitive for new C++23 code.
std::flat_map / std::flat_set β Cache-Friendly Associative Containers (P1222)
std::flat_map delivers the ordered-map interface on top of two sorted std::vectors, giving binary-search lookups with CPU-cacheβfriendly linear layout. A node-based std::map pointer-chases through a red-black tree; flat_map stores all keys contiguously and all values contiguously, so a range scan or lower_bound walks a tight memory region that fits in cache. The trade-off is O(n) insert and erase (elements must be shifted), so this container is best when reads and iterations dominate writes.
// C++23
#include <flat_map>
#include <flat_set>
// Build up with insert β O(n) per insert due to shifting
std::flat_map<std::string, int> scores;
scores["alice"] = 95;
scores["bob"] = 87;
scores["carol"] = 91;
// Lookup is O(log n) binary search on the key vector
if (auto it = scores.find("bob"); it != scores.end())
std::println("bob scored {}", it->second);
// Bulk construction from pre-sorted data is O(n) β prefer this
std::vector<std::string> keys = {"alice", "bob", "carol"};
std::vector<int> vals = {95, 87, 91};
std::flat_map<std::string, int> fm(
std::sorted_unique, // precondition: keys are sorted + unique
std::move(keys),
std::move(vals));
// flat_set β same idea, just a sorted unique-key set
std::flat_set<int> primes = {2, 3, 5, 7, 11, 13};
bool has_seven = primes.contains(7); // trueIterator invalidation is the key gotcha: any insert or erase invalidates all iterators, unlike std::map where iterators are stable. Prefer std::map when you need to hold iterators across mutations, or when insertions and deletions are interleaved with lookups at high frequency.
std::expected<T, E> Monadic Interface β Chaining Without Nesting (P2505)
The monadic methods on std::expected β and_then, transform, or_else, and transform_error β let you build error-propagating pipelines that read like a happy-path sequence. Without them, each fallible step requires an if (!result) guard, ballooning indentation; with them, errors short-circuit the chain automatically, mirroring the ? operator in Rust or bind in Haskell's Either.
// C++23
#include <expected>
#include <charconv>
enum class Err { parse, range, io };
std::expected<int, Err> parse_port(std::string_view s) {
int port{};
auto [p, ec] = std::from_chars(s.data(), s.data() + s.size(), port);
if (ec != std::errc{}) return std::unexpected(Err::parse);
if (port < 1 || port > 65535) return std::unexpected(Err::range);
return port;
}
std::expected<std::string, Err> resolve(int port); // forward decl
// Monadic chain β error at any step skips the rest
auto result = parse_port(user_input)
.and_then(resolve) // Tβexpected<U,E>; propagates error
.transform([](std::string addr) { // TβU on success; wraps in expected
return "Resolved: " + addr;
})
.or_else([](Err e) -> std::expected<std::string, Err> {
// recover or re-wrap the error
return std::unexpected(e);
});
std::println("{}", result.value_or("(failed)"));and_then expects a callable returning std::expected<U, E> (for flatMap-style chaining), while transform expects a callable returning a plain U (it wraps the result automatically). transform_error maps the error type without touching a success value, useful when translating internal error enums to public-API error codes.
// C++23 β transform_error: remap the error type
auto public_err = internal_result
.transform_error([](InternalErr e) -> PublicErr {
switch (e) {
case InternalErr::disk_full: return PublicErr::storage;
default: return PublicErr::unknown;
}
});Compared to Rust's Result<T, E>, std::expected covers the same structural ground but lacks the ? early-return syntax; the closest C++ equivalent is calling .value() (which throws std::bad_expected_access on error) or writing explicit and_then chains.
std::stacktrace β Portable Stack Capture (P0881)
std::stacktrace captures the current call stack as a sequence of std::stacktrace_entry objects, each carrying the source file, line number, and symbol description. Before C++23, capturing a stack trace required platform-specific calls (backtrace on POSIX, CaptureStackBackTrace on Windows) or a third-party library. The new facility is portable and integrates naturally with exception types and assertion handlers.
// C++23
#include <stacktrace>
#include <stdexcept>
// Capture the current stack trace (at the call site)
auto trace = std::stacktrace::current();
// Print all frames
std::println("Stack trace ({} frames):", trace.size());
for (const auto& frame : trace)
std::println(" {}:{} in {}", frame.source_file(),
frame.source_line(),
frame.description());
// Embed a trace inside a custom exception
struct TracedException : std::runtime_error {
std::stacktrace trace;
explicit TracedException(std::string msg)
: std::runtime_error(std::move(msg))
, trace(std::stacktrace::current()) {}
};
void deep_call() {
throw TracedException("something went wrong");
}
// Catch and inspect
try {
deep_call();
} catch (const TracedException& ex) {
std::println("caught: {}", ex.what());
std::println(" at:\n{}", ex.trace); // operator<< supported
}std::stacktrace::current() accepts an optional skip argument to drop frames from the top, and an optional max_depth cap. Capturing a stack trace is not free β it typically involves a system call or walking frame pointers β so avoid it on hot paths. The primary use cases are diagnostics, assertions, and enriched exception types in error-handling infrastructure.
// C++23 β skip the top 1 frame (the capture helper itself)
void assertion_handler(const char* expr) {
auto trace = std::stacktrace::current(1); // skip assertion_handler frame
std::println(stderr, "assertion failed: {}", expr);
std::println(stderr, "{}", trace);
std::abort();
}See Also
- Coroutines β C++20 foundation that
std::generatorbuilds on - Concepts β required for writing deducing-
thistemplates correctly - C++20 Features β ranges, modules, concepts, coroutines baseline
- std::format β the formatting engine behind
std::print - Ranges β complete ranges reference including C++23 additions