request-free-img

C++ 编译期魔法:constexpr、consteval、constinit 完全解析,一文彻底搞懂它们的区别与最佳实践

在这个视频开始之前,我想问你一个问题,你是希望在编译时就把可能存在的BUG排查出来,还是在程序运行时发现问题再回头来改呢?

你可以花点时间去思考这个问题,

今天介绍的C++知识点,就是为了让你写出更健壮的代码

在C++中,constexpr、consteval、constinit 的使用总是让新手程序员摸不着头脑

今天这个视频就来给大家讲讲它们到底在什么时候使用、 能干什么、有什么限制

1. constexpr —— “我很宽容”的编译期函数

constexpr从C++11起就存在,它的中文名称叫常量表达式说明符,它可以用来修饰变量、函数、成员函数、lambda

咱们来看下边这段代码

constexpr int square(int x) {
    return x * x;
}

这是一个 constexpr 函数,它的含义是:“我有能力在编译期执行,如果你把常量表达式传给我,我就给你编译期常量结果;如果传的是运行期变量,我就当普通函数运行。”

简单说就是:编译器会把它当成“双重身份”函数, 如果是是常量表达式上下文 → 就会进行编译期求值
如果是普通上下文 → 就会生成普通机器码,进行运行期调用

int main() {
    // 比如在这里,传入常量,那么就是编译期计算,a的结果就是 25
    constexpr int a = square(5);

    int b = 10;
    // 而这一行就是运行期计算,因为b是一个运行期的变量
    // 虽然 square 是 constexpr 函数,但调用处不需要常量表达式
    // 所以编译器会选择“降级”为普通函数调用
    int c = square(b);

    // 我们可以看一下它的汇编代码,在汇编代码中可以看到它的函数调用
    mov edi, DWORD PTR [rbp-8] ; 把 b 的值取出来
    call square(int) ; 真正发生一次函数调用!
    mov DWORD PTR [rbp-12], eax ; 把返回值存到 c

    // 这个 d 是 constexpr 变量 → 它要求初始化必须是常量表达式
    // 但是,这里 square(b) 中的 b 是运行期变量,它的地址在栈上,值只有运行时才知道
    // 所以,即使 square 本身是 constexpr 函数,但参数不是常量表达式 → 整个调用就不是常量表达式,无法在编译期计算出 square(b) 的值
    // 所以这里编译时会报错
    constexpr int d = square(b); // 错误!b 不是编译期常量
}
constexpr —— “我很宽容”的编译期函数
constexpr —— “我很宽容”的编译期函数

因此,一句话总结,constexpr —— “我很宽容,你要编译期我尽量,运行期也行”

2. consteval —— “很傲娇”的立即函数(C++20)

consteval 是 C++20 新增的关键字,它还有一个名称叫做:immediate function,中文名称是强制编译期函数,或者叫立即函数说明符

看到这个中文名称,可能你也差不多明白它的作用了,咱们来看下边的示例代码

consteval int square_strict(int x) {
    return x * x;
}
int main() {
    // 这里传入常量表达式5给square_strict,我们看一下它的汇编代码
    // 可以看到它的汇编代码就是一行,将立即数十六进制的19h也就是十进制的25,直接写入变量 a 的内存位置中。
    // 004318D5 mov dword ptr [a],19h
    // 所以它根本不生成任何函数调用、不生成任何计算指令,而是直接在可执行文件的 .data 段里把变量 a 初始化为 25。
    // 这就是C++ 编译期求值最漂亮、最极致的实现——程序运行前,a 就已经是 25 了
    constexpr int a = square_strict(5); // OK

    // 我们接着看代码
    // x是一个运行时变量
    int x = 10;
    // 把x传给square_strict,这意味着 square_strict(x) 必须在运行期求值
    // 但 square_strict 是 consteval 函数,它禁止任何运行期调用,所以这里会编译出错
    int b = square_strict(x);           // 编译错误

    constexpr int c = square_strict(x); // 同样错误
}
consteval —— “很傲娇”的立即函数
consteval —— “很傲娇”的立即函数

一句话总结就是:consteval “我很傲娇,必须编译期,否则不见!”

3. constinit —— 彻底终结“静态初始化顺序灾难”(C++20)

constinit 是 C++20 中正式引入的关键字。它的中文名称是:常量初始化说明符

为了让你真正理解constinit,我先问你一个问题:C++ 的全局或静态对象在程序启动时的初始化分为哪三个阶段?

可能很多人不知道,没事,我告诉你

  • 第一个阶段是:Zero initialization 零初始化,在程序加载到内存那一刻,它会把对象全部字节设为 0 → 如果是std::string类型,就会变成一个空字符串(使它成为一个合法对象!)
  • 第二个阶段是:Constant initialization 静态初始化,它在程序启动前,在零初始化之后
  • 第三个阶段是:Dynamic initialization 动态初始化,全局变量的动态初始化发生在程序启动阶段,不同单元的全局变量的初始化顺序完全未定义。正因为它的初始化顺序未定义,所以这是C++程序员将要面对的问题,现在请你记住它

下面我们先看几行代码,看一下在没有 constinit 之前,C++程序员到底有多惨

假设在我们的项目中有一个文件叫做a.cpp
在这个文件中有一个函数叫read_from_file,它用来从文件中读取配置信息并赋值给config变量

// file: a.cpp
std::string config = read_from_file(); // 我负责初始化 config

在我们的项目中还有一个文件叫做b.cpp
在这个文件中会使用config变量

// file: a.cpp
// file: b.cpp
extern std::string config;
const std::string prefix = config + "/logs"; // 我想用 config!

现在这个项目运行时,有可能发生什么事情呢?

当程序启动时,先初始化 b.cpp 的 prefix变量
但此时 config 还没构造,它仅仅进行了零初始化,(现在它还是一个空字符串)
所以 prefix 会变成 “/logs”(这明显不是我们想要的路径地址!)
因此我们的程序出现了BUG,这种BUG极其隐蔽,非常难查

那有人可能就要问了,平时我也这样写过,为什么我的程序运行正常呢?
你没出问题,不是因为代码安全,而是因为你“中了大奖” —— 你的编译器/链接器“恰好”把 config 的初始化排在了 prefix 前面!

实际上:Linux 内核、Chrome、MySQL、Boost、几乎所有大项目都曾经踩过这个坑

在没有constinit之前,C++程序员发明了一个叫做:Nifty Counter 技巧,它曾经是 Boost、ACE、Qt 等大厂框架的标配写法,堪称“上古神器”

现在有了 constinit,只需要写成这样,就彻底安全了!

constinit std::string config = read_from_file(); // ← 重点在这里!

之后,其他任何文件里随便用config,都绝对安全了

constinit彻底终结 C++ 程序员几十年来最头疼、最难调试、最隐蔽的 Bug
它第一次真正优雅、安全、零开销地解决了“全局对象初始化顺序”这个千年老大难问题。

怎么样,现在你明白了吗?


更多问题探讨,请关注公众号:程序员角