Skip to content
C++
Language
since C++23
Basic

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++23

C++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:

bash
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:

cpp
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:

cpp
// 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:

cpp
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:

cpp
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:

cpp
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:

cpp
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:

cpp
#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:

cpp
#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:

cpp
#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:

cpp
#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:

cpp
#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:

cpp
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:

cpp
// 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::println over std::cout for new code; it is faster to type, harder to misformat, and avoids the << chain verbosity.
  • Use std::expected at 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 this whenever you have const& and non-const versions doing the same thing; three functions become one, and perfect forwarding is automatic.
  • Prefer std::mdspan over 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.

cpp
// 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.

cpp
// 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);   // true

Iterator 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.

cpp
// 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.

cpp
// 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.

cpp
// 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.

cpp
// 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::generator builds on
  • Concepts β€” required for writing deducing-this templates 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