C++23: More small pearls in the core language
The core language C++23 has more to offer than the great innovation Deducing This. The smaller but exciting innovations include the static multidimensional subscript (index operator) and the call operator.
Advertisement
Rainer Grimm has been working as a software architect, team leader and training manager for many years. He likes to write articles on the programming languages āāC++, Python and Haskell, but also likes to speak frequently at specialist conferences. On his blog Modernes C++ he deals intensively with his passion for C++.
auto(x) and auto{x}
In my last article āC++23: The little gems in the core languageā I gave a brief explanation of auto(x) and auto{x}: The calls convert x to a prvalue as if taking x as a function argument would be passed by value. auto(x) and auto{x} perform a decay copy. I also explained decay copy in this context. This explanation of auto(x) and auto{x} was short, too short.
GaŔper Ažman write a tweet about it:
GaÅ”per is an active member of the C++ standardization committee: in C++ he was involved in the standardization of ādeducing thisā and āusing enumā. In addition, GaÅ”per is involved in the standardization of ācustomization pointsā and ācontractsā and is vice-chair of the Networking Study Group.
Advertisement
I asked GaÅ”per if he would like to write a few words about the impact of auto(x) and auto{x} on functional interface design. Iām happy to present his answer. His explanations are embedded in the source code.
#include
#include
#include
#include
#include
#include
#include
#include
#include
// To achieve a design that improves local reasoning, we should
// design algorithm interfaces to not mutate their arguments if
// at all possible.
// Unfortunately, this is often at odds with the efficiency of
// the implementation of the algorithm.
// This comes up in many areas. A few examples include:
// ā matrix algorithms, where implementations often require
// that arguments do not alias
// ā state machines, where testing is far easier if we can
// produce a completely new state
// ā range algorithms such as the below example
// ā immutable maps and sets (see the immer
// library:
// For clarity, let us consider a small example.
// Our data structure will be a vector of integers
// Our family of algorithms will be āsortedā and āuniquedā
// In c++20 (if we ignore ranges ā we are trying to
// illustrate an approach), we would probably design the
// interface by taking by value and returning by value
// (helpers)
auto read_input(int argc, char** argv) -> std::vector
auto write_output(int) -> void;
namespace traditional {
template
auto sorted(std::vector
std::sort(x.begin(), x.end());
return x;
}
template
auto uniqued(std::vector
x.erase(std::unique(x.begin(), x.end()), x.end());
return x;
}
// This pattern leads to the following usage pattern
auto usage(int argc, char** argv) {
for (auto i : uniqued(sorted(read_input(argc, argv)))) {
write_output(i);
};
}
// This is good, but sooner or later someone will want to refactor
// this code like this
auto refactor(int argc, char** argv) {
auto input = read_input(argc, argv);
for (auto i : uniqued(sorted(input))) {
write_output(i);
};
}
// can you spot the bug? Non-professionals often donāt!
// If you work with researchers and scientists, this kind of
// mistake is ubiquitous, and leads to serious, serious
// slow-downs that are often difficult to find if not spotted
// immediately.
// What can we do? We should ask the compiler to issue an error,
// of course. We can do this by explicitly asking for an rvalue
// reference instead of taking by value.
}
namespace require_moves {
template
auto sorted(std::vector
// ^^ new
std::sort(x.begin(), x.end());
return x;
}
template
auto uniqued(std::vector
// ^^ new
x.erase(std::unique(x.begin(), x.end()), x.end());
return x;
}
auto read_input(int argc, char** argv) -> std::vector
auto write_output(int) -> void;
auto usage(int argc, char** argv) {
// compiles unchanged, and has the same performance
for (auto i : uniqued(sorted(read_input(argc, argv)))) {
write_output(i);
};
}
auto refactor(int argc, char** argv) {
auto input = read_input(argc, argv);
for (auto i : uniqued(sorted(std::move(input)))) {
// ^^^^^^^^^^ ^ required, does
// not compile without it!
write_output(i);
};
}
auto print_diff(std::vector
// of course, now we have a problem. What if we actually needed
// the copy?
#if defined(TRY_1)
auto check_is_sorted_and_uniqued(int argc, char** argv) {
auto input = read_input(argc, argv);
auto sorted_and_uniqued = uniqued(sorted(input));
// ^^^^^^ no matching function
// for call to sorted
if (input != sorted_and_uniqued) {
print_diff(input, sorted_and_uniqued);
exit(1);
}
exit(0);
}
#elif defined(TRY_2)
// we can work around this by making a copy explicitly
auto check_is_sorted_and_uniqued(int argc, char** argv) {
auto input = read_input(argc, argv);
auto input_copy = input; // (x); }
// If we have that, we could write our check_is_sorted_and_uniqued
// without the named copy:
auto check_is_sorted_and_uniqued(int argc, char** argv) {
auto input = read_input(argc, argv);
auto sorted_and_uniqued = uniqued(sorted(decay_copy(input)));
// ^^^^^^^^^^ āexplicitā
// copy
if (input != sorted_and_uniqued) {
print_diff(input, sorted_and_uniqued);
exit(1);
}
exit(0);
}
// This works, and in this case is optimal, but leaves something to
// be desired in generic cases. Let us try and see what happens if
// we try and refactor sort+unique into a generic algorithm
#endif
// Letās take our vector as a forwarding reference so we can reuse
// its memory if we own it. We need a concept for that
template
inline constexpr bool is_vector_v = false;
template
inline constexpr bool is_vector_v<:vector a>> = true;
template
concept a_vector = is_vector_v<:remove_cvref_t>>;
// we take this by forwarding reference; but now,
// we make an additional move-construction if v is passed by
// rvalue reference
auto sorted_and_uniqued(a_vector auto&& v) {
return uniqued(sorted(decay_copy(std::forward
// ^^^^^^^^^^ an extra move construction or
// the needed copy-construction
// specifically, we move-construct decay_copyās return value.
}
// so, decay_copy is clearly not optimal. We need something that
// wonāt result in additional move-constructions and still
// accomplish our ācopies are explicitā goal.
// enter: decay-copy in the language!
}
namespace done_properly {
using require_moves::sorted, require_moves::uniqued, require_moves::a_vector, require_moves::print_diff;
// in regular user code, we can now use auto{} instead of
// decay-copy:
auto check_is_sorted_and_uniqued(int argc, char** argv) {
auto input = read_input(argc, argv);
auto sorted_and_uniqued = uniqued(sorted(auto(input)));
// ^^^^^^^^^^^ explicit
// copy
if (input != sorted_and_uniqued) {
print_diff(input, sorted_and_uniqued);
exit(1);
}
exit(0);
}
// in generic contexts
auto sorted_and_uniqued(a_vector auto&& v) {
return uniqued(sorted(auto(std::forward
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
// correct forwarded copy of an argument we took by forwarding
// reference
}
}
// Thanks for reading!
More information on how to pass function parameters can be found here: C++ Core Guidelines: Semantics of Function Arguments and Return Values.
Multidimensional index operator
Thanks to std::mdspan, C++23 supports multidimensional arrays. In addition, the C++23 core language of C++23 offers a multidimensional index operator to complete the feature.
// multidimensionalSubscript.cpp
#include
#include
template
struct Matrix {
std::array
T& operator[](std::size_t x, std::size_t y) { // (1)
return mat[y * X + x];
}
};
int main() {
std::cout mat;
for (auto i : {0, 1, 2}) {
for (auto j : {0, 1, 2}) mat[i, j] = (i * 3) + j; // (2)
}
for (auto i : {0, 1, 2}) {
for (auto j : {0, 1, 2}) std::cout (1) defines the two-dimensional subscript operator for the class Matrix. (2) use it to define the elements and (3) use it to read out the values.
Here is the output of the program:
Static operator () and operator []
With C++23, the call operator (operator ()) and the multidimensional index operator (operator []) be static. The answer to the why question is typically C++: optimization.
optimization
The implicit this pointer must be passed in an extra register when a member function is not called inline. Thanks to a static member function, you can save on this pointer. Lambdas that have no state can also be static in C++23:
auto sum = [](auto a, auto b) static {return a + b;};
For consistency, the multidimensional index operator can also be static.
// multidimensionalSubscriptStatic.cpp
#include
#include
template
struct Matrix {
static inline std::array
static T& operator[](std::size_t x, std::size_t y) { // (1)
return mat[y * X + x];
}
};
int main() {
std::cout mat;
for (auto i : {0, 1, 2}) {
for (auto j : {0, 1, 2}) mat[i, j] = (i * 3) + j;
}
for (auto i : {0, 1, 2}) {
for (auto j : {0, 1, 2}) std::cout Now the two-dimensional index operator (1) and the std::array mat (2) are static.
Whatās next?
My next article will be a guest post by Victor Duvanenko. In his article he presents detailed performance figures for my favorite feature in C++17: the parallel STL algorithms. (rm)
To home page