编译期计算:constexpr函数,constexpr构造函数,编译期容器

这一章我们来聊聊编译期计算。说白了,就是把本该在运行时干的活,提前到编译阶段搞定。C++11 引入 constexpr 之后,这个能力才真正变得实用。我个人觉得,这是现代 C++ 最被低估的特性之一。

constexpr 函数:让编译器帮你打工

先看一个最简单的例子。你写一个计算阶乘的函数,传统做法是运行时递归。但如果你加上 constexpr,编译器会在编译期就把结果算出来。

constexpr int factorial(int n) {
    return n <= 1 ? 1 : n * factorial(n - 1);
}

int main() {
    constexpr int val = factorial(5); // 编译期就算好了
    int arr[val];                     // 数组大小必须是编译期常量
}

这里有个关键点:factorial(5) 的调用结果被赋值给 constexpr 变量,编译器就必须在编译期求值。如果参数不是编译期常量,比如从用户输入读取,那它就会退化为普通函数调用。嗯,这就是 constexpr 的“双模式”特性——既能编译期跑,也能运行时跑。

核心要点:constexpr 函数并不保证一定在编译期执行。只有当它的所有参数都是编译期常量,且结果被用于需要编译期常量的上下文时,编译器才会强制编译期求值。

我在项目中遇到过一个问题:有人把 constexpr 函数写得特别复杂,里面调用了很多 STL 算法。结果编译时间暴涨。为什么?因为编译器在编译期模拟执行这些代码,复杂度一点没少。所以我的建议是:constexpr 函数尽量保持简单,别搞太复杂的逻辑。

constexpr 构造函数:编译期造对象

光有函数还不够,我们还需要能在编译期构造对象。C++11 开始支持 constexpr 构造函数,C++14 放宽了很多限制,到了 C++17 和 C++20 就更灵活了。

class Point {
public:
    constexpr Point(double x, double y) : x_(x), y_(y) {}
    constexpr double x() const { return x_; }
    constexpr double y() const { return y_; }
private:
    double x_, y_;
};

int main() {
    constexpr Point p(3.0, 4.0);
    constexpr double dist = p.x() * p.x() + p.y() * p.y(); // 编译期计算
}

注意看,构造函数加了 constexpr,成员函数也加了 constexpr。这样整个对象的生命周期都在编译期完成。你想想看,如果有一个复杂的数学对象,比如矩阵或复数,你完全可以在编译期把常量矩阵的逆算好,运行时直接拿来用。

避坑指南:constexpr 构造函数体在 C++11 中必须是空的,所有初始化都得在初始化列表里完成。C++14 放宽了限制,允许在构造函数体内做一些简单的赋值操作。但别指望在里面调用 new 或者虚函数——这些在编译期根本没法做。

我曾经踩过一个坑:在 constexpr 构造函数里用了 std::vector,结果编译报错。后来才意识到,vector 的动态内存分配在编译期是不允许的。嗯,这里要注意:constexpr 对象只能使用静态存储,不能涉及堆内存。

编译期容器:真的能做到吗?

说到容器,很多人会问:能不能在编译期搞一个 std::vector 或者 std::map?答案是:不能直接用标准库的容器,因为它们涉及动态内存分配。但我们可以自己造一个编译期友好的“伪容器”。

C++20 引入了 constexpr std::vectorconstexpr std::string,但注意:它们只能在编译期构造,然后传递给运行时使用。你不能在编译期对它们进行动态插入删除——那还是需要运行时堆内存。

// C++20 允许 constexpr vector 的简单使用
constexpr std::vector<int> make_vec() {
    std::vector<int> v{1, 2, 3, 4, 5};
    return v; // 编译期构造,运行时使用
}

int main() {
    constexpr auto v = make_vec(); // 错误!constexpr 变量不能持有动态内存
    // 正确用法:
    auto v2 = make_vec(); // 运行时使用编译期构造的结果
}

我个人习惯的做法是:用 std::array 或者自己写的编译期固定大小容器。比如实现一个编译期的静态数组,支持 size()begin()end() 等接口。

重要提醒:不要把编译期容器和运行时容器混为一谈。编译期容器的“元素”必须是编译期常量,而且容器大小必须在编译期确定。如果你需要动态大小,那就老老实实用运行时容器。

知识体系图:编译期计算的核心脉络

下面这张图帮你理清 constexpr 相关概念之间的关系。我画的时候特意把“编译期 vs 运行时”这条分界线标出来了,你一看就明白。

编译期计算知识体系 constexpr constexpr 函数 constexpr 构造函数 编译期容器 特点 • 双模式:编译期/运行时均可执行 • 函数体必须简单(C++11限制) • C++14放宽:允许局部变量、循环 • 不能有动态内存分配 特点 • 初始化列表初始化所有成员 • 成员函数也需 constexpr • 不能调用非 constexpr 函数 • 编译期构造,运行时使用 特点 • 固定大小(std::array) • 无动态内存分配 • C++20支持constexpr vector(有限) • 适合编译期查找表、元数据

实际应用场景

说了这么多理论,到底什么时候用?我举几个真实例子。

  • 查找表:比如三角函数值表、CRC 校验表。编译期算好,运行时直接查。我做过一个通信协议的项目,CRC 表就是 constexpr 算出来的,性能提升很明显。
  • 类型元数据:比如编译期计算类型的大小、对齐方式、成员偏移量。这些信息在模板元编程中特别有用。
  • 配置参数校验:你可以在编译期检查配置参数是否合法。比如数组大小不能为负数,枚举值必须在有效范围内。一旦不合法,编译直接报错,比运行时 crash 好多了。

个人经验:我习惯把 constexpr 函数和 static_assert 配合使用。比如写一个 constexpr 函数检查某个条件,然后用 static_assert 在编译期断言。这样很多错误在写代码的时候就暴露了,而不是等到测试阶段才发现。

避坑指南:我踩过的那些坑

我曾经在一个项目里,把 constexpr 函数写得特别长,里面调用了十几个其他 constexpr 函数。结果编译时间从 10 秒飙到了 3 分钟。后来我拆分成多个小函数,并且用 consteval(C++20)强制编译期求值,才把编译时间降下来。

还有一个坑:constexpr 函数里不能有 static 局部变量。为什么?因为 static 变量的初始化涉及线程安全,编译期没法处理。嗯,这个限制很多人不知道。

重要警告:constexpr 不等于“快”。编译期计算只是把运行时的开销转移到了编译时。如果你的 constexpr 函数逻辑很复杂,编译时间会显著增加。权衡好“编译时间”和“运行时间”的取舍,才是工程实践的关键。

好了,这一章的内容就到这里。编译期计算是个好东西,但别滥用。记住:简单、明确、可维护,才是好代码的标准。


公众号:蓝海资料掘金营,微信 deep3321