在之前的 C++26 反射文章中,我们接触到了一个新特性 template for。当时我们用它可以更直接地遍历结构体成员。不过,template for 究竟是什么?
从技术层面来说,它是一个与静态反射完全独立的提案,被称为扩展语句(Expansion Statement,P1306)。然而,这两项特性在设计上又相辅相成。本文就来聊聊扩展语句的基本原理,以及它如何解决 C++ 最古老的问题之一:遍历异构数据。
异构迭代
在标准 C++ 中,for 循环要求容器中的每个元素都具有完全相同的类型。
例如对于 std::vector<int>,下面这种写法可以正常工作:
for (int x : vec) {
// ...
}
但如果你面对的是一个 std::tuple<int, double, std::string> 呢?
auto my_tuple = std::make_tuple(42, 3.14, "Hello");
// 错误:'x' 的类型是什么?
// 在第 1 次循环中它是 int,第 2 次是 double,第 3 次是 string!
for (auto x : my_tuple) {
std::cout << x << '\n';
}
因为 C++ 是静态类型语言,传统的运行时 for 循环无法在每次迭代中改变变量 x 的类型。
在过去,解决这个问题通常需要编写递归模板函数,或者依赖 std::apply、Boost.Hana 这类工具。虽然可行,但为了一个看似简单的需求,往往要写出远超预期的模板代码。
template for
C++26 引入了扩展语句。
在 for 循环前加上 template 关键字,就可以告诉编译器在编译期展开代码。编译器会在编译期间把循环体展开为一连串独立的语句,运行时不复存在任何循环对象或循环计数器。
理解 template for 的关键在于:它并不是一个真正的循环,而是一系列孤立的代码块。因为每一次迭代都有自己独立的作用域 { },所以变量 x 在每次展开时都是重新声明的。这意味着 x 在第一个代码块中可以是 int,在第二个代码块中又可以轻松变成 double。
注:与模板实例化类似,扩展语句会根据展开元素的数量成比例地增加生成的代码体积。如果你遍历一个包含 100 个元素的 tuple,编译器将生成 100 份循环体的副本。这是循环展开的经典权衡:以二进制体积为代价,换取执行速度和极大的类型灵活性。
看下面这段代码:
#include <tuple>
#include <iostream>
void print_tuple()
{
auto tup = std::make_tuple(10, 3.14, "Hello");
template for (auto value : tup)
{
std::cout << value << '\n';
}
}
int main()
{
print_tuple();
return 0;
}
其汇编代码精简后如下:
"print_tuple()":
call "std::get<0ul, int, double, char const*>(...)"
call "std::get<1ul, int, double, char const*>(...)"
call "std::get<2ul, int, double, char const*>(...)"
call "std::ostream::operator<<(int)"
call "std::ostream::operator<<(double)"
call "std::operator<<(..., char const*)"
可以理解为编译器生成了类似下面的代码:
{
const auto& field = std::get<0>(tup); // int
std::cout << field << '\n';
}
{
const auto& field = std::get<1>(tup); // double
std::cout << field << '\n';
}
{
const auto& field = std::get<2>(tup); // std::string
std::cout << field << '\n';
}
支持类型
看完 tuple 的遍历后,你可能会好奇:template for 都支持哪些类型?根据标准文档,它主要支持三类数据。
可解构类型
只要能在某个类型上使用结构化绑定,比如:
auto [a, b] = obj;
就能在其上使用 template for。这包括 std::tuple、std::pair 以及标准结构体。
struct Size {
int width;
short height;
};
consteval long sum_size(Size size)
{
long result = 0;
template for (auto value : size) {
result += value;
}
return result;
}
static_assert(sum_size({100, 20}) == 120);
初始化列表
也可以把多个表达式包在花括号 {} 中,直接遍历这个表达式列表。这在编写参数包时非常有用,可以避免使用复杂的折叠表达式。
#include <iostream>
#include <string_view>
template <typename... Ts>
void print_all(const Ts&... values)
{
template for (const auto& value : {values...}) {
std::cout << value << '\n';
}
}
int main()
{
print_all(7, 2.5, std::string_view{"ready"});
}
虽然看起来像 std::initializer_list,但内存中实际上不会创建任何数组。花括号只是告诉编译器逐个展开这些表达式。
静态反射
template for 还能与 <meta> 头文件很好地结合,遍历任何值的范围,只要该范围是一个常量表达式。
在之前的反射文章中,我们遍历过结构体成员。但由于 std::meta::nonstatic_data_members_of 这类函数返回的是 std::vector,它会在堆上分配内存,因此直接将它们传给 template for 会违反 C++ 的内存规则。
为此,反射提案提供了 std::meta::define_static_array() 工具。它能把 vector 冻结为一个安全的编译期 span,以便安全地进行扩展遍历:
struct Point {
int x;
int y;
};
void print_offsets()
{
constexpr auto members = std::meta::define_static_array(
std::meta::nonstatic_data_members_of(^^Point)
);
template for (constexpr auto mem : members) {
std::cout << "Offset: "
<< std::meta::offset_of(mem).bytes
<< '\n';
}
}
如果你希望系统性地理解 template for 这类现代 C++ 特性背后的模板机制,可以看看这门 template for 课程,它从基础语法讲到 C++20 Concepts、变长模板与完美转发,对现代 C++ 范式覆盖得比较完整。
一些细节
前面例子中可能存在一个微妙差异:有时写的是 template for (auto x : ...),有时又是 template for (constexpr auto x : ...)。
- 使用
auto:当提取的是运行时数据时,例如从 std::tuple 或结构体中取元素。循环本身在编译期展开,但变量保存的是运行时数据。
- 使用
constexpr auto:当遍历的是编译期数据时,例如反射描述符 std::meta::info。
如果计划拼接该变量,例如 [: mem :],或者把它传递给类型萃取,那么该变量必须为 constexpr。
控制流
普通 for 循环中可以使用 continue 和 break,template for 也不例外。
#include <iostream>
int main()
{
template for (auto number : {1, 2, 3, 4, 5}) {
if (number % 2 == 0) {
continue;
}
std::cout << number << '\n';
if (number == 3) {
break;
}
}
}
看到这段代码,第一反应可能是:它们会阻止编译器实例化后续语句吗?
其实不会。它们的作用和运行时一样,控制的是运行时执行流程,并不会阻止编译器实例化后续语句。
执行到 2 时,continue 跳过当前这份语句的剩余部分,接着执行下一份;执行到 3 时,break 结束整个扩展语句。因此,4 和 5 对应的语句虽然已经被实例化,但这次运行不会执行到它们。
结语
扩展语句让 C++ 能以接近普通范围 for 的写法处理 tuple、参数包和结构体成员等异构数据。与静态反射结合后,我们还可以针对不同成员分别生成代码,而不用先写一套递归模板。
使用时记住一点就够了:循环体在编译期分别实例化,循环体中的操作仍然可以在运行时执行。它让异构迭代更容易表达,同时也要留意元素数量增加带来的编译开销和代码体积。