Книга: C++23 STL Cookbook
Назад: Preface
Дальше: Chapter 2: Best Practices

1

Introduction to New C++23 Features

C++23 is not as large an update as C++20, yet it does add some significant new features. These include the new print() and println() functions, a new generator class, and multidimensional support for subscripts and views.

We'll cover some of these more impactful features in the following recipes:

  • Simplify your code with the std module
  • Format text with the new print() and println() functions
  • Use synchronous generators
  • Use mdspan as a multidimensional array
  • Initialize containers from ranges
  • Find content in strings and ranges with contains()

As of mid-2024, many of these new features, as well as some of the features introduced with C++20, are not yet supported on all the major C++ compilers. As we cover each feature, I'll point out which compilers support it.

This chapter aims to familiarize you with these new features in C++23, so you may use them in your own projects and understand them when you encounter them.

Simplify your code with the std module

C++20 introduced the concept of modules . Modules represent a more compact and efficient alternative to including headers in your code. The promise of the modules feature is that it will eliminate the need for # include headers in most circumstances. For that promise to be fulfilled, the standard library needs to be available as modules. C++23 provides the std module for that purpose. How to do it.

This recipe demonstrates the usage of the std module.

  • We'll start with a simple Hello, World file:
    #include <print> #include <string>  int main() {     std::string whoami {"I'm Spartacus"};     std::println("Hello, World, {}", whoami); }

    When compiled and run, it produces this output:

    Hello, World, I'm Spartacus
  • When using the std module, you no longer need to use the #include directives. Now, our code can look like this:
    import std;  int main() {     std::string whoami {"I'm Spartacus"};     std::println("Hello, World, {}", whoami); }

    When compiled and run, this produces the same output:

    Hello, World, I'm Spartacus

How it works

This feature uses the modules specification, introduced with C++20, to provide the entire C++ Standard Library in one pre-compiled module. Because modules are imported at link time, they do not import any code or symbols that are not directly referenced in the target code. This makes modules both safer and more efficient than #include directives. As a side benefit, using modules also eliminates the need for the cumbersome include guards that are necessary in #include files.

The std module is a powerful feature that can mitigate long lists of STL #include directives:

#include <print> #include <iostream> #include <stack> #include <deque> #include <map> #include <string> #include <utility> #include <cctype> #include <cmath> #include <iterator> #include <limits>

All of that may now be replaced with one import statement:

import std;

There's more…

Many traditional #include headers have legacy C Standard Library symbols declared in the global namespace (e.g., printf or puts ). The C++23 standard provides an alternative module named std.compat which provides those global namespace declarations. You may use this anywhere you would use the std module:

import std.compat;

Otherwise, both std and std.compat are the same.

As of late 2025, the C++23 std module specification is not yet implemented on some compilers. In fact, some compilers do not yet implement C++20 modules. Therefore, this book and the accompanying examples will continue to use #include headers. I look forward to having more complete implementations in the future.

Format text with the new print() and println() functions

When the format library was developed for C++20, the intent was to transition away from using cout and iostream for displaying formatted text. A print() function was planned but was not ready in time for the C++20 specification.

Available now with the C++23 update, the print() and println() functions are defined in the <print> header.

Compiler Support

As of mid-2024, all major production compilers support the print() and println() functions. This code was tested on the current versions of Apple Clang, GCC, and MS Visual C++.

How to do it

The print() function has finally been included with C++23, along with its sibling, println() which prints a line ending without requiring the string to include a newline \n character.

  • Here's a simple example of the print() function in use:
    #include <print> #include <string>  using std::print; using std::string;  int main() {     string whoami {"I'm Spartacus"};     print("Hello, World, {}\n", whoami); }

    The using statement brings the print symbol into the current namespace. I find this convenient with functions and objects that I use frequently.

    The print() function uses a formatting string, which is passed to the formatter class introduced with C++20. This provides you with all the formatting features of the format() function.

  • Alternatively, you can use println() to end the line, without including the \n character in your string:
    println("Hello, World, {} ", whoami);

    This usage is functionally equivalent to the previous example, but a newline \n character is automatically added to the end of the format string.

How it works

The print() functions use the formatter class from the C++20 specification to expand any variables or positional arguments. It formats the output according to the options specified in the format string .

Because the print() functions bypass the cumbersome iostream library, these functions are far more efficient than using format with cout . Performance and code size are comparable to printf() but with the type-safety benefits of the format library.

These functions are intended to be used instead of cout for formatting and displaying text on the console or writing to a file stream. You should almost always favor using print() or println() where you would have previously used cout .

See also…

A good reference for format string options is available at:

Use synchronous generators

A generator is a type of iterator that generates a sequence of values. It's an extremely useful and powerful pattern. Until now, it's been common practice to use an iterator class to create a generator in C++. With C++23 we have a new generator class that uses coroutines to create a synchronous generator, which suspends execution, and resumes when called again. This makes generators far more convenient in C++.

The generator class is defined in the <generator> header.

Compiler Support

As of late 2025, of the three major C++ compilers, only GCC and Microsoft Visual C++ support the generator specification. This feature is not yet supported on Apple Clang. This code was tested on GCC 16.2 and MSVC 19.44.

Note

When the ranges library is used in examples, this book uses namespace aliases for convenience and brevity:

namespace ranges = std::ranges;

namespace views = ranges::views;

This allows us to save trees by using ranges::to instead of std::ranges::to and views::iota instead of std::ranges::views::iota .

It's also quite a bit easier to read.

How to do it

An iterator class is commonly used to provide a generator. However, it is now much simpler with C++23's new generator class.

  • Here's an example that generates a sequence of prime numbers:
    template<typename T> std::generator<T> primes() {     constexpr auto isprime = [](const T& n) {         for (T i {2}; i * i <= n; ++i) {             if (n % i == 0) return false;         }         return true;     };     for (T n : views::iota(T{2})) {         if (!isprime(n)) continue;         co_yield n;     } }

    In this example, the generator class is used to generate a sequence of prime numbers. We use a template declaration to make it type-agnostic.

    The co_yield statement (introduced with C++20 as part of the coroutines specification) returns the value from each iteration. The next time the generator is called, execution continues after the co_yield statement.

    We use a lambda expression called isprime() to check if a value is prime. And the iota range factory is used to generate the sequential values.

  • We can now call the primes() generator in a for loop, just like any iterator or range:
    int main() {     for (auto n : primes<uint64_t>() | views::take(25)) {         print("{} ", n);     }     print("\n"); }

    The for loop calls the primes() generator and pipes it to the take() view. This leverages the ranges library to limit our output to the first 25 iterations.

    The output looks like this:

    2 3 5 7 11 13 17 19 23 29 31 37 41 43 47 53 59 61 67 71 73 79 83 89 97

    This gives us the first 25 prime numbers, as expected.

How it works

The generator class creates a wrapper around a coroutine. When we create a generator object, we need to pass it a coroutine that produces the desired result:

 std::generator<long> gen_fib() {     long a{0};     long b{1};      while(true) {         co_yield a;         auto next = a + b;         a = b;         b = next;     } }

In this example, the generator is declared as type long . Within the curly braces, we declare a coroutine function. The co_yield statement suspends execution until the function is called again. This is what makes it a coroutine.

Notice that the co_yield statement is not the last line in the function. It is not an exit point. It simply returns the value to the caller, and when called again it picks up execution at the next statement.

We can call this with a for loop:

int main() {     for (auto n : gen_fib() | views::take(10)) {         print("{} ", n);     }     print("\n"); }

Our output is:

0 1 1 2 3 5 8 13 21 34

This gives us the first 10 values in the Fibonacci sequence.

The generator pattern is extremely valuable, and this new generator class makes it very simple to implement.

The generator class is defined in the <generator> header.

See also…

See C hapter 4 , STL Compatible Iterators, for further generator recipes.

Use mdspan as a multidimensional array

Defined in C++23, the new mdspan class provides a multidimensional view of any contiguous container, including primitive C arrays.

The mdspan class uses the new C++23 multidimensional extension to the subscript operator [] to provide access to individual elements in a multidimensional space.

The multidimensional space is an abstraction superimposed over a contiguous container or primitive array.

Consider this primitive array:

Figure 1.1: A primitive array suitable for use with mdspan

Figure 1.1: A primitive array suitable for use with mdspan

This array is a simple one-dimensional vector of integers, increasing in value from 1 to 8. Each element in the array is accessed using a scalar index, from [0] for the first element to [7] for the eighth element.

Using mdspan , we can view this exact same data as a two-dimensional array:

mdspan m(array, 2, 4);

Now we can access this same data using two-dimensional subscripts:

Figure 1.2: A two-dimensional view using mdspan

Figure 1.2: A two-dimensional view using mdspan

This gives us a two-dimensional view of our array. The first dimension exposes elements [0] to [3] as m[0,0] to m[0,3] , and the second exposes elements [4] to [7] as m[1,0] to m[1,3] .

The mdspan class is defined in the <mdspan> header.

Compiler support

As of late 2025, mdspan is supported by the Apple clang and Microsoft Visual C++ compilers. The code in this section has been tested on Apple clang 17.0 and MSVC 19.44.

How to do it

The mdspan class requires a contiguous data set, such as a primitive array, for its source data. STL containers that conform to this requirement include array and vector .

  • For this example, we'll use an STL vector object as our source data:
    vector v {1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12};

    We can access the underlying data of a vector using its data() method. This provides the contiguous data required by mdspan :

    mdspan mds2(v.data(), 2, 6);

    The mdspan object is named mds2 to indicate that it's a two-dimensional view. The first argument is a pointer to the contiguous array, as returned by the data() method of the vector object. The subsequent arguments are the extents of the view dimensions. Each extent specifies the number of elements in that dimension.

    In this example, we have two dimensions with extents of 2 and 6. The product of the extents must not exceed the number of underlying elements. Our source vector has 12 elements and the product of 2 × 6 is 12, so the product of our extents and the size of our source data are equal.

    This creates a two-dimensional mdspan object, with 2 elements in one dimension, and each of those elements has its own span of 6 elements.

  • We can now view our data in two dimensions using the new multidimensional subscript operator:
    for (size_t i1 {0}; i1 < mds2.extent(0); ++i1) {     for (size_t i2 {0}; i2 < mds2.extent(1); ++i2) {         print("[{},{}] {:02d} ", i1, i2, mds2[i1,i2]);     }     println(""); }

    The output looks like this:

    [0,0] 01 [0,1] 02 [0,2] 03 [0,3] 04 [0,4] 05 [0,5] 06 [1,0] 07 [1,1] 08 [1,2] 09 [1,3] 10 [1,4] 11 [1,5] 12

    We format the output to include the subscripts so we can clearly see the position of each element in the two-dimensional array. Each element's integer value is formatted with leading zeros, using the formatter specification {:02d} , so the values align visually.

  • We can view this same data set in three dimensions by simply creating another mdspan object:
    mdspan mds3(v.data(), 2, 3, 2);

    For this object, We pass the same data and specify three extents. This creates a new three-dimensional view into the same source data.

  • We can print the mds3 view using the same technique we used for two dimensions:
    for (size_t i1 {0}; i1 < mds3.extent(0); ++i1) {     for (size_t i2 {0}; i2 < mds3.extent(1); ++i2) {         for (size_t i3 {0}; i3 < mds3.extent(2); ++i3) {             print("[{},{},{}] {:03d} ",                   i1, i2, i3, mds3[i1,i2,i3]);         }         println("");     } }

    That gives us this output:

    [0,0,0] 001 [0,0,1] 002 [0,1,0] 003 [0,1,1] 004 [0,2,0] 005 [0,2,1] 006 [1,0,0] 007 [1,0,1] 008 [1,1,0] 009 [1,1,1] 010 [1,2,0] 011 [1,2,1] 012

    Both views may coexist because the underlying data remains in its original location. The mdspan class merely provides a view into that data set.

How it works

The constructor for the mdspan class looks like this:

template<class... OtherIndexTypes> mdspan(data_handle_type p, OtherIndexTypes... exts);

The first argument is a pointer to a contiguous data source. The exts argument(s) is (are) variadic template argument(s) that take one or more extents , which specify the dimensions of the mdspan object.

The C++23 specification provides new multidimensional support for the subscript [] operator. This means that we may now use comma-separated values in the subscript operator.

The multidimensional subscript operator uses a variadic template to pass arguments as a parameter pack. We can demonstrate this capability with a simplified example of a class that overloads a multidimensional subscript operator:

template <typename T> struct multidim {     template <typename... I>     const T operator[](I... indices) const {         return (indices + ...);     } };

This example uses a fold expression to return the sum of the indices passed to the subscript [] operator. We can call it like this:

multidim<int> m1 {}; println("multidim[1,1,1,1,1]: {}", m1[1,1,1,1,1]); println("multidim[5,6,7]: {}", m1[5,6,7]);

And we get this output:

multidim[1,1,1,1,1]: 5 multidim[5,6,7]: 18

The mdspan class uses this new feature to provide support for multiple indices into the data, like this:

x = o[1,2,3];

This allows the mdspan object to use the subscript operator to access values in multiple dimensions.

There's more…

Because the mdspan class uses an external data store and does not own the underlying data itself, it returns data elements as references. This allows you to both read and write those elements.

That means that if we start with a simple ordinal data set:

vector v {1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12};

We can modify that data set like this:

for (size_t i1 {0}; i1 < mds2.extent(0); ++i1) {     for (size_t i2 {0}; i2 < mds2.extent(1); ++i2) {         mds2[i1, i2] = mds2[i1, i2] * 10 + (int)i2;     } }

That modifies the data in the underlying vector object, so when we look at it:

for (auto n : v) {     print("{} ", n); }

We now get this output:

10 21 32 43 54 65 70 81 92 103 114 125

While it's important to understand that this allows the data to be mutable, it's not always a good idea. If you want to minimize the risk of side effects, you should qualify your source data as const :

const vector v {1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12};

It's generally good practice to const -qualify any variables that you don't need to modify.

Initialize containers from ranges

Beginning with C++23, we can now initialize a container directly from a range or view .

Until now, to initialize a container from a range we could use a for loop:

vector<int> v {}; for (auto n : views::iota(1, 11)) {     v.push_back(n); }

Or we could construct the vector from the range iterators:

auto r = views::iota(1, 11); vector<int> v(r.begin(), r.end());

In C++23, this becomes much easier with the new ranges::to class:

auto v = views::iota(1, 11) | ranges::to<vector<int>>();

This new pattern is both simpler and clearer. The ranges::to class is defined in the <ranges> header.

How to do it

The ranges::to class takes any range or view as input via the pipe syntax and returns a new non- view object.

  • For convenience, we first declare namespace aliases for ranges and views :
    namespace ranges = std::ranges; namespace views = ranges::views;
  • We start by populating a vector from a view :
    auto vec = views::iota(1, 11)     | views::transform([](const auto n){ return n * 5; })     | ranges::to<vector>(); for (auto n : vec) print("{} ", n); print("\n");

    In this example, views::iota(1, 11) generates a sequence of integers from 1 to 10 . We then use views::transform with a lambda function to multiply each integer by 5 . Finally, ranges::to<vector>() returns a new vector for our initialization.

    The function call operator (parentheses) after ranges::to<> is required for use with the pipe syntax:

    auto vec = r | ranges::to<vector>;   // syntax error auto vec = r | ranges::to<vector>(); // correct usage

    This produces the output:

    5 10 15 20 25 30 35 40 45 50
  • You may use this technique to initialize any sequential container:
    auto lst = vec | views::reverse     | ranges::to<list<double>>(); for (auto d : lst) print("{} ", d); print("\n");

    In this example, we use views::reverse to reverse the order of the sequence, and ranges::to<list<double>>() to construct a list of double , which produces the output:

    50 45 40 35 30 25 20 15 10 5

Let's take a look at how this works.

How it works

The ranges::to class is designed to work in a variety of contexts. To return a container C from range R , it considers a series of scenarios and chooses the first valid method from the list:

  • Construct C from R
  • Construct C from R using a tagged range constructor ( from_range_t )
  • Construct C from the range iterators R.begin() , R.end()
  • Construct C , then insert each element of R at the end of C
  • If C is a range whose value type is itself a range (and is not a view ), and R 's value type is also a range , for each element of R , a value of to<range_value_t> is inserted at the end of C

This allows a more unified and concise syntax for constructing containers from ranges.

Find content in strings and ranges with contains()

C++23 provides a new strategy for searching within a string , string_view , or range object. The new string::contains() and string_view::contains() methods check whether a string or string_view contains a given substring. The ranges::contains() algorithm looks for an element or sub-range in a given range . These functions return a Boolean true or false value to indicate if the provided value(s) are contained in the target.

This new method of searching a container is simpler and more convenient than using the find() method. The find() method returns a position, or the special string::npos value to indicate that the substring was not found. That means that if we just want to know if a string is contained within another string, we need to compare against the npos value like this:

string s {"https://example.com"}; if (s.find("https://") != string::npos) {     println("good!"); }

For this purpose, the contains() method is both simpler and more readable:

string s {"https://example.com"}; if (s.contains("https://")) {     println("good!"); }

The same is true for the ranges::contains() algorithm:

const vector<int> v {1, 2, 3, 4, 5}; if (ranges::contains(v, 3)) {     println("got it!"); }

This provides a concise solution for a simple search.

The ranges::contains() algorithm is defined in the <algorithm> header.

How to do it

The string::contains() method provides a simple true or false result when searching a string object for a substring.

  • We can search for content in a string :
    const string s {"Big Light In Sky Appears In East"}; if (s.contains("Sky")) {     println("found"); }
  • The string_view class also has a contains() method:
    const string_view sv {"Why is abbreviated such a long word?"}; if (sv.contains("word")) {     println("found"); }
  • Or we can search for a single character:
    const string s2 {"abcdefg"}; if (s2.contains('f')) {     println("found"); }
  • We can search for an element in a range using ranges::contains() :
    const auto r = views::iota(1,11); if (ranges::contains(r, 7)) {     println("found"); }
  • Or we can search for a sub-range within a range, using ranges::contains_subrange() :
    const std::vector<int> v1 {1, 2, 3, 4, 5}; const std::vector<int> v2 {2, 3, 4}; if (ranges::contains_subrange(v1, v2)) {     println("found"); }

These new C++23 features make searching within containers more concise and readable.

How it works

The string::contains() function is a method of the string class. This means that it is called on the string object with the member operator:

if (s.contains("Sky")) {     ... }

The function signatures for string::contains() look like this:

bool contains( std::string_view sv ) const noexcept; bool contains( char c ) const noexcept; bool contains( char* s ) const;

This provides overloads for passing different types to the function. The first overload takes a std::string_view object. When we pass a std::string object it gets promoted to a string_view for simple access to the raw underlying C-string data.

The second overload takes a char type. This allows us to search for an individual character in a string:

if (s.contains('r')) {     println("char 'r' found"); }

The third overload takes a primitive C-string:

if (s.contains("Sky")) {     println("c-string \"Sky\" found"); }

This overload prevents the C-string from being promoted to a more complex std::string object.

The ranges::contains() algorithm is found in the <algorithm> header. This is a stand-alone template function, not a class member function. The function signatures look like this:

template< input_iterator I, sentinel_for<I> S, class T,     class Proj = std::identity > constexpr bool contains( I first, S last, const T& value,     Proj proj = {} );  template< input_range R, class T, class Proj = identity > constexpr bool contains( R&& r, const T& value,     Proj proj = {} );

This function provides two overloads. The first uses iterators for the source container. The second takes a range object. We will consider the range overload:

constexpr bool contains( R&& r, const T& value,     Proj proj = {} );

We use this as we would any algorithm in the ranges library:

const auto r = views::iota(1,11); if (ranges::contains(r, 7)) {     println("found"); }

This allows us to succinctly search for a value in a range.

The third argument proj is a projection . This allows us to use a lambda expression to modify the values from the range r before the comparison is performed:

const auto r = views::iota(1, 11); if (ranges::contains(r, 14, [](auto n){return n * 2;})) {     println("found"); }

Using this simple lambda, we double each value in the range r before comparing it. Now the value 7 will match the value 14 in the ranges::contains call.

There's more…

The ranges::contains_subrange() algorithm may be used to test for a sub-range within a range. Its function signatures specify two ranges:

template< forward_iterator I1, sentinel_for<I1> S1,     forward_iterator I2, sentinel_for<I2> S2,     class Pred, class Proj1, class Proj2 > constexpr bool contains_subrange( I1 first1, S1 last1, I2 first2,     S2 last2, Pred pred = {},     Proj1 proj1 = {}, Proj2 proj2 = {} );  template< forward_range R1, forward_range R2,     class Pred, class Proj1, class Proj2 > constexpr bool contains_subrange( R1&& r1, R2&& r2,     Pred pred = {}, Proj1 proj1 = {}, Proj2 proj2 = {} );

The two ranges r1 and r2 are often referred to as the needle and the haystack , where we can think of the function as searching for a needle in a haystack. The range r1 is the haystack and r2 is the needle. Likewise, the projections proj1 and proj2 operate on the haystack and the needle, respectively.

This allows us to search for one range within another:

const std::vector<int> v1 {1, 2, 3, 4, 5}; const std::vector<int> v2 {2, 3, 4}; if (ranges::contains_subrange(v1, v2)) {     println("found"); }

This works just like ranges::contains , but searches for a range rather than a single value.

The contains_subrange() algorithm also takes a predicate, which can be used as an alternative comparison function, just like other functions in the algorithm library.

Using the two projections, we can modify values before we compare them:

const std::vector<int> v3 {10, 20, 30, 40, 50}; const std::vector<int> v4 {2, 3, 4}; if (ranges::contains_subrange(v3, v4, {},         [](const auto& n){return n / 5;},         [](const auto& n){return n * 2;})) {     println("found"); }

In this example, we leave the comparison predicate empty with {} and we provide two projections. The first projection modifies values read from the haystack v3 , and the second projection modifies values read from the needle v4 .

While there are many options available, in practice you'll most likely use the simplest forms which provide concise syntax for finding a substring within a string, or an object within a sequence.

Subscribe to Deep Engineering

Join thousands of developers and architects who want to understand how software is changing, deepen their expertise, and build systems that last.

Deep Engineering is a weekly expert-led newsletter for experienced practitioners, featuring original analysis, technical interviews, and curated insights on architecture, system design, and modern programming practice.

Scan the QR or visit the link to subscribe for free.

Deep Engineering QR

Назад: Preface
Дальше: Chapter 2: Best Practices