13. 编译期计算:constexpr函数与模板元编程、编译期斐波那契数列与素数判断
说实话,编译期计算这个话题,我每次讲都觉得特别过瘾。为什么呢?因为这是C++真正区别于其他语言的地方之一——你写的代码,有一部分是在编译阶段就执行完毕的。运行时零开销,性能直接拉满。
我记得刚接触模板元编程那会儿,被各种递归模板搞得晕头转向。后来C++11引入了constexpr,我才发现,原来很多元编程的活儿,用constexpr函数就能干得更漂亮。今天咱们就把这两条路都走一遍。
13.1 编译期计算的两条路
先理清一个概念:编译期计算,说白了就是让编译器帮你算好结果,运行时直接拿现成的。C++里主要有两种方式:
- 模板元编程(TMP):利用模板特化和递归,在类型系统层面做计算。老派做法,C++98就有了。
- constexpr函数:C++11引入,C++14大幅放宽限制。写起来更像普通函数,但能在编译期求值。
我个人习惯是:能用constexpr的,优先用constexpr。模板元编程虽然强大,但可读性确实差一些。不过有些场景——比如类型萃取、编译期分支选择——还是得靠模板。
核心原则:编译期计算的目标是让运行时"零成本抽象"。你付出的代价只在编译阶段,运行时拿到的就是最优结果。
13.2 constexpr函数:现代C++的编译期利器
先看一个最简单的例子——编译期阶乘:
constexpr int factorial(int n) {
return n <= 1 ? 1 : n * factorial(n - 1);
}
int main() {
constexpr int result = factorial(5); // 编译期就算好了
// result == 120
}
这里有个关键点:constexpr函数不一定总在编译期执行。如果你把结果赋给一个constexpr变量,那编译器必须算出来。如果赋给普通变量,编译器可能选择在运行时算。嗯,这里要注意——别以为写了constexpr就一定是编译期执行。
C++14之后,constexpr函数里可以写循环、局部变量、甚至if语句。这就舒服多了:
constexpr int factorial_cpp14(int n) {
int result = 1;
for (int i = 2; i <= n; ++i) {
result *= i;
}
return result;
}
你看,这跟普通函数几乎没区别了。我在项目中经常用这种方式替代宏定义,既安全又高效。
13.3 编译期斐波那契数列
斐波那契数列是编译期计算的经典案例。咱们先用模板元编程实现:
// 模板元编程版本
template <int N>
struct Fibonacci {
static constexpr int value = Fibonacci<N-1>::value + Fibonacci<N-2>::value;
};
template <>
struct Fibonacci<0> {
static constexpr int value = 0;
};
template <>
struct Fibonacci<1> {
static constexpr int value = 1;
};
// 使用
constexpr int fib10 = Fibonacci<10>::value; // 55
说实话,这写法看着就头疼。模板特化、递归继承……每次写都像在跟编译器玩猜谜。再看看constexpr版本:
constexpr int fibonacci(int n) {
if (n <= 1) return n;
int a = 0, b = 1;
for (int i = 2; i <= n; ++i) {
int next = a + b;
a = b;
b = next;
}
return b;
}
constexpr int fib10 = fibonacci(10); // 55,编译期计算
你想想看,哪个更容易理解?明显是后者。而且性能上完全一样——都是编译期算好的。
我的建议:如果计算逻辑不涉及类型操作,优先用constexpr。模板元编程留给那些真正需要类型计算的场景。
13.4 编译期素数判断
素数判断稍微复杂一点。咱们先写一个constexpr版本:
constexpr bool is_prime_impl(int n, int divisor) {
if (divisor * divisor > n) return true;
if (n % divisor == 0) return false;
return is_prime_impl(n, divisor + 1);
}
constexpr bool is_prime(int n) {
if (n < 2) return false;
return is_prime_impl(n, 2);
}
// 编译期验证
static_assert(is_prime(17), "17 should be prime");
static_assert(!is_prime(18), "18 should not be prime");
这里用了static_assert——它会在编译期检查条件。如果is_prime(17)返回false,编译器直接报错。我在项目中经常用这个技巧做编译期校验,比如检查配置参数是否合法。
那模板元编程版本呢?也给你看看:
template <int N, int D>
struct IsPrimeImpl {
static constexpr bool value =
(D * D > N) ? true :
(N % D == 0) ? false :
IsPrimeImpl<N, D + 1>::value;
};
template <int N>
struct IsPrime {
static constexpr bool value =
(N < 2) ? false :
IsPrimeImpl<N, 2>::value;
};
// 使用
static_assert(IsPrime<17>::value, "17 is prime");
嗯……这代码读起来像在解谜题。三目运算符嵌套,模板递归展开。虽然功能一样,但维护起来真心累。
13.5 实战:编译期素数表
咱们来点实用的。假设你需要一个编译期生成的素数表,用于后续的运行时查询。用constexpr可以这样写:
#include <array>
constexpr std::array<int, 10> generate_primes() {
std::array<int, 10> primes = {};
int count = 0;
int num = 2;
while (count < 10) {
bool is_prime = true;
for (int i = 2; i * i <= num; ++i) {
if (num % i == 0) {
is_prime = false;
break;
}
}
if (is_prime) {
primes[count++] = num;
}
++num;
}
return primes;
}
constexpr std::array<int, 10> primes = generate_primes();
// primes = {2, 3, 5, 7, 11, 13, 17, 19, 23, 29}
你看,C++14的constexpr函数里可以写循环、局部数组,甚至for循环。这在C++11里是做不到的。我曾经在C++11时代为了生成编译期素数表,写了一堆模板特化,代码量翻了三倍。C++14之后,一切都简单了。
注意:constexpr函数在编译期执行时,不能有未定义行为。比如数组越界、整数溢出等,都会导致编译错误。编译器在这方面检查得很严格。
13.6 性能与编译时间的权衡
编译期计算不是免费的。你算得越多,编译器干活就越久。我见过一个项目,把整个配置解析都做成了编译期计算,结果每次改配置都要编译好几分钟。
我的经验是:
- 小规模计算(几十次迭代以内):放心用
constexpr,编译时间几乎无感。 - 中等规模(几百到几千次):注意编译时间,可以考虑拆分。
- 大规模计算:慎重。有时候运行时计算反而更灵活。
你想想看,编译期计算的优势是运行时零开销,但代价是编译时间增加。这个trade-off,得根据项目实际情况来权衡。
13.7 知识体系总览
下面这张图总结了本章的核心逻辑:
13.8 总结
编译期计算是C++的独门绝技。两条路各有千秋:
- 模板元编程:老而弥坚,适合类型层面的计算。但代码可读性差,调试困难。
- constexpr函数:现代C++的宠儿,写起来像普通函数,功能强大。C++14之后几乎能胜任所有数值计算场景。
我个人建议:新项目优先考虑constexpr。只有在需要操作类型、做编译期分支选择时,才回头用模板元编程。记住,代码是写给人看的,不是写给编译器看的。
一句话总结:编译期计算让你的程序"未卜先知",运行时只管执行,不问来路。
公众号:蓝海资料掘金营,微信deep3321