C++ Template Metaprogramming|模板元编程
C++ Template Metaprogramming|模板元编程
C++17,C++20,C++23,SFINAE,concepts,constexpr。
模板元编程盖了两件不太一样的事。一件是在类型层面做判断:用类模板的特化和实参推导挑出该走的那条路,靠替换失败把不合适的候选剔掉。另一件是在值层面做计算,用常量表达式把结果在编译期算出来。前者回答的是“这个类型合不合法、该选哪个重载”,后者回答的是“这个数在编译期是多少”。
三个标准对这两件事的改动大致在一条线上:让编译器少实例化、把约束从替换失败挪到签名上、把更多东西放进常量求值。下面按标准分开整理常用的那几件工具,最后算一下这些写法换来的编译期开销。文中程序的验证环境是 Wandbox[1] ,编译器用了 gcc-head(GCC 17.0.0 开发版)和 gcc-13.2.0 两份,标准写在每个大节的节首。正文里贴的报错和输出都是从这些环境上取的原文。
C++17
本节代码:Wandbox 在线编译,-std=gnu++17。
参数包与折叠表达式
用参数包做累加之前,先看包本身。template <typename... Ts> 里的 Ts... 是一个类型包,函数签名里的 Ts... values 是跟着它的函数参数包,sizeof...(Ts) 给出包里元素的个数。包不能像数组那样按下标取,也不能直接参与运算,要用它就得先展开成若干个独立的东西。
C++17 之前展开一个包只有递归这一条通用路径:把包拆成“第一个”和“剩下的”,一直拆到空包,再给空包写一个终止条件。类模板版本长这样:
// C++11:每种后缀都是一个类模板特化
template <typename... Ts> struct Sum;
template <> struct Sum<> { static constexpr int value = 0; };
template <typename T, typename... Rest>
struct Sum<T, Rest...> { static constexpr int value = T::value + Sum<Rest...>::value; };
空包那一条不能省。换成函数模板写还会多一层麻烦:totalSize() 和 totalSize(T, Rest...) 两个重载在调用时可能同时匹配,编译器直接报 ambiguous,得把“只有一个参数”和“两个及以上”拆成两个模板才不冲突。
C++17 的折叠表达式把展开这一步收进语法,运算符写在包上,编译器替你把包拆开[2] 。一共四种写法:
(包 op ...) // 一元右折叠
(... op 包) // 一元左折叠
(初值 op ... op 包) // 二元左折叠
(包 op ... op 初值) // 二元右折叠
先用 sizeof 试一个不需要任何辅助类型定义的版本:
template <typename... Ts>
constexpr int totalSize() { return (0 + ... + sizeof(Ts)); }
(0 + ... + sizeof(Ts)) 是二元左折叠,totalSize<int, char, double>() 展开成 ((0 + sizeof(int)) + sizeof(char)) + sizeof(double)。初值写到右边就是另一种结合顺序:(sizeof(Ts) + ... + 0) 展开成 sizeof(int) + (sizeof(char) + (sizeof(double) + 0))。加法满足结合律,两个结果一样;换成减法才能看出差别,这也是左右折叠最容易记混的地方:
template <typename... Args>
constexpr int rightMinus(Args... args) { return (args - ...); } // 1 - (2 - 3) = 2
template <typename... Args>
constexpr int leftMinus(Args... args) { return (... - args); } // (1 - 2) - 3 = -4
一元折叠不带初值,包里至少得有一个元素。空包时的规则按运算符分档:只有 &&、|| 和逗号有定义的值——&& 给 true,|| 给 false,逗号给 void(),其余运算符在空包上是 ill-formed[3] 。二元折叠有初值兜底,空包时结果就是初值本身,所以累加、累乘这类操作都用二元形式。拿 + 试一下,报错很直接:
error: fold of empty expansion over operator+
除了求和,折叠表达式最常见的用法是“对每个参数做同一件事”:
// 把所有参数一次打印出来;初值是 std::cout,空包就只打印一个换行
template <typename... Args>
void printAll(Args&&... args) { (std::cout << ... << args) << '\n'; }
// 对每个参数做一件事;逗号折叠在空包上是 void(),不用另写空包分支
template <typename... Args>
void touchAll(Args&&... args) { ((void)args, ...); }
判断“全部满足”和“任意满足”也用一元折叠:
template <typename... Ts> constexpr bool all_integral = (... && std::is_integral_v<Ts>);
template <typename... Ts> constexpr bool any_integral = (... || std::is_integral_v<Ts>);
两个地方容易写错。一是括号:折叠表达式必须整体括起来,不写括号的话编译器根本不把它当折叠表达式,报的是“参数包没被展开”:
error: parameter packs not expanded with '...':
3 | constexpr std::size_t total() { return sizeof(Ts) + ...; }
二是折叠表达式的操作数里必须出现包。想写“全部为真”却漏了包,报错指向的是“没有未展开的参数包”:
error: operand of fold expression has no unexpanded parameter packs
2 | constexpr bool b = (... && std::is_integral_v<int>);
省下来的是实例化次数,不是求值链长度。\(n\) 个类型的递归版本,Sum<T1, ..., Tn> 一路要求到 Sum<>,一共 \(n + 1\) 个类模板特化,每个特化都是一个独立的类型,符号表和调试信息里各占一份;折叠版本只有一次实例化。把一个类型换成没有 ::value 的,编译器会把这条链一层层列出来:
error: 'value' is not a member of 'NotAConstant'
In instantiation of 'constexpr const int Sum<NotAConstant>::value':
recursively required from 'constexpr const int Sum<std::integral_constant<int, 2>, NotAConstant>::value'
required from 'constexpr const int Sum<std::integral_constant<int, 1>, std::integral_constant<int, 2>, NotAConstant>::value'
实例化短了,常量求值的依赖深度没变:右折叠的表达式树还是一棵往一边倒的链,深度 \(n\),和递归版本一样。要更短的依赖链得换成平衡的写法,那是求值顺序的事。
if constexpr 与被丢弃的分支
普通 if 的两个分支都要实例化,所以模板里不能靠 if 做类型分派。下面这个函数想按指针和非指针走不同路径,用运行期的 if 写出来,T 取 std::string 时另一个分支照样要实例化:
template <typename T>
auto getValue(const T& holder) {
if (std::is_pointer_v<T>) { return *holder; }
else { return holder; }
}
*holder 对 const std::string& 不成立,报错落在函数体里,而不是落在调用点上:
In instantiation of 'auto getValue(const T&) [with T = std::basic_string<char>]':
error: no match for 'operator*' (operand type is 'const std::string')
C++17 的 if constexpr 把条件本身当作编译期判断:在模板实体里,条件在实例化后不依赖模板参数时,被丢弃的那个分支不会被实例化[4] 。写成 if constexpr 之后,T 是 std::string 时 *holder 那一行根本不存在:
template <typename T>
auto getValue(const T& holder) {
if constexpr (std::is_pointer_v<T>) {
return *holder; // 非指针类型不会实例化这一行
} else {
return holder;
}
}
这里有个容易踩的边界:被丢弃的分支不实例化,不等于不检查。在非模板函数里,两个分支都会照常做语义检查,if constexpr 换不来条件编译[5] 。下面这段在 if constexpr (false) 里写的错误照样编译不过:
void f() {
if constexpr (false) {
int i = 0;
int* p = i; // error: invalid conversion from 'int' to 'int*'
}
}
void_t 与检测惯用法
先补一句它依赖的机制。SFINAE 指的是把模板实参代进模板时,如果某个类型或者表达式不合法,这条候选会被安静地移出重载集合,而不是直接报错;检测一个类型有没有某个成员,靠的就是这个行为。C++17 之前没有现成的工具,常用的写法是自己定义 void_t 再用它做偏特化。C++17 把它收进标准库(N3911),检测惯用法就有了统一的写法:
template <typename T, typename = void>
struct has_push_back : std::false_type {};
template <typename T>
struct has_push_back<T, std::void_t<decltype(std::declval<T&>().push_back(1))>> : std::true_type {};
偏特化能不能匹配上,取决于 decltype 里那个表达式的替换有没有失败。这套做法依赖 CWG 1558 这条修正:在那之前,别名模板里没被用到的参数不保证参与替换,可能被直接忽略,所以早期编译器上得先把参数包过一遍 make_void 之类的结构才能用[6] 。std::is_detected 这类现成的检测工具只进了 Library Fundamentals TS,标准库里一直没有,所以这段模板还是要自己写。
同一条“要不要继续算下去”的分界也出现在 std::conjunction 和 std::disjunction 上。它们短路的是实例化:只要前面已经能定下结果,后面的 Bj::value 就不会被实例化[7] 。手写 && 没有这个性质:
struct NoSize {};
template <typename T>
struct HasSize : std::bool_constant<(sizeof(typename T::size_type) > 0)> {};
static_assert(!std::conjunction_v<std::false_type, HasSize<NoSize>>); // 通过,不实例化 HasSize<NoSize>
static_assert(!(std::false_type::value && HasSize<NoSize>::value)); // error: no type named 'size_type'
后一行的 && 会老老实实把右操作数实例化出来,哪怕左边的值已经是 false。参数包展开成 (... && Bs::value) 是同样的道理,包里每个 B 都会被实例化。
auto 非类型参数与类模板实参推导
template <auto N> 让非类型参数的类型从实参推出来,不用再给每种类型写一遍模板[8] :
template <auto N> struct Constant { static constexpr auto value = N; };
static_assert(Constant<3>::value == 3);
static_assert(Constant<'x'>::value == 'x');
类模板实参推导(CTAD)解决的是另一半繁琐。有了它,std::array 的元素类型和长度能从初始化列表里推出来[9] :
std::array a{1, 2, 3};
static_assert(std::is_same_v<decltype(a), std::array<int, 3>>);
C++20
本节代码:Wandbox 在线编译,-std=gnu++2a。
concepts 把约束写进签名
SFINAE 的问题是约束没地方放。要限制一个函数只接受有 size() 的类型,只能把条件塞进返回类型或者一个带默认值的模板参数里,失败时读者看到的是“没有匹配的函数”,而不是“你缺了 size()”。concepts 把这个条件变成可以命名的实体,直接写在签名上[10] :
template <typename T>
concept Sized = requires(const T& value) {
{ value.size() } -> std::convertible_to<std::size_t>;
};
template <Sized T>
std::size_t count(const T& value) { return value.size(); }
原子约束的检查和 SFINAE 一样是按替换来做的:把实参代进表达式,替换出非法类型或表达式时这条约束判定为不满足,不是硬错误[11] 。差别在报告方式上,传给 count 一个没有 size() 的类型,错误里会写清楚是哪个 concept 没过:
error: no matching function for call to 'count(Widget)'
• candidate 1: 'template<class T> requires Sized<T> std::size_t count(const T&)'
• constraints not satisfied
• required for the satisfaction of 'Sized<T>' [with T = Widget]
in requirements with 'const T& value' [with T = Widget]
约束还可以互相包含。用 && 写出来的合取会被拆成原子约束,重载选择时更窄的那条优先,不需要再靠返回类型上的 enable_if 排序。带约束的 auto 参数写法(P1141R2)只是把 template <Sized T> 缩成了 void count(const Sized auto&),约束本身没有变化。约束是在候选筛选中检查的,通过之后函数体该实例化还是实例化,concepts 省下的是错误信息的可读性,不是实例化次数。
类类型非类型模板参数
C++20 之前非类型模板参数只能取整数、枚举、指针、成员指针、左值引用和 nullptr_t 这几类。P0732R2 放开了类类型,条件是这个类必须是 structural type:所有基类和非静态数据成员都是 public 且非 mutable,它们的类型本身也必须是 structural 的(或者其数组),枚举、指针、左值引用、nullptr_t 和 C++20 起的浮点类型都算[12] 。浮点参数补进来的时候顺带改了等价的判据,标准里说的是值 identical,而不是走 == 比较[13] 。
这套规则让字符串字面量能当模板实参。写一个只存字符、成员全 public 的定长字符串:
template <std::size_t N>
struct FixedString {
char data[N] = {};
constexpr FixedString(const char (&text)[N]) noexcept {
for (std::size_t i = 0; i < N; ++i) {
data[i] = text[i];
}
}
constexpr std::string_view view() const noexcept { return {data, N - 1}; }
};
template <FixedString Name, typename T>
struct Field {
static constexpr std::string_view key = Name.view();
T value{};
};
template <FixedString Name> 里的 FixedString 是个“类类型占位符”,N 从实参推出来[14] ,所以 Field<"qty", double> 这种写法直接可用。Field::key 拿到的 string_view 指向的是一个模板参数对象:标准规定类类型的非类型模板参数在程序中是一个 const T 的静态存储期对象,两个不相等的模板实参对应两个不同的对象[15] 。视图指向它不会悬空,这一点是这类写法能成立的前提。
consteval 与 is_constant_evaluated 的边界
consteval 声明的是立即函数:调用它必须出现在常量求值里,否则编译不过[16] 。std::is_constant_evaluated() 是另一种用法,同一个函数体里按当前是不是常量求值走两条路[17] ,比如编译期用循环、运行期调用 std::pow:
constexpr double power(double base, int exp) {
if (std::is_constant_evaluated()) {
double result = 1.0;
for (int i = 0; i < exp; ++i) {
result *= base;
}
return result;
}
return std::pow(base, exp);
}
坑在 if constexpr 里。if constexpr 的条件本身就是一个 manifestly constant-evaluated 语境,std::is_constant_evaluated() 放在这个位置会永远返回 true[18] :
constexpr int branch() {
if constexpr (std::is_constant_evaluated()) { return 1; }
else { return 2; }
}
GCC 对这段给了一条警告,而运行期调用 branch() 也真的返回 1:
warning: 'std::is_constant_evaluated' always evaluates to true in 'if constexpr' [-Wtautological-compare]
runtime=8.000000 branch=1
所以 if constexpr (std::is_constant_evaluated()) 不能拿来分派,得写成运行期的 if。C++23 给的是语法层面的答案。
C++23
本节代码:Wandbox 在线编译,-std=gnu++2b。
if consteval
if consteval 不带条件,条件的位置换成关键字,两个分支都要写成复合语句[19] 。它的语义是“当前处于 manifestly constant-evaluated 语境”,而且正向分支本身构成一个 immediate function context,里面可以直接调 consteval 函数:
consteval int squareAtCompileTime(int n) { return n * n; }
constexpr int square(int n) {
if consteval {
return squareAtCompileTime(n);
} else {
return n * n;
}
}
同样的事情用 is_constant_evaluated 写就不成立。下面这份换到 -std=gnu++2a(同一站点)就过不了,因为那个分支不是 immediate function context,里面的 n 也不是常量:
constexpr int square(int n) {
if (std::is_constant_evaluated()) {
return squareAtCompileTime(n); // error: call to consteval function is not a constant expression
} else {
return n * n;
}
}
大括号是语法要求的一部分,不能省:if consteval 后面必须是一个复合语句,写成 if consteval s0; else s1; 会把 else 挂到外层 if 上去,直接被判成编译错误[20] 。取反的形式写作 if !consteval(也可以写 if not consteval),等价于把两个分支调换。
显式对象参数
类模板继承里那个老问题,是基类看不到派生类型。CRTP 的基类成员函数里 *this 是基类类型,想调派生类的方法得 static_cast<Derived*>(this),而且基类构造函数执行的时候派生部分还没构造完。C++23 的显式对象参数(deducing this)把这个参数写成普通参数,类型和值类别都推出来[21] :
struct Widget {
std::string name;
template <typename Self>
auto&& getName(this Self&& self) noexcept {
return std::forward<Self>(self).name;
}
};
Self 会按对象的值类别推成 Widget&、const Widget& 或 Widget,所以 const 和非 const 两个重载可以用一份模板:w.getName() 返回 std::string&,cw.getName() 返回 const std::string&。CRTP 那份也可以直接写成在基类里调派生类的方法:describe(this const Self& self) 里的 Self 就是派生类型,不需要转换。lambda 也能这么写,递归不用再借助 std::function 或者 Y 组合子:
auto fib = [](this auto&& self, int n) -> int {
return n < 2 ? n : self(n - 1) + self(n - 2);
};
static_assert(fib(10) == 55);
支持得比较晚。同一站点的 gcc-13.2.0 加 -std=gnu++2b 编这段是错的:
error: expected identifier before 'this'
auto&& getName(this Self&& self) noexcept {
按编译器支持表,GCC 要到 14、Clang 要到 18 才有这个特性[22] 。
static operator() 与放宽的 constexpr
无状态的函数对象和 lambda 本来也要有个对象才能调,C++23 允许把 operator() 声明成 static[23] ,operator[] 同理[24] :
struct Less {
static constexpr bool operator()(int a, int b) noexcept { return a < b; }
};
constexpr auto negate = [](int x) static { return -x; };
static_assert(Less{}(1, 2));
static_assert(negate(3) == -3);
捕获为空的 lambda 不会自动变成 static,static 得自己写;它也不能和捕获、mutable、显式对象参数同时出现[25] 。加上之后调用不再需要隐式对象参数,转函数指针的时候拿到的是这个静态函数本身的地址。换到 -std=gnu++2a(同一站点),GCC 只是给警告,说 static 只对 C++23 有效。
同一批改动里还有几条是把 constexpr 的门槛往下调。P2647R1 允许 constexpr 函数里有 static constexpr 局部变量[26] ,P2242R3 允许标签和 goto 出现在 constexpr 函数里[27] :
constexpr int lookup(int index) {
static constexpr int table[] = {10, 20, 30};
return table[index];
}
constexpr int absViaGoto(int n) {
if (n < 0) goto negative;
return n;
negative:
return -n;
}
同样两段换到同一站点的 -std=gnu++2a,都过不了:
error: 'table' defined 'static' in 'constexpr' function only available with '-std=c++23'
error: 'goto' in 'constexpr' function only available with '-std=c++23'
允许声明不等于允许常量求值。goto 写进 constexpr 函数之后,运行期调用没问题,但常量求值走到 goto 那一步还是不成立,C++23 下 static_assert(absViaGoto(-5) == 5) 报的是:
error: 'goto' is not a constant expression
P2448R2 删掉的是另一类规则:以前一个 constexpr 函数模板如果没有任何特化能常量求值,程序是 ill-formed,不需要诊断[28] 。这条本来就是 no diagnostic required,GCC 在两个标准下都不报,实际影响的是“随手写上 constexpr”这件事的代价。P2280R4 顺手放宽了常量表达式里使用未知指针和引用的一部分限制[29] 。
实例化次数与编译期资源
前面几节反复出现的“少实例化”能算成一个数。\(n\) 个类型的递归求和,需要的特化是每个后缀一个,加上空包那个:
而两者的常量求值依赖深度一样,右折叠的表达式树深度就是 \(n\)。这两个量要分开看:实例化次数决定编译器要记多少份特化、生成多少个符号,依赖深度决定常量求值的串行链有多长。
编译器对这些量都有硬上限,标准只保证一个下限:模板实例化递归至少支持 \(1024\) 层,递归的 constexpr 调用至少支持 \(512\) 层[30] 。GCC 自己的默认值是 -ftemplate-depth=900,另外还有几个相关的开关:-fconstexpr-ops-limit 默认 \(33554432\),单次常量求值的操作数超过它就不再继续;-fconstexpr-cache-depth 默认 \(8\),控制递归求值的缓存深度,调大能省重复计算但吃内存;-ftemplate-backtrace-limit 默认 \(10\),限制一次错误里列多少条实例化 note[31] 。
这些限制不会出现在运行期,代价都记在编译时间和编译内存上。一个类模板特化就是一个类型,每个都要单独记在符号表里,调试信息里也跟着涨。concepts 不改变这个账:约束是在选候选的时候查的,选中之后函数体照常实例化,它换来的是错误信息的可读性——报错指出哪个 concept 没过,而不是把一整条替换链倒出来。
适用场景
值得写模板的场合有一个共同点:结果本身必须是类型,或者必须参与重载选择和内存布局。类型擦除的包装、把一组异构操作拼成一次调用、按协议生成不同的静态分发路径,都属于这一类;这些地方不用模板就得付出运行期的分支或者间接调用。
反过来,结果是值的时候用 constexpr 函数就够了,别绕成偏特化和递归。只有两三个类型的分派,直接写重载比 if constexpr 加 trait 更好读。编译期字符串做键这种写法,只有在键确实要参与类型身份、或者本来就在热路径上频繁比较的时候才划算,否则编译时间的开销换不回什么。
规模上还有一条边界:模板元编程的代价随实例化数量增长,和运行期的 \(O(n)\) 不是一个量纲。递归特化写起来直观,但每一层都占一次实例化深度,GCC 默认的 -ftemplate-depth 是 \(900\),瓶颈会先出现在这里;同样的计算换成折叠表达式或者 constexpr 循环,实例化深度就不再随 \(n\) 涨。工具链的支持也要算进去,这一节里 deducing this 这类特性在常用发行版编译器上还不齐,跨版本分发的代码要用就得先确认编译器的版本。
完整实现
完整的编译期字段名访问实现(编译环境:Wandbox 在线编译,-std=gnu++2a):
点击查看代码
#include <cstddef>
#include <cstdio>
#include <string_view>
#include <tuple>
// 只存字符、成员全 public 的定长字符串,可以作为类类型的非类型模板参数
template <std::size_t N>
struct FixedString {
char data[N] = {};
constexpr FixedString(const char (&text)[N]) noexcept {
for (std::size_t i = 0; i < N; ++i) {
data[i] = text[i];
}
}
constexpr std::string_view view() const noexcept { return {data, N - 1}; }
};
// 名字放进类型里,值放在对象里
template <FixedString Name, typename T>
struct Field {
static constexpr std::string_view key = Name.view();
T value{};
};
// 字段名不是运行时参数,编译期就换成下标
template <typename... Fields>
class Record {
static_assert(sizeof...(Fields) > 0);
public:
template <FixedString Name>
constexpr auto& get() noexcept {
constexpr std::size_t index = indexOf<Name>();
static_assert(index < sizeof...(Fields), "field not found");
return std::get<index>(fields_);
}
template <FixedString Name>
constexpr const auto& get() const noexcept {
constexpr std::size_t index = indexOf<Name>();
static_assert(index < sizeof...(Fields), "field not found");
return std::get<index>(fields_);
}
private:
template <FixedString Name>
static consteval std::size_t indexOf() {
constexpr bool hit[] = { (Fields::key == Name.view())... };
for (std::size_t i = 0; i < sizeof...(Fields); ++i) {
if (hit[i]) {
return i;
}
}
return sizeof...(Fields);
}
std::tuple<Fields...> fields_{};
};
using Quote = Record<Field<"symbol", std::string_view>,
Field<"qty", double>,
Field<"price", double>>;
int main() {
Quote quote;
quote.get<"symbol">().value = "600519";
quote.get<"qty">().value = 300.0;
quote.get<"price">().value = 1700.0;
const Quote& view = quote;
if (view.get<"symbol">().value != "600519") return 1;
if (view.get<"qty">().value != 300.0) return 2;
std::printf("qty=%g\n", view.get<"qty">().value);
return 0;
}
https://en.cppreference.com/w/cpp/language/template_parameters ↩︎
https://en.cppreference.com/w/cpp/language/template_parameters ↩︎
https://en.cppreference.com/w/cpp/language/template_parameters ↩︎
https://en.cppreference.com/w/cpp/types/is_constant_evaluated ↩︎
https://gcc.gnu.org/onlinedocs/gcc/C_002b_002b-Dialect-Options.html ↩︎
https://gcc.gnu.org/onlinedocs/gcc/C_002b_002b-Dialect-Options.html ↩︎

浙公网安备 33010602011771号