armhub.dev
Programming · 55 pages
Programmingtutorial

C++ Lesson 14 — Modern C++23

A tour of C++23 features that actually change how you write code — std::expected, std::print, stacktrace, deducing this, if consteval, and the C++20 highlights worth catching up on.

4 min read · 812 wordsupdated · May 13, 2026
Lesson 14 — at a glance
What you'll learn
std::expected, std::print, stacktrace, multidim subscript, deducing this, if consteval, [[assume]], plus a recap of concepts, ranges, format, and <=>
Code provided
About 120 lines — modern replacements for printf and idiomatic uses of each feature
Time
60-90 minutes
Pre-reqs
Lessons 1-13 (templates, lambdas, modules)

This lesson is a tour, not a deep dive. C++23 added a long list of features; most are small refinements, a few are large enough to change how you write code. We cover the second group, then loop back to mature-but-under-used C++20 features.

The examples assume -std=c++23 and a 2026-era compiler (GCC 14+, Clang 17+, MSVC 17.10+).

std::expected<T, E> (C++23)

You already met std::expected in lesson 11. It is the headline C++23 addition: a return-by-value alternative to exceptions that finally made it into the standard. A value or an error, with monadic combinators.

#include <expected>
 
std::expected<int, std::string> divide(int a, int b) {
    if (b == 0) return std::unexpected("divide by zero");
    return a / b;
}
 
auto r = divide(10, 0);
if (!r) std::println("error: {}", r.error());

If you skipped lesson 11, go back. This feature is the modern C++ answer to "how do I return an error without throwing?", and it is the right choice for almost all new code.

std::print and std::println (C++23)

The <print> header brings a type-safe, fast replacement for printf into the standard library, finally retiring the C printf for new code. The format strings use the same syntax as std::format (Python-style {} placeholders):

#include <print>
 
int    x = 42;
double y = 3.14;
std::string s = "hello";
 
std::print("x = {}, y = {}, s = {}\n", x, y, s);    // no implicit newline
std::println("x = {}, y = {}, s = {}", x, y, s);    // adds newline
 
// Format specifiers in braces, no separate format char per type:
std::println("{:>10}", x);     // right-align, width 10
std::println("{:.3f}", y);     // 3 decimal places
std::println("{:#x}", x);      // hex with 0x prefix

The big wins versus printf:

  1. Type safety. The compiler checks arguments against the format string at compile time. std::println("{:d}", "hello"); is a compile error; printf("%d", "hello"); is a security advisory.
  2. No type-specific format char. No %d vs %ld vs %zu — the formatter knows the type.
  3. Custom types work. Implement std::formatter<MyType> once and your type prints with {} everywhere.
  4. Fast. Typically 2-5x faster than printf on modern implementations.

The cost is the C++23 dependency. The {fmt} library is the drop-in alternative for older or embedded targets.

<stacktrace> (C++23)

Runtime stack traces, previously platform-specific (backtrace() / CaptureStackBackTrace), are now standard:

#include <stacktrace>
#include <print>
 
void inner() { std::println("{}", std::stacktrace::current()); }
void middle() { inner(); }
void outer()  { middle(); }
int main()    { outer(); }

Output (symbol-dependent):

   0# inner() at main.cpp:6
   1# middle() at main.cpp:7
   2# outer() at main.cpp:8
   3# main at main.cpp:9

Works best with -g and frame pointers. Degraded on embedded targets without debug info, but for hosted code it is now a portable diagnostic.

Multidimensional subscript m[i, j] (C++23)

Before C++23, the comma in arr[i, j] was the comma operator — evaluate i, discard it, evaluate j, subscript with j. C++23 makes operator[] accept multiple arguments:

template <typename T>
class Matrix {
public:
    Matrix(std::size_t rows, std::size_t cols) : rows_(rows), cols_(cols), data_(rows * cols) {}
 
    T &operator[](std::size_t r, std::size_t c) { return data_[r * cols_ + c]; }
    const T &operator[](std::size_t r, std::size_t c) const { return data_[r * cols_ + c]; }
 
private:
    std::size_t       rows_, cols_;
    std::vector<T>    data_;
};
 
Matrix<double> m(3, 4);
m[1, 2] = 5.0;          // C++23 — was a comma-operator bug before

This is the syntax std::mdspan (also new in C++23) uses for multi-dimensional array views. Small but welcome — the comma-operator footgun is finally gone.

Deducing this (C++23)

Member functions can now name their implicit this parameter explicitly, with a deduced type. The killer use case is eliminating the const-and-non-const duplication problem:

class Container {
public:
    // Before C++23 — two near-identical implementations:
    int       &at(std::size_t i)       { /* ... */ }
    const int &at(std::size_t i) const { /* ... */ }
 
    // With deducing this — one implementation, both const-correct overloads:
    template <typename Self>
    auto &at(this Self &self, std::size_t i) {
        return self.data_[i];
    }
 
private:
    std::vector<int> data_;
};

The this Self &self parameter (the keyword this as the first parameter is the new syntax) tells the compiler "deduce the const-ness and value category of the object from the call site". c.at(0) deduces Self = Container, static_cast<const Container &>(c).at(0) deduces Self = const Container. One template, two overloads, no duplication.

Other use cases: recursive lambdas, CRTP-like patterns without inheritance, chained ref-qualified methods. Small feature, large consequences for library design.

if consteval (C++23)

if constexpr (C++17) branches at compile time based on a type or constant condition. if consteval (C++23) branches based on whether the function is being evaluated at compile time.

constexpr int compute(int x) {
    if consteval {
        // Compile-time path — no I/O, no runtime calls.
        return x * x;
    } else {
        // Runtime path — can use printf, throw, etc.
        std::println("computing at runtime");
        return x * x;
    }
}
 
constexpr int a = compute(5);   // takes the consteval branch
int            b = compute(5);  // takes the runtime branch

Useful for constexpr functions that need a slow but I/O-rich runtime implementation and a fast compile-time-only implementation. The runtime branch can call non-constexpr functions; the compile-time branch cannot.

[[assume(condition)]] (C++23)

A standard attribute that tells the compiler "you may assume this condition is true". The compiler uses the assumption to optimise; it does not check it. If the assumption is false at runtime, behaviour is undefined.

int divide_by_positive(int a, int b) {
    [[assume(b > 0)]];
    return a / b;
}

The compiler can now omit the divide-by-zero check (if it had one) and use a slightly faster division instruction in some cases. The assumption is a contract: the caller guarantees b > 0, and any violation is undefined behaviour.

Use sparingly. [[assume]] is a sharp tool — wrong assumptions silently miscompile. Reserve it for hot loops where you have verified the assumption holds by construction.

Static operator() (C++23)

If a function-object class's operator() does not depend on this, it can be static:

struct Comparator {
    static bool operator()(int a, int b) { return a < b; }
};
 
std::ranges::sort(v, Comparator{});

The benefit is mostly micro-optimisation — static operator() does not pass an implicit this argument, so call sites are a register lighter. Also useful for stateless comparators and predicates where construction is gratuitous. Lambdas got the same treatment via [](...) static { ... }.

auto(x) for explicit copy (C++23)

auto(x) constructs a new auto-deduced object from x. The use case is forcing a copy of something that you have only as a reference, to break an aliasing dependency or to pass to a function that mutates:

void may_modify_first(std::string &a, const std::string &b);
 
void caller(std::string &s) {
    may_modify_first(auto(s), s);   // first param is a copy, second is original
}

Without auto(x), you would have to declare a temporary or use a cast. Small, but the syntax is finally clean.

C++20 recap — features that are not new but are still under-used

A lot of C++20 went into compilers but has not yet entered everyone's mental toolkit. The four most worth catching up on:

Concepts

Type constraints on templates. Replaces SFINAE for most use cases:

template <typename T>
concept Numeric = std::integral<T> || std::floating_point<T>;
 
template <Numeric T>
T add(T a, T b) { return a + b; }
 
add(1, 2);          // OK
add("a", "b");      // error: 'const char *' does not satisfy 'Numeric'

Compile errors become readable ("does not satisfy Numeric") instead of pages of substitution-failure noise. Concepts are the right way to write templates in 2026 — use them.

<format> and std::format

C++20 added std::format(fmt, args...) which returns a std::string. Same syntax as std::print, just returns the formatted text instead of printing it.

std::string s = std::format("x = {}, y = {:.2f}", 42, 3.14159);

Same upsides as std::print (type safety, no %-mismatch, custom types via std::formatter).

Ranges and views

The <ranges> library lets you compose algorithms with pipes:

#include <ranges>
 
std::vector<int> v = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
 
auto result = v
    | std::views::filter([](int n) { return n % 2 == 0; })
    | std::views::transform([](int n) { return n * n; })
    | std::views::take(3);
 
for (int n : result) std::print("{} ", n);   // 4 16 36

The pipes are lazy — no intermediate vector is constructed. Each element flows through the pipeline as the loop pulls it. The implementation is heavy template machinery, but at the call site the syntax is clean.

Three-way comparison <=>

The "spaceship operator" <=> generates all six comparison operators from one definition:

struct Version {
    int major, minor, patch;
 
    auto operator<=>(const Version &other) const = default;
};
 
Version v1{1, 2, 3}, v2{1, 2, 4};
if (v1 < v2) std::println("v1 is older");

= default does lexicographic comparison member by member. For custom orderings you write the body, returning std::strong_ordering, std::weak_ordering, or std::partial_ordering as appropriate. Big DRY win — the old "five-operator boilerplate" is gone.

A complete worked example

Replace printf with std::println and show the type-safety win. Build with g++ -std=c++23 -O2 -Wall modern.cpp:

#include <expected>
#include <print>
#include <ranges>
#include <stacktrace>
#include <string>
#include <string_view>
#include <vector>
 
// ---- C++20 concept + C++23 deducing this.
 
template <typename T>
concept Printable = requires(T t) {
    std::print("{}", t);
};
 
class Counter {
public:
    template <typename Self>
    auto value(this Self &&self) { return self.value_; }
 
    void increment() { ++value_; }
private:
    int value_ = 0;
};
 
// ---- std::expected for fallible parsing.
 
std::expected<int, std::string> parse_int(std::string_view s) {
    if (s.empty()) return std::unexpected("empty");
    int v = 0;
    for (char c : s) {
        if (c < '0' || c > '9') return std::unexpected(std::format("bad char '{}'", c));
        v = v * 10 + (c - '0');
    }
    return v;
}
 
int main() {
    // ---- std::println — type-safe formatting.
    int    x = 42;
    double y = 3.14159;
    std::println("x = {}, y = {:.3f}", x, y);
    // std::println("x = {:d}", "hello");   // compile error — would silently corrupt with printf
 
    // ---- C++20 ranges pipeline.
    std::vector<int> nums = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
    auto squares_of_even = nums
        | std::views::filter([](int n) { return n % 2 == 0; })
        | std::views::transform([](int n) { return n * n; });
    std::print("squares of evens: ");
    for (int n : squares_of_even) std::print("{} ", n);
    std::println("");
 
    // ---- std::expected chained with .and_then.
    auto r = parse_int("123")
        .and_then([](int n) -> std::expected<int, std::string> {
            if (n > 1000) return std::unexpected("too big");
            return n * 2;
        });
    if (r) std::println("parsed and doubled: {}", *r);
    else   std::println("error: {}", r.error());
 
    // ---- Deducing this.
    Counter c;
    c.increment(); c.increment();
    std::println("counter: {}", c.value());
 
    // ---- Stack trace.
    auto trace = std::stacktrace::current();
    std::println("trace:\n{}", trace);
}

Every line here is a feature from this lesson or its lead-ins. Removing the C++23 dependency means losing about half the code's elegance.

What you learned

  • std::expected<T, E> is the standard, value-based error-handling type. Use it.
  • std::print / std::println are the modern type-safe replacement for printf. Custom types plug in via std::formatter.
  • std::stacktrace gives you portable runtime stack traces.
  • m[i, j] is a real multi-arg subscript; the comma-operator bug is gone.
  • Deducing this eliminates const-and-non-const duplication and enables new patterns.
  • if consteval, [[assume]], static operator(), and auto(x) are small but real ergonomic wins.
  • C++20 recap: concepts (use them), std::format, <ranges> views with pipes, <=> for free comparison operators.

Exercises

Exercise 14.1. Convert this printf call to std::println. What changes about the type-safety guarantees?

const char *name = "alice";
int         age  = 30;
double      bal  = 12.5;
printf("name=%s age=%d bal=%.2f\n", name, age, bal);

Exercise 14.2. Write a std::formatter specialisation so std::println("{}", point) works for struct Point { double x, y; };.

Exercise 14.3. Use std::expected and .and_then to chain three fallible operations (parse, validate, transform) into a single expression. Show what happens at each failure point.

Next

PreviousNext
C++ Lesson 13 — ModulesC++ Lesson 15 — C++ on Cortex-M

See also: C++ on Cortex-M, Bare-metal C tutorial, GCC flag reference.