C++26 Expansion Tricks
P1306 gives us compile time repetition of a statement for each element of a range - what if we instead want the elements as a pack without introducing a new function scope?
In this blog post we’ll look at the expand
helper, expansion statements and how arbitrary ranges can be made decomposable via structured bindings to reduce the need for IILEs.
Element-wise expansion
The expand
pattern
The reflection features introduced in P2996 by themselves are sufficient to iterate over a compile time range. The paper introduces a helper expand
for this purpose, here’s a slightly modified version:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
template <auto... Elts>
struct Replicator {
template <typename F>
constexpr void operator>>(F fnc) const {
(fnc.template operator()<Elts>(), ...);
}
};
template <auto... Elts>
constexpr inline Replicator<Elts...> replicator{};
template <std::ranges::range R>
consteval std::meta::info expand(R const& range) {
std::vector<std::meta::info> args{};
for (auto item : range) {
args.push_back(std::meta::reflect_value(item));
}
return substitute(^^replicator, args);
}
This allows us to write the following code. Note that Member
needs to be a constant to be usable in a splice.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
template <typename T>
void print_members(T const& obj) {
[:expand(nonstatic_data_members_of(^^T)):]
>> [&]<auto Member>{
std::println("{}: {}", identifier_of(Member), obj.[:Member:]);
};
}
struct Test {
int x;
char p;
};
int main() {
print_members(Test{42, 'y'});
// prints:
// x: 42
// p: y
}
Early return
This does not yet match loop semantics closely enough. continue
can be expressed as return;
but we cannot yet express break
or return a value.
First, let’s introduce a way to stop iteration at any point. The short-circuiting property of &&
and ||
is useful for this, we just need to let the lambda return a bool to indicate whether we should keep iterating.
1
2
3
4
5
6
7
template <auto... Elts>
struct Replicator {
template <typename F>
constexpr void operator>>(F fnc) const {
(fnc.template operator()<Elts>() && ...);
}
};
To reuse the example from before, we can now let it stop as soon as some arbitrary condition is met. In the following example we stop as soon as a member named x
is reached - therefore the second member of Test
will not be printed.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
template <typename T>
void print_members(T const& obj) {
[:expand(nonstatic_data_members_of(^^T)):]
>> [&]<auto Member>{
std::println("{}: {}", identifier_of(Member), obj.[:Member:]);
// stop after we've reached a member named "p"
return identifier_of(Member) == "p";
};
}
struct Test {
int x;
char p;
};
int main() {
print_members(Test{42, 'y'});
// prints:
// x: 42
}
Returning values
Returning values is a little more difficult. To do this let’s circle back a bit - instead of expressing our intent to continue iteration by returning a bool, we stop iterating once the first evaluation of the lambda returns something other than a void
.
So, let’s first define a metafunction to retrieve the first non-void type in a pack of types.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
template <typename...>
struct FirstNonVoid;
template <>
struct FirstNonVoid<> {
using type = void;
};
template <typename T, typename... Ts>
struct FirstNonVoid<T, Ts...> {
using type = std::conditional_t<
std::is_void_v<T>,
typename FirstNonVoid<Ts...>::type,
T
>;
};
template <typename... Ts>
using first_non_void = typename FirstNonVoid<Ts...>::type;
With this utility we can now tell if any specialization of fnc
’s operator()
returned something other than void
. This unfortunately matters since void
is not a regular type.
Let’s first do the trivial case where no F::operator()
specialization returns a value. This also means that no early return is going to happen - we can safely fold over ,
instead.
1
2
3
4
5
6
7
8
9
template <typename F>
constexpr auto operator>>(F fnc) const {
using ret_t = first_non_void<decltype(fnc.template operator()<Elts>())...>;
if constexpr (std::is_void_v<ret_t>){
(fnc.template operator()<Elts>(), ...);
} else {
// ...
}
}
Returning a value is a little more involved.
We already know that sooner or later a F::operator()
specialization will return something other than a void
, so we can prepare an object of this type to be returned later. To avoid a default-constructibility requirement on the return type, the return object can be wrapped in a union. Note however that this will imply that the return type must be copy-constructible.
This issue can also be worked around, but the primary point here is to see just how much code is required to roughly emulate expansion statements.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
template <typename F>
constexpr auto operator>>(F fnc) const {
using ret_t = first_non_void<decltype(fnc.template operator()<Elts>())...>;
if constexpr (std::is_void_v<ret_t>){
(fnc.template operator()<Elts>(), ...);
} else {
union {
char dummy;
ret_t obj;
} ret {};
if(!(invoke<Elts>(fnc, &ret.obj) && ...)){
return ret.obj;
} else {
std::unreachable();
}
}
}
To keep using the short-circuiting property of &&
, another helper invoke
must be introduced. If the requested F::operator()
specialization returned void
, invoke
shall return true
. Otherwise it must return false
to stop iteration and finally copy construct ret.obj
from the return value.
1
2
3
4
5
6
7
8
9
10
11
12
template <auto E, typename F, typename R>
constexpr bool invoke(F fnc, R* result) {
using return_type = decltype(fnc.template operator()<E>());
if constexpr (std::is_void_v<return_type>){
fnc.template operator()<E>();
return true;
} else {
std::construct_at(result, fnc.template operator()<E>());
return false;
}
}
Finally we can write the following code.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
template <typename T>
auto get_p(T const& obj) {
return [:expand(nonstatic_data_members_of(^^T)):]
>> [&]<auto Member>{
if constexpr (identifier_of(Member) == "p") {
return obj.[:Member:];
}
};
}
struct Test {
int x;
char p;
};
int main() {
std::print("{}", get_p(Test{42, 'y'}));
// prints:
// y
}
However, note that a if constexpr
statement must be used to guard the early return.
Expansion statements
Unfortunately using expand
implies having to use a lambda expression and therefore introduce a new function scope. While this isn’t typically all that problematic, it can for instance cause issues with reflections of function parameters (P3096) since they are only splicable within their corresponding function body.
P1306 template for
expansion statements allow us to avoid the extra function scope.
1
2
3
4
5
6
7
-[:expand(some_range):] >> []<auto Elt>{
- // ...
-};
+template for (constexpr auto Elt : define_static_array(some_range)) {
+ // ...
+}
The define_static_array
from P3491 is required because we do not yet have non-transient constexpr allocation. This is unfortunate, but oh well.
Amazingly expansion statements also support break
, continue
and early return.
Transforming ranges to packs
The expand
pattern
So, we’ve established that expansion statements are pretty useful. What if we need the elements as a pack though? We might want to use the elements in a fold expression or expand them into an argument list.
To do this, let’s introduce operator->*
for Replicator
. Unlike operator>>
we want to expand all elements into the template argument list of a single call to F::operator()
.
1
2
3
4
5
6
7
8
9
10
11
12
template <auto... Elts>
struct Replicator {
template <typename F>
constexpr void operator>>(F fnc) const {
(fnc.template operator()<Elts>(), ...);
}
template <typename F>
constexpr decltype(auto) operator->*(F fnc) const {
return fnc.template operator()<Elts...>();
}
};
We can now write
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
void print_args(auto... args){
((std::cout << args << ' '), ...) << '\n';
}
template <typename T>
void print_t(T obj) {
[:expand(nonstatic_data_members_of(^^T)):]
->* [&]<auto... Members>{
print_args(obj.[:Members:]...);
};
}
struct Test {
int x;
char p;
};
int main() {
print_t(Test{42, 'y'});
// prints
// 42 y
}
Operator choice
Note that the choice of operator
->*
is mostly arbitrary. It just happens to be a rarely used operator that looks different enough to>>
to not confuse the two.You might as well use regular member function templates instead of user-defined operator templates to achieve the following syntax:
1 2 3 [:expand(some-range):].for_each([]<auto Elt>{ // ... });and respectively
1 2 3 [:expand(some-range):].into([]<auto... Elts>{ // ... });
Structured bindings
Unfortunately this suffers from the same problem as before - we are introducing another function scope.
To get around that, it’s possible to use a structured binding to introduce a pack of the elements within the current scope. For this P1061 Structured Bindings can introduce a Pack and P2686 constexpr structured bindings are essential.
Promoting ranges
The simplest way to make an arbitrary range decomposable is to promote
it to a constexpr C-style array. Unfortunately define_static_array
from P3491 gives us a constexpr span
, not the actual array. The underlying machinery is extremely simple though:
1
2
3
4
5
6
7
8
9
10
11
template <typename T, T... Vs>
constexpr inline T fixed_array[sizeof...(Vs)]{Vs...};
template <std::ranges::input_range R>
consteval std::meta::info promote(R&& iterable) {
std::vector args = {^^std::ranges::range_value_t<R>};
for (auto element : iterable) {
args.push_back(std::meta::reflect_value(element));
}
return substitute(^^fixed_array, args);
}
With promote
in place, we can now write the following code.
1
2
3
4
void foo(int x, char c) {
constexpr auto [...Param] = [:promote(parameters_of(^^foo)):];
bar([:Param:]...);
}
Promoting strings
In a lot of existing C++(20 and upwards) code you see the following pattern to accept string literals as constant template arguments.
1 2 3 4 5 6 7 8 9 10 11 template <std::size_t N> struct fixed_string { constexpr explicit(false) fixed_string(const char (&str)[N]) noexcept { std::ranges::copy(str, str+N, data); } char data[N]{}; }; template <fixed_string S> struct Test{};P2996’s
reflect_value
does not allow reflecting string literals directly (see P2996) and the waydefine_static_string
is currently specified, it does not help with this either since the generated character array has already decayed to a pointer at that point. Consider the following code:
1 2 3 4 5 6 7 8 9 10 using a = Test<"foo">; // ok // error: cannot reflect "foo" using b = [:substitute(^^Test, {reflect_value("foo")}):]; // error: cannot deduce N using c = Test<define_static_string("foo")>; // error: cannot deduce N using d = [:substitute(^^Test, {define_static_string("foo")}):];Unfortunately
define_static_array
cannot be used for this either, since the generated array is wrapped in a constexpr span for extraction.However, with
promote
this is rather easy to solve.
1 2 3 4 5 template <fixed_string S> struct Test{}; using e = Test<[:promote("foo"):]>; // ok using f = [:substitute(^^Test, {promote("foo")}):]; // ok
Implementing the tuple protocol
Another way to make something decomposable via structured bindings is to implement the tuple protocol. If you’ve been paying attention you might have noticed the similarity between promote
and expand
. If Replicator
were to implement the tuple protocol, expand
would be sufficient.
This is very easy to do:
1
2
3
4
5
6
7
8
9
10
11
12
13
template <std::size_t Idx, auto... Elts>
constexpr auto get(Replicator<Elts...> const&){
return Elts...[Idx];
}
template <auto... Elts>
struct std::tuple_size<Replicator<Elts...>>
: std::integral_constant<std::size_t, sizeof...(Elts)> {};
template <std::size_t Idx, auto... Elts>
struct std::tuple_element<Idx, Replicator<Elts...>> {
using type = decltype(Elts...[Idx]);
};
Now that Replicator
is decomposable, we can finally get rid of the lambda expression.
1
2
3
4
-[:expand(some_range):] >> []<auto... Elts>{
- // ...
-};
+constexpr auto [...Elts] = [:expand(some_range):];
Run on Compiler Explorer. At the time of writing constexpr
structured bindings (P2686) are not yet implemented in clang, which is why the example does not make use of it.
On a side note, this also means expand
is usable in expansion statements and can be used to replace define_static_array
.
1
2
3
4
5
6
7
-template for (constexpr auto Elt : define_static_array(some_range)) {
- // ...
-}
+template for (constexpr auto Elt : [:expand(some_range):]) {
+ // ...
+}
Sequences
While all of the aforementioned examples make use of reflection features, generating a pack of constants is not actually a new concept.
By far the most common range to expand into a constant template parameter pack is a sequence of integers. In fact, this is common enough for C++14 to have introduced std::integer_sequence
, std::index_sequence
and std::make_index_sequence
for this very purpose.
In a lot of code we see IILEs being used to retrieve the pack. The following pattern is rather popular:
1
2
3
[]<std::size_t... Idx>(std::index_sequence<Idx...>){
// ...
}(std::make_index_sequence<Count>());
Since we already have the ability to expand arbitrary ranges through the expand
helper, we can simply make use of C++20’s std::ranges::iota_view
to generate the sequence.
1
2
3
consteval std::meta::info sequence(unsigned maximum) {
return expand(std::ranges::iota_view{0U, maximum});
}
This now allows us to introduce an integer sequence as a pack as follows.
1
constexpr auto [...Idx] = [:sequence(Count):];
Decomposing
integer_sequence
Interestingly, reflection features are not actually needed for this. We could instead implement the tuple protocol for
std::integer_sequence
in exactly the same way we’ve already done forReplicator
.P1789 suggests exactly that, which if accepted would allow us to write the following code.
1 constexpr auto [...Idx] = std::make_index_sequence<Count>();