C++模板元编程-Cpp20版
C++20 模板元编程精讲(现代元编程)
面向读者:已经了解 C++98 模板元编程的基础(模板、特化、递归实例化),或至少会写泛型代码。本笔记讲解 C++20 时代"写模板元编程"的现代方式。
核心观念升级:C++20 让"编译期编程"从"凑模板特化的奇技淫巧"变成"写普通代码,只是运行在编译期"。 我们先把工具演进讲清楚,再配大量可直接编译的实际例子。
第0部分 从 C++98 到 C++20:元编程工具演进地图
先建立全局观,再逐个讲透。
| 能力 | C++98 做法 | C++11 | C++14 | C++17 | C++20 |
|---|---|---|---|---|---|
| 编译期算值 | 递归模板 + enum{value} |
constexpr 函数(限制多) |
constexpr 放开(if/for/循环) | 大量加强 | constexpr 容器、constexpr 几乎全功能 |
| 编译期分支 | 特化/偏特化模拟 | std::conditional |
- | if constexpr |
保留 |
| 编译期类型操作 | 自制 traits | std::type_traits + decltype |
- | std::void_t |
更完整 |
| 约束模板 | 无 | std::enable_if(SFINAE) |
- | enable_if 的简化 |
concepts / requires |
| 编译期常量表达式 | 手动 | constexpr |
泛 lambda 的 auto 参数 | if constexpr、auto 模板参数 |
consteval、constexpr lambda 等 |
| 编译期字符串/容器 | 不行 | 不行 | 部分 | 基本可行 | 相对成熟 |
一句话总结演进:C++98 用"模板特化写电路",C++11 用"constexpr 函数写普通函数",C++17 的 if constexpr 消灭了 90% 的特化 hack,C++20 的 concepts 让"为什么选这个模板"也能被编译器检查。
第1部分 constexpr:编译期计算,从"写模板"变成"写代码"
1.1 constexpr 是什么
constexpr 修饰的函数/变量表示"可以在编译期求值"。C++98 里你必须用模板递归算阶乘;C++20 里你直接写:
// C++20:完全普通的函数,加个 constexpr 就能编译期算
constexpr long long fact(int n) {
long long r = 1;
for (int i = 2; i <= n; ++i) r *= i;
return r;
}
// 编译期使用(必须 constexpr 上下文)
constexpr long long f5 = fact(5); // 编译期就算好 = 120
// 运行时使用(同一函数也能当普通函数用)
int n = 7;
long long f7 = fact(n); // 运行时调用,OK
关键点:同一个 constexpr 函数既能编译期用、也能运行时用。fact(5) 在需要常量的地方被编译期求值;传运行时变量时正常运行时调用。这是 constexpr 对比模板递归的最大优势——一份代码,两个阶段。
1.2 constexpr 变量 vs const 变量
const int c1 = 5; // 运行时也可以初始化,只是"不可修改"
constexpr int c2 = 5; // 必须是编译期常量
int x = 5;
const int c3 = x; // OK,const 允许运行时值
// constexpr int c4 = x; // 错误:x 不是编译期常量
1.3 consteval:强制编译期(C++20)
consteval 声明的函数只能在编译期调用——如果尝试运行时调用,编译直接报错:
consteval long long square(int x) {
return x * x;
}
constexpr long long a = square(6); // OK,编译期
// int r = 3; long long b = square(r); // 错误:square 必须在编译期调用
用途:保证某些昂贵计算绝不在运行时发生;编译期就生成常量表。
1.4 编译期对象与 constexpr 构造函数(C++20 大杀器)
C++20 允许 constexpr 函数里创建对象、调用成员函数(C++14 放开循环/if,C++20 放开更多)。于是可以写出:
struct Point {
double x, y;
constexpr Point(double x, double y) : x(x), y(y) {}
constexpr double len() const { return x * x + y * y; }
};
constexpr Point p(3.0, 4.0);
static_assert(p.len() == 25.0, "3-4-5 三角形");
static_assert(C++11):编译期断言,条件不满足直接编译错误——这是现代版本 C++98 自制 StaticAssert 的正式语法。static_assert + constexpr 是调试编译期代码的利器。
1.5 constexpr 的实际用途(结合场景)
场景一:编译期生成查找表(查表优化)
// 编译期生成 sin 表(0~90 度,间隔 1 度)
#include <cmath>
constexpr double deg2rad(double d) { return d * 3.14159265358979323846 / 180.0; }
constexpr std::array<double, 91> make_sin_table() {
std::array<double, 91> t{};
for (int i = 0; i <= 90; ++i)
t[i] = std::sin(deg2rad(i));
return t;
}
constexpr auto SIN_TABLE = make_sin_table(); // 编译期建表
// 运行时直接查表,零计算开销
double s = SIN_TABLE[30]; // ≈ 0.5
std::array(C++11)配合 constexpr,C++20 里能在编译期构建容器。注意 std::sin 在 constexpr 环境下的支持:C++20 开始很多数学库函数 constexpr 化,但编译期精度依赖编译器实现。
场景二:编译期元数据(游戏/网络配置)
struct Config {
constexpr static int max_players = 8;
constexpr static int tick_rate = 60;
constexpr static long long max_memory = 1LL << 30;
};
// 所有地方用 Config::max_players,编译期常量,零运行时读取
第2部分 if constexpr:真正的编译期分支(C++17,C++20 沿用)
2.1 为什么需要它
C++98 里"按类型选分支"必须用特化/SFINAE:
template<typename T>
std::string describe() {
return IsPointer<T>::value ? "pointer" : "value"; // 运行时算,两个分支都被编译
}
问题:两个分支都会被实例化/编译。如果某个分支只对特定类型合法(如 *p 对 int 就非法),编译器直接报错。C++98 只能用特化把非法分支"隔离"。
2.2 if constexpr 的写法
#include <type_traits>
template<typename T>
void process(const T& v) {
if constexpr (std::is_pointer_v<T>) { // 注意 if constexpr + 编译期常量条件
std::cout << "pointer to " << *v << "\n"; // 只有 T 是指针时才编译这段
} else {
std::cout << "value " << v << "\n"; // 否则编译这段
}
}
关键区别:if constexpr 的分支在编译期被真实丢弃——T=int 时 *v 这段根本不生成、不检查。这就是 C++98 特化想做到而做不到的"编译期短路"。
2.3 对比 C++98 的等价物
C++98 你要写:
template<typename T> struct process_impl {
static void run(const T& v) { std::cout << "value " << v << "\n"; }
};
template<typename T> struct process_impl<T*> {
static void run(const T* v) { std::cout << "pointer to " << *v << "\n"; }
};
C++17+ 一个 if constexpr 搞定。这是"特化做分支"被取代的转折点——你在实践中 90% 的 TMP 分支需求都会用 if constexpr 而不是特化。
2.4 编译期递归的替代:if constexpr + 普通递归函数
// 编译期/运行时二合一:把模板递归换成普通递归 + constexpr
constexpr int sum_upto(int n) {
if (n <= 0) return 0;
return n + sum_upto(n - 1); // 普通递归,若 n 是编译期常量则编译期算
}
配合 constexpr,逻辑完全透明,不再需要 Sum<N> 那种模板递归。
2.5 if constexpr 与 decltype 结合:编译期类型检查
#include <type_traits>
template<typename T>
void print(const T& v) {
if constexpr (std::is_integral_v<T>) {
std::cout << "integer: " << v << "\n";
} else if constexpr (std::is_floating_point_v<T>) {
std::cout << "float: " << v << "\n";
} else {
std::cout << "other: " << typeid(T).name() << "\n";
}
}
这是现代代码里"类型分派"的标准写法——替代了 C++98 的一整套特化继承体系。
第3部分 可变参数模板(C++11+)与折叠表达式(C++17)
3.1 可变参数模板:接受任意数量参数
template<typename... Ts>
void print_all(Ts... args) {
(std::cout << ... << args) << "\n"; // 折叠表达式(C++17):左折叠展开
}
Ts... 叫参数包。折叠表达式 (std::cout << ... << args) 会展开成 std::cout << a1 << a2 << ... << an。
场景:日志系统、参数转发、类型列表处理。C++98 要处理"任意多参数"只能写十几个重载或递归模板;C++11 后用参数包 + 折叠,一行解决。
3.2 折叠表达式:编译期"循环求和"
// 编译期求和(类型为 int 时)
template<typename... Args>
constexpr int sum_all(Args... args) {
return (0 + ... + args); // 右折叠,初始值 0,处理空包
}
constexpr int r = sum_all(1, 2, 3, 4, 5); // 15,编译期算出
(args + ...)左折叠,(... + args)右折叠;- 加
0 + ... + args的0是初始值,保证空参数包也合法。
3.3 sizeof...(Ts):数出类型个数
template<typename... Ts>
struct Count {
static constexpr size_t value = sizeof...(Ts);
};
Count<int, double, char>::value == 3;
3.4 结合 if constexpr:递归处理类型包
#include <iostream>
void print_pack() { /* 空包出口 */ }
template<typename First, typename... Rest>
void print_pack(First f, Rest... rest) {
std::cout << f << " ";
print_pack(rest...); // 递归展开
}
用 if constexpr 可以合并成不需要出口重载的版本:
template<typename First, typename... Rest>
void print_pack2(First f, Rest... rest) {
std::cout << f << " ";
if constexpr (sizeof...(Rest) > 0)
print_pack2(rest...); // 无剩余参数时,这段编译期删除
}
第4部分 Concepts 与 requires(C++20 核心新特性)
4.1 为什么需要 concepts
C++98 里模板参数"能做什么"没人管:你写 T + T,编译器只能在实例化时发现 T 不支持 +,然后甩出一屏难以理解的错误。
concepts 的目标:
- 在调用点就检查类型是否满足要求(错误信息清晰、定位早);
- 让"重载选择"基于能力(而不仅是类型匹配);
- 让模板接口自带文档(概念名本身就是语义说明)。
4.2 定义一个 concept
#include <concepts>
// 定义概念:T 必须支持 < 运算
template<typename T>
concept LessThanComparable = requires(const T& a, const T& b) {
{ a < b } -> std::convertible_to<bool>; // 要求 a<b 合法且可转成 bool
};
概念 = 一个编译期布尔表达式。requires 表达式用于描述"这段代码必须合法"。
4.3 requires 表达式的四种形式
// 1. 简单要求:表达式合法
template<typename T>
concept HasSize = requires(const T& c) {
c.size(); // 必须有 .size() 成员函数
};
// 2. 类型要求:typename X 必须是一个类型
template<typename T>
concept HasValueType = requires {
typename T::value_type; // T 必须有嵌套类型 value_type
};
// 3. 复合要求:{ 表达式 } -> 约束
template<typename T>
concept Incrementable = requires(T t) {
{ ++t } -> std::same_as<T>; // ++t 合法,且返回类型必须是 T
};
// 4. 嵌套要求:requires 表达式内部再放 requires
template<typename T>
concept Container = requires(T c) {
c.begin();
c.end();
requires sizeof(T) > 4; // 附加条件
};
4.4 三种使用概念的方式
方式一:约束模板参数(最常用)
template<std::integral T> // T 必须是整数类型
T twice(T v) { return v * 2; }
twice(3); // OK,int
// twice(3.5); // 错误:double 不满足 integral
std::integral 是标准库预置概念(整数类型)。常用预置概念:
std::integral/std::floating_point:整数/浮点;std::signed_integral/std::unsigned_integral;std::same_as<T, U>/std::convertible_to<From, To>;std::constructible_from/std::default_initializable;std::invocable/std::predicate;std::derived_from/std::range(C++20 Ranges)。
方式二:在函数声明里约束(requires 子句)
template<typename T>
requires std::integral<T> // 约束放在参数列表之后
T add1(T a, T b) { return a + b; }
// 简写:auto 参数 + concept(类型约束缩写)
std::integral auto add2(std::integral auto a, std::integral auto b) {
return a + b;
}
方式三:约束类模板
template<typename T>
requires std::integral<T>
struct Counter {
T value;
};
Counter<int> c; // OK
// Counter<double> d; // 错误
4.5 用 concepts 做"能力驱动"的重载
这是 concepts 最强的实战价值——按能力重载,而不是按类型重载:
#include <concepts>
#include <iostream>
// 版本一:能迭代(容器)
template<typename T>
requires requires(const T& c) { c.begin(); c.end(); }
void describe(const T&) {
std::cout << "it's a container (has begin/end)\n";
}
// 版本二:可转 int(标量)
template<typename T>
requires std::convertible_to<T, int>
void describe(const T&) {
std::cout << "it's a scalar\n";
}
// 用法:
// std::vector<int> v; describe(v); → 版本一
// describe(42); → 版本二(int 无 begin/end,版本一被淘汰)
对比 C++98:这等价于用 SFINAE/enable_if 写两个重载并"条件性启用"。concepts 语法清晰、错误信息友好,且编译器能给出"为什么没选中"的明确理由。
4.6 概念与特化共存
concepts 不替代偏特化,二者配合使用。模板特化仍是"对某些类型给特殊实现"的机制,concept 负责"声明接口能力":
template<typename T> struct Size; // 主模板
template<std::integral T> struct Size<T> {
static constexpr int value = sizeof(T);
};
第5部分 现代类型操作:decltype、type_traits、void_t
5.1 decltype:让编译器告诉你表达式的类型
C++98 里"获取表达式的类型"几乎不可能;decltype(C++11)让编译器直接推导:
int a = 3;
decltype(a) b = 5; // int
decltype(a + 3.0) c = 0.0; // double(表达式 a+3.0 的类型)
decltype + auto 返回值:推导函数返回类型
template<typename A, typename B>
auto multiply(const A& x, const B& y) -> decltype(x * y) { // C++11 尾置返回
return x * y;
}
// C++14 起可以直接:
// template<typename A, typename B> auto multiply(const A& x, const B& y) { return x*y; }
5.2 void_t:检测类型是否存在的万能钩子(C++17)
std::void_t<T> 总是返回 void,但对非法的 T 会替换失败——正是 SFINAE 的触发点。用它做一个"检测器":
#include <type_traits>
// 检测 T 是否嵌套有 type 成员(如 std::iterator_traits 的 iterator_category)
template<typename T, typename = void>
struct HasTypeMember : std::false_type {};
template<typename T>
struct HasTypeMember<T, std::void_t<typename T::type>>
: std::true_type {};
// 使用:
// struct A { using type = int; };
// HasTypeMember<A>::value == true
// HasTypeMember<double>::value == false
原理:如果 T::type 不存在,std::void_t<typename T::type> 替换失败 → 走 false 版;存在则走 true 版。这个"SFINAE 探测"模式在 C++17 后极常见。
5.3 常用 type_traits 速查
#include <type_traits>
// 判断
std::is_pointer_v<T>; // 是否指针(_v 后缀 = ::value 简写,C++17)
std::is_integral_v<T>;
std::is_same_v<A, B>;
std::is_convertible_v<From, To>;
std::is_constructible_v<T, Args...>;
// 变换(结果是类型,用 ::type)
std::remove_pointer_t<T>; // 去指针(_t 后缀 = ::type 简写,C++14)
std::add_const_t<T>;
std::remove_reference_t<T>;
std::conditional_t<cond, A, B>; // C++98 自制 Conditional 的正式版
std::decay_t<T>; // 去引用+去 cv+数组转指针+函数转指针
// 用 _v / _t 后缀,日常代码更简洁
if constexpr (std::is_integral_v<T>) { ... }
5.4 类型列表的现代替代:std::tuple + std::variant
C++98 要自己写 Typelist;现代代码用标准容器:
#include <tuple>
#include <variant>
// 类型列表 → std::tuple(异构"同存")
std::tuple<int, double, char> t{1, 2.5, 'a'};
std::get<1>(t); // 2.5,按索引取
std::get<double>(t); // 按类型取
// 类型列表 → std::variant(异构"互斥":同一时刻只存一个)
std::variant<int, double, std::string> v;
v = 3.14; // 存 double
std::visit([](auto&& x) { ... }, v); // 运行时按当前类型分派
std::tuple/variant 的价值:C++98 里这些要手写海量特化代码(Boost 的实现就是 TMP 的巅峰);现在直接可用,且内部实现仍依赖模板元编程——读它们的实现能加深理解。
第6部分 编译期容器与字符串(C++20 新能力)
6.1 constexpr 的 std::array:编译期构建容器
#include <array>
constexpr std::array<int, 5> build_primes() {
std::array<int, 5> p{2, 3, 5, 7, 11};
return p;
}
constexpr auto PRIMES = build_primes();
static_assert(PRIMES[3] == 7);
6.2 编译期字符串(FixedString)
C++20 之前没有 constexpr 字符串容器,常用"字符数组包装"实现编译期字符串:
#include <string_view>
template<size_t N>
struct FixedString {
char data[N]{};
constexpr FixedString(const char (&s)[N]) {
for (size_t i = 0; i < N; ++i) data[i] = s[i];
}
constexpr operator std::string_view() const { return {data, N - 1}; }
};
// 用法(C++20 模板参数可以是字面量类对象):
// template<FixedString S> void print() { ... }
// print<"hello">();
实际用途:把字符串作为编译期模板参数,用于反射式序列化、编译期校验格式、日志标签等。
6.3 编译期算法:sort、transform 都可以 constexpr
#include <algorithm>
constexpr std::array<int, 5> sort5() {
std::array<int, 5> a{5, 3, 4, 1, 2};
std::sort(a.begin(), a.end()); // C++20 中 constexpr 支持 std::sort
return a;
}
constexpr auto S = sort5();
static_assert(S[0] == 1 && S[4] == 5);
C++20 让 <algorithm> 大部分算法 constexpr 化——编译期能做完整的排序/查找/变换。
第7部分 综合实战:一个 C++20 的完整 TMP 应用
目标:写一个编译期 "类型 → 可打印描述 + 序列化字节数" 的框架,演示 constexpr、if constexpr、concepts、type_traits、static_assert 的协同。
#include <concepts>
#include <iostream>
#include <type_traits>
#include <string_view>
// ---- 概念:可序列化 = 整数或浮点(演示 concepts 约束)----
template<typename T>
concept Serializable = std::integral<T> || std::floating_point<T>;
// ---- 编译期元数据:类型大小与标志 ----
template<typename T>
struct Meta {
static constexpr size_t bytes = sizeof(T);
static constexpr bool is_integral = std::is_integral_v<T>;
static constexpr std::string_view tag =
std::is_integral_v<T> ? "int-like" : "float-like";
};
// ---- 主逻辑:if constexpr 做类型分派 ----
template<Serializable T> // 约束:只接受可序列化类型
void process(const T& v) {
std::cout << "tag = " << Meta<T>::tag
<< ", size = " << Meta<T>::bytes << " bytes, value = ";
if constexpr (Meta<T>::is_integral) {
std::cout << v << " (integral)\n";
} else {
std::cout << v << " (floating)\n";
}
}
int main() {
static_assert(Serializable<int>); // 编译期断言:int 满足概念
static_assert(Serializable<double>);
// static_assert(Serializable<std::string>); // 若放开,编译失败:string 不满足
constexpr int ten = Meta<int>::bytes; // 编译期常量
std::cout << "Meta<int>::bytes = " << ten << "\n";
process(42);
process(3.14);
return 0;
}
输出:
Meta<int>::bytes = 4
tag = int-like, size = 4 bytes, value = 42 (integral)
tag = float-like, size = 8 bytes, value = 3.14 (floating)
这个例子涵盖:concept 约束参数、type_traits 判断、if constexpr 分支、constexpr 元数据、static_assert 断言——C++20 元编程的日常配方全部到位。
第8部分 更多实战场景(工作中真的会用到)
8.1 场景一:编译期注册表(事件/命令分发)
#include <tuple>
#include <type_traits>
// 给一组类型自动生成"类型→ID"的映射表
template<typename... Ts>
struct TypeRegistry {
static constexpr size_t count = sizeof...(Ts);
template<typename T>
static constexpr size_t index_of() {
// C++20 用折叠表达式数出 T 在第几个位置
size_t idx = 0;
bool found = false;
((std::is_same_v<T, Ts> ? (found = true, true) : (++idx, false)) || ...);
return found ? idx : count;
}
};
using Registry = TypeRegistry<int, double, char>;
static_assert(Registry::index_of<double>() == 1);
8.2 场景二:编译期配置表驱动代码生成
template<typename T>
concept HasName = requires { T::name; }; // 有静态 name
template<HasName T>
void print_tag() {
std::cout << "tag: " << T::name << "\n";
}
8.3 场景三:SFINAE 的现代替代——concepts 做"条件启用"
C++98/C++11 时代用 enable_if 让某个函数"只在条件成立时存在":
// 旧式 enable_if 写法
template<typename T>
std::enable_if_t<std::is_integral_v<T>, int> f(T) { return 1; }
C++20 用 concept 更清晰:
template<std::integral T>
int f(T) { return 1; }
8.4 场景四:constexpr + 编译期校验(安全相关)
constexpr bool check_bounds(size_t i, size_t n) {
return i < n;
}
static_assert(check_bounds(3, 10), "index within bounds");
// static_assert(check_bounds(15, 10), "..."); // 编译失败:越界
用于数组访问、协议长度校验——错误在编译期就暴露。
8.5 场景五:泛型 + constexpr 的数学库(向量运算)
#include <array>
template<size_t D>
using Vec = std::array<double, D>;
template<size_t D>
constexpr double dot(const Vec<D>& a, const Vec<D>& b) {
double s = 0;
for (size_t i = 0; i < D; ++i) s += a[i] * b[i];
return s;
}
constexpr Vec<3> u{1, 2, 3}, v{4, 5, 6};
static_assert(dot(u, v) == 32.0); // 1*4+2*5+3*6 = 32,编译期验证
第9部分 C++98 vs C++20:同一个任务,两种写法对比
| 任务 | C++98 | C++20 |
|---|---|---|
| 编译期算阶乘 | Factorial<N> 递归模板 + enum |
constexpr int f(int) 普通函数 |
| 按类型选分支 | 全特化/偏特化类 | if constexpr |
| 类型是否相同 | 自制 IsSame |
std::is_same_v |
| 类型选择 | 自制 Conditional |
std::conditional_t |
| 编译期断言 | 自制 StaticAssert<true> |
static_assert |
| 条件启用函数 | EnableIf + SFINAE |
concept / requires |
| 类型列表 | 自制 Typelist | std::tuple / std::variant |
| 编译期容器 | 不支持 | constexpr std::array 等 |
| 递归展开参数包 | 递归重载 + 出口 | 折叠表达式 |
核心区别一句话:
- C++98:模板元编程 = "写模板,让编译器生成代码"(语法层面就在做元编程);
- C++20:模板元编程 = "写普通代码,加 constexpr/concept 让它在编译期跑"(语义层面)。
第10部分 何时该用、何时不该用(工程判断)
用 C++20 TMP 的地方:
- 需要编译期常量、查表、数学库(constexpr);
- 需要类型分派/类型特征判断(if constexpr + type_traits);
- 需要约束接口、让错误在调用点暴露(concepts);
- 需要异构容器(tuple/variant,或用它们实现注册表)。
别滥用 TMP 的警告:
- 编译时间:递归实例化、大型 constexpr 表达式会显著拖慢编译——可读性受损;
- 错误信息:constexpr 失败/概念不满足的错误虽比 C++98 好,但复杂嵌套仍难读;
- 不要为了炫技:运行时 if + 虚函数可能更清晰;性能瓶颈先测出来再优化;
- 注意 constexpr 的能力边界:不是所有标准库函数都 constexpr(如动态分配、I/O 通常不行),C++20 后虽大幅放开,仍需确认。
总结:C++20 模板元编程一张图
编译期编程 = 值计算(constexpr/consteval)
+ 类型操作(type_traits / decltype)
+ 条件分支(if constexpr)
+ 接口约束(concepts / requires)
+ 批量处理(可变参数模板 / 折叠表达式 / tuple)
学习路径建议:
- 先掌握
constexpr+static_assert(能写编译期函数); - 再学
if constexpr+type_traits(能做类型分派); - 然后学 concepts/requires(能给模板立规矩);
- 最后学 tuple/variant/折叠表达式(能做类型包处理);
- 遇到老代码时,对照 C++98 篇理解它在干什么。
最终一句话:C++20 让你用写普通程序的思维做编译期编程——工具更强了,但"在编译期处理类型与常量"的元编程本质没变。理解本质,两种标准都能游刃有余。

浙公网安备 33010602011771号