编译期计算:constexpr 与模板元编程实用化
编译期计算的价值不在炫技,而在两件事:把错误提前到编译期、把开销从运行时挪走。
一、static_assert:编译期断言
static_assert(sizeof(void*) == 8, "需要 64 位平台");
static_assert(std::is_trivially_copyable<T>::value, "T 必须可平凡拷贝");
C++11 的 static_assert 必须带消息;C++17 起消息可省略。用在:
- 结构体布局锁定(见内存对齐篇);
- 模板实参约束(C++20 用
requires,之前用static_assert+ 类型萃取); - 平台假设(字节序、指针宽度、
long宽度)。
二、constexpr 函数
// C++11:单 return 语句
constexpr int square(int x) { return x * x; }
// C++14:任意语句
constexpr std::size_t strLen(const char* s) {
std::size_t n = 0;
while (s[n]) ++n;
return n;
}
constexpr std::size_t k = strLen("hello"); // 编译期 5
注意:constexpr 函数在参数不是常量表达式时退化为运行时执行,不会报错。要用 static_assert 强制编译期求值:
static_assert(square(4) == 16, "编译期校验");
三、constexpr 的实用场景
场景 1:表驱动替代运行时计算
constexpr std::array<int, 256> makeCrcTable() {
std::array<int, 256> t{};
for (int i = 0; i < 256; ++i) {
int c = i;
for (int j = 0; j < 8; ++j) c = (c & 1) ? (0xEDB88320 ^ (c >> 1)) : (c >> 1);
t[i] = c;
}
return t;
}
constexpr auto kCrcTable = makeCrcTable(); // 编译期生成,运行时零成本
这就是"编译期建表"的现代写法,替代了以前用脚本生成头文件的做法。
场景 2:编译期字符串哈希
constexpr std::uint32_t fnv1a(const char* s, std::size_t n) {
std::uint32_t h = 2166136261u;
for (std::size_t i = 0; i < n; ++i) {
h ^= static_cast<unsigned char>(s[i]);
h *= 16777619u;
}
return h;
}
constexpr std::uint32_t operator""_hash(const char* s, std::size_t n) {
return fnv1a(s, n);
}
switch (key) {
case "alpha"_hash: /* ... */ break; // 编译期算好,无运行时开销
case "beta"_hash: /* ... */ break;
}
四、模板特化与 if constexpr(C++17)
C++11 时代的模板分支靠特化与重载,写起来啰嗦:
// C++11:靠重载做类型分支
template <class T> struct Serializer { static void write(T v) { rawWrite(v); } };
template <> struct Serializer<std::string> { static void write(const std::string& s) { rawWrite(s.data(), s.size()); } };
C++17 起用 if constexpr:
template <class T>
void write(const T& v) {
if constexpr (std::is_same_v<T, std::string>) {
rawWrite(v.data(), v.size());
} else if constexpr (std::is_arithmetic_v<T>) {
rawWrite(&v, sizeof(v));
} else {
static_assert(sizeof(T) == 0, "unsupported type");
}
}
if constexpr 丢弃的分支不会被实例化,因此不会触发无意义的编译错误。
五、类型萃取(type traits)常用清单
| 萃取 | 用途 |
|---|---|
std::is_integral / is_floating_point | 数值类型分支 |
std::is_same / is_convertible | 类型匹配 |
std::is_trivially_copyable | 能否 memcpy |
std::is_nothrow_move_constructible | 容器扩容策略 |
std::enable_if | SFINAE 重载约束(C++11) |
std::decay_t | 退化推导结果(C++14) |
std::remove_reference_t | 去掉引用(C++14) |
_t 与 _v 后缀是 C++14/17 的简写形式,老代码里的 typename std::is_same<A,B>::type 可逐步替换。
六、编译期递归与循环
C++11 没有 constexpr for,模板元编程靠递归:
template <int N> struct Fact {
static constexpr long long value = N * Fact<N - 1>::value;
};
template <> struct Fact<0> {
static constexpr long long value = 1;
};
C++14 起直接写循环即可,上面的代码可以简化为:
constexpr long long fact(int n) {
long long r = 1;
for (int i = 2; i <= n; ++i) r *= i;
return r;
}
结论:新代码尽量用 constexpr 函数,模板元编程只在需要"类型级计算"时才用。
七、编译期与运行时开销对照
| 手段 | 编译期 | 运行时 | 适用 |
|---|---|---|---|
| 宏 | 无检查 | 展开后同函数 | 仅条件编译,别用来算数 |
constexpr 函数 | 求值 | 零(常量表达式处) | 计算常量、建表 |
| 模板特化 | 实例化 | 零(直接内联) | 类型相关逻辑 |
| 虚函数 | 无 | 一次间接跳转 | 运行时多态 |
std::function | 无 | 类型擦除 + 可能堆分配 | 存储回调 |
八、代价与边界
- 编译时间上升:大量模板实例化与
constexpr求值会拖慢编译,注意用前置声明与显式实例化隔离; - 报错可读性下降:模板错误信息冗长,用
static_assert提供人类可读的诊断; - 调试困难:
constexpr中间值在调试器中可能不可见; - 过度设计:不是所有常量都值得
constexpr,const int足够清楚时就用它。
九、实战建议
- 先用
constexpr函数替代"计算型宏",零成本改造; - 给关键结构加
static_assert锁布局与大小; - 表驱动逻辑(CRC、查表、映射)改编译期生成;
- 类型分支优先
if constexpr(C++17)而非特化; static_assert(sizeof(T) == 0, ...)作为"未支持类型"的兜底诊断。
编译期做的事越多,运行时能出的错就越少。