EthicsComputerCourses
Join training +91 90532 10052

C++23 Features in Practice

C++ · Lesson 37 of 46 Advanced 7 min read Updated

C++23 is the current published C++ standard (ISO/IEC 14882:2024). You have already used several of its library additions in this course: std::print and std::println, std::expected, the enumerate and zip views, std::ranges::to, std::to_underlying and import std;. This lesson covers the other C++23 features you are most likely to meet in modern codebases and interviews, with a note on compiler support for each.

You will learn deducing this, which removes duplicate member functions; std::generator, the first standard coroutine type; std::mdspan with the new multidimensional []; std::stacktrace; and a handful of small but handy additions.

Deducing this: one function instead of two

Classes that give access to their contents often need two versions of the same member function: a const one that returns a const&, and a non-const one that returns a plain reference. C++23 lets a member function take the object itself as an explicit first parameter, written with the keyword this. Combined with auto, one template handles both cases:

C++
#include <cstddef>
#include <print>
#include <string>
#include <utility>
#include <vector>

class Playlist {
public:
    void add(std::string song) { songs_.push_back(std::move(song)); }

    auto& operator[](this auto& self, std::size_t i) {   // self is const or not, as the caller is
        return self.songs_[i];
    }

private:
    std::vector<std::string> songs_;
};

int main() {
    Playlist trip;
    trip.add("Morning Raga");
    trip.add("Highway Song");
    trip[1] = "Monsoon Song";             // non-const object: returns std::string&

    const Playlist& view = trip;
    std::println("{} / {}", view[0], view[1]);   // const object: returns const std::string&
    // view[0] = "Oops";                 // error: cannot assign through a const reference
}
Output
Morning Raga / Monsoon Song

self is the object the function was called on, so there is no implicit this->; you write self.songs_. When called on a const object, self is deduced as const Playlist&, and self.songs_[i] is a const std::string&, so the auto& return type becomes const too. The same trick removes the four overloads (&, const&, &&, const&&) that library code used to write by hand.

Explicit object parameters also make recursive lambdas easy, which was awkward before:

C++
#include <print>

int main() {
    auto factorial = [](this auto self, int n) -> long long {
        return n <= 1 ? 1 : n * self(n - 1);       // the lambda calls itself
    };
    std::println("15! = {}", factorial(15));
}
Output
15! = 1307674368000

std::generator: lazy sequences with co_yield

A coroutine is a function that can pause and later continue where it stopped. C++20 added the language support (co_yield, co_await, co_return) but no ready-to-use coroutine types. C++23 adds std::generator (header <generator>): a function returning std::generator<T> produces values one at a time with co_yield, and the caller can loop over it like a range. Each value is calculated only when the loop asks for it.

In an Indian Railways sleeper coach, seats come in groups of eight, and positions 1, 4 and 7 of each group are lower berths:

C++
#include <generator>
#include <print>
#include <ranges>

std::generator<int> lower_berths(int coach_size) {
    for (int seat = 1; seat <= coach_size; ++seat) {
        int position = seat % 8;
        if (position == 1 || position == 4 || position == 7) {
            co_yield seat;                          // pause here and hand out one seat
        }
    }
}

int main() {
    bool first = true;
    for (int seat : lower_berths(72) | std::views::take(6)) {
        std::print("{}{}", first ? "" : ", ", seat);
        first = false;
    }
    std::println("");
    std::println("Lower berths in a 72-seat coach: {}", std::ranges::distance(lower_berths(72)));
}
Output
1, 4, 7, 9, 12, 15
Lower berths in a 72-seat coach: 27

With views::take(6), the generator runs only until the sixth berth; the rest of the loop inside lower_berths never executes. Generators can even be infinite (for (int n = 1; ; ++n)), because the caller decides how many values to take. A generator is an input range: you can loop over it once, but not go back.

std::mdspan and the multidimensional subscript

C++23 lets operator[] take several arguments, so a grid can be indexed as grid[row, col] instead of grid[row][col] or grid(row, col). The standard library uses this in std::mdspan (header <mdspan>): a non-owning view that treats a flat block of memory as a multi-dimensional array, like std::span does for one dimension.

C++
#include <cstddef>
#include <mdspan>
#include <print>
#include <vector>

int main() {
    std::vector<char> seats(3 * 4, '.');            // 3 rows of 4 seats, one block of memory
    std::mdspan hall(seats.data(), 3, 4);           // view it as 3 x 4

    hall[0, 1] = 'X';                               // C++23 multidimensional subscript
    hall[2, 3] = 'X';

    for (std::size_t r = 0; r < hall.extent(0); ++r) {
        for (std::size_t c = 0; c < hall.extent(1); ++c) {
            std::print("{}", hall[r, c]);
        }
        std::println("");
    }
}
Output
.X..
....
...X

extent(0) is the number of rows and extent(1) the number of columns. Storing a grid in one std::vector and viewing it with mdspan is faster than a vector of vectors, because all data sits together and there is one allocation instead of one per row. Scientific, image-processing and machine-learning code uses this layout everywhere. Your own classes can also declare T& operator[](std::size_t row, std::size_t col).

std::stacktrace: where did this happen?

When something goes wrong deep inside a program, the error message is far more useful if it says how the program got there. std::stacktrace (header <stacktrace>) captures the current call stack, and C++23 can print it with std::println:

C++
#include <print>
#include <stacktrace>

void reserve_seat(int seat) {
    if (seat < 1 || seat > 72) {
        std::println("Invalid seat {}. Call stack:\n{}", seat, std::stacktrace::current());
    }
}

int main() {
    reserve_seat(80);
}

The output lists one numbered line per active function call, starting with reserve_seat and then main, each with the source file and line when the program was built with -g. The exact format and the extra frames from the C runtime differ between compilers, so it is not shown here. With GCC you must link an extra library: -lstdc++exp in GCC 14 and later. Stack traces are especially useful in log files and in exception classes that record where they were thrown.

Smaller additions worth knowing

C++
#include <algorithm>
#include <print>
#include <string>
#include <utility>
#include <vector>

using namespace std::string_literals;

enum class Coach { sleeper, ac3, ac2 };

int fare_per_km(Coach c) {
    switch (c) {
        case Coach::sleeper: return 1;
        case Coach::ac3: return 3;
        case Coach::ac2: return 4;
    }
    std::unreachable();                        // promise: every enumerator is handled above
}

int main() {
    std::vector stations = {"Rohtak"s, "Jaipur"s, "Pune"s};
    std::string route = "Delhi-Rohtak-Jaipur";

    std::println("{}", std::ranges::contains(stations, "Pune"s));   // C++23 algorithm
    std::println("{}", route.contains("Rohtak"));                    // C++23 string member
    for (auto i = 0uz; i < stations.size(); ++i) {                   // uz: a std::size_t literal
        std::println("{}. {}", i + 1, stations[i]);
    }
    std::println("AC3 fare for 500 km: Rs. {}", fare_per_km(Coach::ac3) * 500);
}
Output
true
true
1. Rohtak
2. Jaipur
3. Pune
AC3 fare for 500 km: Rs. 1500
FeatureWhat it does
std::ranges::containsIs a value in the range? No more find(...) != end()
string::containsDoes the text contain a substring or character?
0uzA std::size_t literal, so loop counters match size() without casts or warnings
std::unreachable()Tells the compiler a point is never reached; reaching it is undefined behaviour
[[assume(expr)]]Lets the optimiser assume a condition is true; like unreachable, a wrong promise is UB
std::move_only_functionLike std::function, but can hold move-only callables such as a lambda owning a unique_ptr
optional and_then, transform, or_elseChain steps on an optional without if checks

Use std::unreachable() and [[assume]] only when you are certain; in debug builds, an assert or a thrown exception is safer.

Common mistakes

  • Writing this-> inside an explicit-object function. There is no implicit this; use the named parameter, such as self.
  • Looping over a generator twice. It is a one-pass input range; call the function again for a new sequence.
  • Using grid[r, c] on a type without a two-argument operator[]. On a plain vector of vectors, [r, c] is not valid; it was a comma expression before C++23 and is now an error in that position.
  • Thinking mdspan owns memory. Like span, it is a view; the vector must outlive it.
  • Using std::unreachable() as an error handler. If the point can be reached, the program has undefined behaviour.

Key points

  • C++23 is the current ISO standard; its library additions include print, expected, generator, mdspan, stacktrace and flat_map.
  • Deducing this (this auto& self) replaces duplicated const and non-const member functions and enables recursive lambdas.
  • std::generator with co_yield produces values lazily and works with range pipelines.
  • std::mdspan views flat memory as a grid, indexed with the new [r, c] subscript.
  • Check compiler support for each feature; library features arrive at different times in GCC, Clang and MSVC.

Exercise

Write a generator std::generator<int> tokens(int start, int step) for a clinic in Rohtak that yields token numbers forever: start, start + step, start + 2 * step, and so on. Print the first five tokens starting at 101 with step 5, using std::views::take.

Show solution
C++
#include <generator>
#include <print>
#include <ranges>

std::generator<int> tokens(int start, int step) {
    for (int n = start; ; n += step) {
        co_yield n;
    }
}

int main() {
    for (int t : tokens(101, 5) | std::views::take(5)) {
        std::println("Token {}", t);
    }
}
Output
Token 101
Token 106
Token 111
Token 116
Token 121

The loop inside tokens never ends on its own, but that is fine: the generator only runs when the next value is requested, and take(5) stops asking after five.

Quick Quiz

1. What does auto& operator[](this auto& self, std::size_t i) replace?

2. When does code inside a std::generator function run?

3. What is std::mdspan?

4. What happens if the program actually reaches std::unreachable()?

Learn C++ with a mentor

Free lessons get you started. Our Java, C & C++ Programming training adds real projects and code reviews from working developers.

  • Project-based learning
  • Mentor code reviews
  • Free counselling call