C++23 is complete and pretty much finalized, so let’s explore the new features
in C++, from a data science point of view. This is not as large of a release as
C++20 or C++11, but it’s still a hefty release with lots of nice new features -
larger than C++17.
Some of the major new features are building on themes from C++20, such as vastly
expanded ranges and views, more constexpr/consteval, module support for the
standard library, std::print to supplement std::format, and a
std::generator to support coroutines. There are also several general features,
lambdas and classes get along much better now, there are new preprocessor
additions (!), a stacktrace library, flat maps/sets, and new std::optional
monadics, deducing this, and many various additions.
Updates on C++20
Like C++14, there were quite a few updates to features added in the previous
standard. Let’s start by revisiting my major points from C++20, and see how they
were updated. The only one without an update was concepts.
Standard lib as a module
Module support in C++ landed in C++20, but there was a glaring omission: the
standard library! That’s now been corrected (P2465).
Classic C++
#include <vector>
#include <iostream>
...
This is fantastic, and shows the power of modules. You don’t need to list a
bunch of includes, just this one import, and now the compiler can intelligently
select what is needed. It’s so clean!
More Constexpr all the things
The largest new addition is if consteval (P1938), which further expands on
consteval from the last release.
There are also more constexpr additions: std::optional, std::variant
(P2231), and std::unique_ptr (!) (P2273), along with the items in cmath
and cstdlib (P0533). This really improves compile time programming in C++23
over its predecessors.
Ranges redux
More additions to ranges! This is a massive usability improvement. It’s really
hard to do much with ranges without a zip, and now it’s there!
auto num = std::vector{1, 2, 3};
auto letters = std::array{'A', 'B', 'C'};
for (const auto [n, l] : std::views::zip(num, letters)) {
std::println("{} {}", n, l);
}
The additions to std::ranges::* are:
to: Convert ranges to containers (including nesting!) (P1206)iota: Like std::iota (P2440)shift_left/shift_right: like std::shift_left / std::shift_right
(P2440)fold*: Folding algorithms (P2322)find_last*: Find last element that matches (P1223)starts_with/ends_with: Check beginning/end of range with another range
(P1659)contains/contains_subrange: If a range contains an element or another
range (P2302)
And, std::views::*:
zip*: Combine ranges (shown above) (P2321)zip_transform: Feed zip output to a function (P2321)chunk / chunk_by: Divides range into chunks, last chunk can be shorter
(good for splitting up work, for example) (P2442, P2443)adjacent: Make a sliding window (overlapping chunks) (P2321)adjacent_transform: Apply a function on the adjacent window (P2321)slide: Runtime version of adjacent, sliding window (P2442)cartesian_product: Compute a Cartesian product (P2374)join_with: Flatten range of ranges into a range (P2441)repeat: Repeats a range (n times or infinite) (P2474)enumerate: Pair each element with its index, like Python (P2164)stride: Take every nth element (P1899)as_const / as_rvalue: Change how elements are handed out (P2278,
P2446)
There still are things missing that were in ranges v3, but a lot of them can be
easily implemented using the next feature in this list!
Coroutines - std::generator
Making a generator is one of the first things users likely think of when seeing
coroutines in C++20, and now it’s much easier with std::generator (P2502,
which is an input range). For example, we could create an infinite range, and
then use take to get the first 10 items:
import std;
std::generator<int> range(int start = 0) {
while(true) {
co_yield start++;
}
}
int main() {
for (int i : range() | std::views::take(10)) {
...
}
}
Formatting now has std::print(ln)
This was skipped over for C++20, but now is here; std::print and
std::println (P2093)! We can finally say goodbye to
std::cout << std::format(...) << std::endl. These write to stdout by
default, but you can give a FILE* or an std::ostream as the first argument
(P2539).
You can also use std::format and friends on ranges (P2286).
New features
Now the major new features of C++23 that don’t expand on C++20.
MDSpan
C++20 deprecated using the comma operator, and now it’s back as a
multidimensional subscript (P2128)! item[1,2] is now valid C++. This is
implemented in a brand new std::mdspan (P0009), which gives you a
multidimensional view of an existing data structure. This means we have a
language for passing views of and using multidimensional arrays
std::vector v{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12};
auto view = std::mdspan(v.data(), 2, 6);
// view.extent(0) == 2
// view.extent(1) == 6
// view[0,0] == 1
This is only available as a view (joining std::span and std::string_view
from C++17) for now. Also, sub-views are not yet included (likely addition for
C++26).
Deducing this
One of the most powerful tiny additions is “deducing this” (P0847). This
allows you to explicitly take a “self” argument (noted by the this keyword
proceeding it).
It looks like this:
Classic C++
struct Foo {
void bar() &;
void bar() const&;
void bar() &&;
void bar() const &&;
};
C++23
struct Foo {
void bar(this Foo& self);
void bar(this Foo const& self);
void bar(this Foo&& self);
void bar(this Foo const&& self);
};
Why would you want to write it that way? Well, besides being more consistent
with the way you specify other parameters, and looking more like languages like
Python, there’s a huge reason to do this: you can template on this parameter!
For example, the above four overloads can be written as:
struct Foo {
template<typename Self>
void bar(this Self&& self) {...}
};
Now you no longer need to make separate const and non-const versions of your
methods!
Another use of this is for CRTP. One of the first advanced C++ tricks you might
learn is CRTP, which allows you to implement compile-time polymorphism by
requiring a subclass to put itself into a template parameter. Deducing this
allows you to simply write this as normal class, since this is deduced as the
child type!
Here’s an example, taken from CLI11’s Options classes, with shortened names for
clarity:
Classic C++
template<typename CRTP>
struct OptionBase {
/// [snip]
CRTP* take_last() {
auto *self = static_cast<CRTP *>(this);
self->mo_policy(MOPolicy::TakeLast);
return self;
}
};
struct OptionDefaults : OptionBase<OptionDefaults> {
/// [snip]
};
struct Option : OptionBase<Option> {
/// [snip]
};
C++23
struct OptionBase {
/// [snip]
template<typename Self>
auto take_last(this Self&& self) {
self.mo_policy(MOPolicy::TakeLast);
return self;
}
};
struct OptionDefaults : OptionBase {
/// [snip]
};
struct Option : OptionBase {
/// [snip]
};
Simplifying the base class (no longer a template class), the methods, and the
child classes (no longer having to template on themselves) is a huge win!
There are other things that can be done with this as well, like recursive
lambdas. By taking a this auto self, you can call self(...) inside the
lambda. To read more, see the Deducing this article or the paper (P0847).
std::expected and monadics
C++ is well known for “pay only for what you use”. The best known exception to
that rule is exception handling; you pay for it (in binary size, etc.) even if
you don’t use it. A different take on handling exceptions is present in Rust:
you make the exception part of a variadic return type. This both avoids
traditional exception handling and forces a user to explicitly handle all
expected errors. Similar to how std::optional is a specialization of a variant
for a value or None return, std::expected (P0323) is a specialized variant
for a value or an error. C++ doesn’t have as many syntax features to help you
use this, but it’s still a welcome addition. And we do have monadic methods
.and_then, .or_else, .transform, and .transform_error (P2505)! These
allow a very elegant chaining of expected return types.
That’s not the only place monadics have been added; std::optional gets them
too (P0798).
std::optional<int> v = /* something */;
v.transform([](int n){...}) // Processes values, skips if nullopt
v.and_then([](int n){...}) // Similar, but returns optionals
v.or_else([]{...}) // Converts empty values, returns optionals
Combined with the existing .value_or, these provide a way to work with
optionals and expected quite elegantly.
Smaller updates
Lambda functions
We can now drop the () from a lambda that doesn’t take any parameters
(P1102). Lambdas can be declared static if they don’t capture anything, which
saves a registry call (P1169).
Preprocessor updates
We finally have #elifdef and #elifndef (P2334), which you would rather
expect since we have #ifdef. #warning is now officially part of the language
(P2437).
Range based for
This used to be buggy:
for(auto&& val: foo().bar()) {...}
Here, foo returns a temporary object, and bar returns a reference to a member,
the compiler could destroy the object before the body of the loop, causing the
reference to be invalid. Now, the lifetime of objects created on the right of
the colon guaranteed to last through the body of the loop (P2718).
Contains
There are now member functions for std::string and std::string_view to check
to see if a string contains a substring or a single character (P1679)! This
tiny thing is a staple in most other languages (it even has a keyword, in, in
Python) and was sorely lacking in C++. There is a matching new ranges feature
for arbitrary ranges, as well (P2302). This complements the .contains that
containers got in C++20.
Even smaller things
Here are a few too small to write up much about:
- Literal suffix for
std::size_t (uz) and std::ssize_t (z) (P0330) auto(x) / auto{x}, which do a decay copy (and optimize to nothing on
prvalues) (P0849) (nice post)- Class template argument deduction improvements for inheritance (P2582)
(nice post)
operator() and operator[] can be static (P1169, P2589)[[assume]] attribute to tell the compiler that an (unevaluated) expression
can be assumed true (P1774)std::to_underlying to convert an enum to the underlying type. (P1682)std::unreachable to mark unreachable code (P0627)std::move_only_function, the std::function you want when the callable
holds a unique_ptr (P0288)std::forward_like to forward a member with the value category of the object
it came from - handy with deducing this (P2445)std::out_ptr / std::inout_ptr to pass a smart pointer to a C API that
wants a T** (P1132)std::start_lifetime_as, the well-defined way to say “these bytes are now a
T” instead of the reinterpret_cast most of us write (P2590)std::byteswap to reverse the bytes of an integer, useful for file formats
(P1272)std::string::resize_and_overwrite to fill a buffer without paying for the
zero fill first (P1072)std::invoke_r, an std::invoke with an explicit return type (P2136)- Labels are allowed at the end of a compound statement (P2324)
- Narrowing contextual conversions are allowed in
static_assert and
if constexpr (P1401)
Other new libraries
We’ve already covered <print>, <expected>, and <mdspan> above.
Stacktraces
C++ now has a built in library to help with stacktraces, <stacktrace>
(P0881). For example:
std::cout << std::stacktrace::current() << '\n';
Better support for exceptions is planned for C++26.
Flat maps and sets
There are new flat (dense storage) versions of maps and sets (P0429, P1222).
These store the map/set values together in memory, at the expense of
modification time. In general, these are probably better if you don’t have to
modify them much.
Spanstream
<spanstream> gives you a stream over a buffer you own, like std::span does
for arrays (P0448). Unlike stringstream, there is no allocation, which makes
it a good fit for parsing.
Final words
C++23 is a great addition to the language. C++ is still getting Rust-like
features (like std::expected), better ranges, and features that deeply impact
what you can do (deducing this). Now we need to watch the various compilers to
see when we can start using these features.
I normally post reports from the meetings here. But with COVID, this development
cycle has been very strange. Here’s what I could find:
C++26
The first C++26 meeting was held hybrid:
A few exciting things are already adopted for C++26, including _ as a
placeholder, work in making string formatting support constexpr, a type-erased
callable reference, 100 more constexpr functions, even constexpr sorting
(including ranges-based sorts).
C++26 looks to be focused on Contracts, Reflection (maybe?), and the async model
(senders/receivers). Executors and a form of std::simd could land too. It’s
much too early to tell what will make it in, but if Reflection does, it will be
the biggest change to C++ in a long time.
The next ++?
There also is a lot of work going into making a successor language to C++. It’s
unclear what that would be, with several groups working on several different
languages, like:
- Val:
making a safe version of C++
- Carbon: A cleanup project by
Google, but interop with C++ will be hard due to removed features like
constructors.
- Cpp2: A better C++, 50x safer and 10x
simpler (target goal) from Herb Sutter
What makes these different from some other languages (like D, Go, and Rust) is
that they are designed to interface smoothly with C++. A great article on these
can be found here.
Further reading
AI Usage Disclaimer
This post was later revised with AI
assistance: the code samples were compiled and checked, paper links were added,
and a few missing features were filled in.