C++26 Expansion Tricks

发布于 2025 年 3 月 21 日,更新于 2025 年 3 月 21 日

作者:Che,阅读时长约 12 分钟

P1306 允许我们在编译时重复执行一个语句,针对一个范围内的_每个_元素。但如果我们希望将这些元素作为一个参数包,而不引入新的函数作用域,该怎么办呢?

在这篇博文中,我们将探讨 expand 辅助函数、展开语句,以及如何通过结构化绑定使任意范围可分解,从而减少对 IILE(立即调用 Lambda 表达式)的需求。

元素级展开

expand 模式

P2996 中引入的反射特性本身就足以迭代编译时范围。该提案引入了一个辅助函数 expand,用于此目的。以下是一个略微修改的版本:

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);
}

```
  
---|---  
`

这允许我们编写以下代码。请注意,`Member` 必须是一个常量,才能在拼接中使用。

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
}

---|---
`

在 Compiler Explorer 上运行

提前返回

这还不够接近循环的语义。 continue 可以表示为 return;,但我们还不能表示 break 或返回值。

首先,让我们介绍一种在任何时候停止迭代的方法。 &&|| 的短路特性非常有用,我们只需要让 Lambda 返回一个布尔值,以指示我们是否应该继续迭代。

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>() && ...);
  }
};

```
  
---|---  
`

为了重用之前的例子,我们现在可以让它在满足某些任意条件时立即停止。在下面的例子中,我们会在遇到名为 `x` 的成员后立即停止 - 因此,`Test` 的第二个成员将不会被打印。

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
}

---|---
`

在 Compiler Explorer 上运行

返回值

返回值有点困难。为此,让我们稍微回顾一下 - 我们停止迭代不是通过返回一个布尔值来表达我们继续迭代的意图,而是只要 Lambda 的第一次评估返回除 void 之外的任何内容,就停止迭代。

因此,让我们首先定义一个元函数,以检索类型包中的第一个非 void 类型。

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;

```
  
---|---  
`

有了这个实用程序,我们现在可以知道 `fnc` 的 `operator()` 的任何特化是否返回了除 `void` 之外的其他内容。不幸的是,这一点很重要,因为 `void` 不是常规类型。

让我们首先做没有 `F::operator()` 特化返回值的情况。这也意味着不会发生提前返回 - 我们可以安全地折叠 `,` 。

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 {
   // ...
  }
}

---|---
`

返回值有点复杂。

我们已经知道 F::operator() 特化迟早会返回除 void 之外的其他内容,因此我们可以准备一个这种类型的对象以供以后返回。为了避免对返回类型有默认构造性的要求,可以将返回对象包装在一个联合体中。但是,请注意,这将意味着返回类型必须是可复制构造的。

这个问题也可以解决,但这里的主要目的是看看需要多少代码才能_大致_模拟展开语句。

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();
    }
  }
}

```
  
---|---  
`

为了继续使用 `&&` 的短路特性,必须引入另一个辅助函数 `invoke`。 如果请求的 `F::operator()` 特化返回 `void`,则 `invoke` 应返回 `true`。 否则,它必须返回 `false` 以停止迭代,并最终从返回值复制构造 `ret.obj`。

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;
  }
}

---|---
`

最后,我们可以编写以下代码。

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
}

```
  
---|---  
`

[在 Compiler Explorer 上运行](https://pydong.org/posts/ExpansionTricks/</https:/godbolt.org/z/v4TjxaqbW>)

但是,请注意,必须使用 `if constexpr` 语句来保护提前返回。

### 展开语句

不幸的是,使用 `expand` 意味着必须使用 Lambda 表达式,因此引入了新的函数作用域。 虽然这通常不会有什么问题,但例如,它可能会导致函数参数的反射问题([P3096](https://pydong.org/posts/ExpansionTricks/</https:/wg21.link/P3096>)),因为它们只能在其对应的函数体中进行拼接。

[P1306](https://pydong.org/posts/ExpansionTricks/</https:/wg21.link/P1306>) `template for` 展开语句允许我们避免额外的函数作用域。

1 2 3 4 5 6 7

| ```
-[:expand(some_range):] >> []<auto Elt>{
-  // ...
-};
+template for (constexpr auto Elt : define_static_array(some_range)) {
+  // ...
+}

---|---
`

需要来自 P3491define_static_array,因为我们还没有非瞬态的 constexpr 分配。 这很不幸,但没办法。

令人惊讶的是,展开语句还支持 breakcontinue 和提前返回。

将范围转换为参数包

expand 模式

因此,我们已经确定展开语句非常有用。 但是,如果我们需要将元素作为参数包,该怎么办呢? 我们可能想在折叠表达式中使用这些元素,或者将它们展开到参数列表中。

为此,让我们为 Replicator 引入 operator->*。 与 operator>> 不同,我们希望将所有元素展开到对 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...>();
  }
};

```
  
---|---  
`

我们现在可以写

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
}

---|---
`

在 Compiler Explorer 上运行

运算符选择 请注意,运算符 ->* 的选择在很大程度上是任意的。 它恰好是一个很少使用的运算符,它看起来与 >> 足够不同,不会混淆两者。 您也可以使用常规成员函数模板而不是用户定义的运算符模板来实现以下语法:

1 2 3

| ```
[:expand(some-range):].for_each([]<auto Elt>{
  // ...
});

---|---
` 和分别

1 2 3

| ```
[:expand(some-range):].into([]<auto... Elts>{
  // ...
});

---|---
`

结构化绑定

不幸的是,这与之前存在同样的问题 - 我们引入了另一个函数作用域。

为了解决这个问题,可以使用结构化绑定在当前作用域内引入元素的参数包。 为此,P1061 Structured Bindings can introduce a PackP2686 constexpr structured bindings 至关重要。

提升范围

使任意范围可分解的最简单方法是将其 promote 为 constexpr C 风格的数组。 不幸的是,来自 P3491define_static_array 为我们提供了一个 constexpr span,而不是实际的数组。 但是,底层机制非常简单:

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);
}

```
  
---|---  
`

有了 `promote`,我们现在可以编写以下代码。

1 2 3 4

| ```
void foo(int x, char c) {
  constexpr auto [...Param] = [:promote(parameters_of(^^foo)):];
  bar([:Param:]...);
}

---|---
`

提升字符串 在很多现有的 C++(20 及以上)代码中,您会看到以下模式来接受字符串文字作为常量模板参数。

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 的reflect_value不允许直接反射字符串文字(参见 [P2996](https://pydong.org/posts/ExpansionTricks/</https:/www.open-std.org/jtc1/sc22/wg21/docs/papers/2025/p2996r9.html#pnum_529>)),并且define_static_string` 的当前指定方式也无济于事,因为生成的字符数组此时已经衰减为指针。 考虑以下代码:

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")}):]; 

---|---
[在 Compiler Explorer 上运行](https://pydong.org/posts/ExpansionTricks/</https:/godbolt.org/z/PvPsMMjqz>) 不幸的是,define_static_array也不能用于此,因为生成的数组被包装在 constexpr span 中以进行提取。 但是,使用promote`,这很容易解决。

1 2 3 4 5

| ```
template <fixed_string S>
struct Test{};
using e = Test<[:promote("foo"):]>; // ok
using f = [:substitute(^^Test, {promote("foo")}):]; // ok

---|---
` 在 Compiler Explorer 上运行

实现元组协议

使某些东西可以通过结构化绑定进行分解的另一种方法是实现元组协议。 如果您一直关注,您可能已经注意到 promoteexpand 之间的相似之处。 如果 Replicator 要实现元组协议,则 expand 就足够了。

这很容易做到:

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]);
};

```
  
---|---  
`

现在 `Replicator` 是可分解的,我们终于可以摆脱 Lambda 表达式了。

1 2 3 4

| ```
-[:expand(some_range):] >> []<auto... Elts>{
-  // ...
-};
+constexpr auto [...Elts] = [:expand(some_range):];

---|---
`

在 Compiler Explorer 上运行。 在撰写本文时,constexpr 结构化绑定(P2686)尚未在 clang 中实现,这就是为什么该示例没有使用它的原因。

顺便说一句,这也意味着 expand 可在展开语句中使用,并可用于替换 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):]) {
+  // ...
+}

```
  
---|---  
`

## 序列

虽然所有上述示例都使用了反射特性,但生成常量参数包实际上并不是一个新概念。

到目前为止,最常展开到常量模板参数包的范围是整数序列。 实际上,这在 C++14 中已经很常见,为此引入了 `std::integer_sequence`、`std::index_sequence` 和 `std::make_index_sequence`。

在很多代码中,我们看到使用 IILE 来检索参数包。 以下模式非常流行:

1 2 3

| ```
[]<std::size_t... Idx>(std::index_sequence<Idx...>){
  // ...
}(std::make_index_sequence<Count>());

---|---
`

由于我们已经能够通过 expand 辅助函数展开任意范围,因此我们可以简单地利用 C++20 的 std::ranges::iota_view 来生成序列。

1
2
3

```
| ```
consteval std::meta::info sequence(unsigned maximum) {
  return expand(std::ranges::iota_view{0U, maximum});
}

```
  
---|---  
`

现在,我们可以按如下方式引入整数序列作为参数包。

1

| ```
constexpr auto [...Idx] = [:sequence(Count):];

---|---
`

分解 integer_sequence 有趣的是,实际上不需要反射特性。 我们可以改为为 std::integer_sequence 实现元组协议,就像我们已经为 Replicator 所做的那样。 P1789 正是提出了这一点,如果被接受,我们将能够编写以下代码。

1

| ```
constexpr auto [...Idx] = std::make_index_sequence<Count>();

---|---
` 在 Compiler Explorer 上运行

C++

C++ C++26 reflection tricks TMP metaprogramming

本文作者根据 CC BY 4.0 许可协议发布。