C++26 中的 Erroneous Behavior

动机

未定义行为( undefined behavior )是悬在 C++ 开发者头上的一把时刻可能掉下的剑。具有未定义行为的程序,不但其正确性无法保证,其行为也不可预知,造成的影响也无法有效控制。隐藏在复杂软件系统中的未定义行为难以复现、精确定位和调试,浪费了开发者的大量宝贵时间。这引发了开发者对 C++ 未定义行为的声讨,促使 C++ 标准委员会在近年开始尝试各种方法以减少 C++ 标准中规定的未定义行为的数量。

来看一个经典的 C++ 未定义行为的例子——读未初始化的局部变量:

void foo(int);
void test() {
    int x;
    foo(x);
}

在其他大部分编程语言中,读未初始化的局部变量都是一个硬错误,编译器会明确拒绝编译这样的程序。但是在 C++ 中,读未初始化的局部变量是一个未定义行为。编译器允许这样的程序通过编译,但是程序在运行时会发生什么则无法预测。C++ 采取这样的设计一部分原因是兼容 C 的行为,另一部分原因则可能是性能:对变量进行初始化总归是要耗费那么一点 CPU 时间的。C++ 相信开发者的智慧,相信在读任何局部变量时那个局部变量都已经被开发者正确地初始化了。需要注意,读未初始化的局部变量是未定义行为,并不意味着程序在运行时仅仅会读到一个任意的值而已。未定义行为的影响是更加深远的,当读未初始化的局部变量发生时,程序理论上来说允许发生 任何 行为。例如,标准允许编译器删除 test 中的所有代码,包括读变量 x 之后发生的对 foo 函数的调用。编译器可以假设未定义行为不会发生并基于这一假设进行优化的这一特点会使得由未定义行为造成的错误非常难以排查。

事实证明,人类犯蠢的时候远远多于人类展露智慧的时候。大部分时候,开发者没有初始化变量的原因仅仅是因为他们忘了这码事了。他们接下来会发现自己的程序通过了编译,但是在运行的时候会发生奇怪的、有时还无法稳定复现的错误。在数个小时的调试后,他们终于想起来检查一下编译日志,随后他们才会发现原来是自己忘记了初始化这个变量。

有没有什么办法来解决这个问题呢?至少我们应该让程序具有更加可预测的行为,这样至少可以让调试工作变得更简单。一种简单的方法是我们可以让编译器使用某个固定的值自动初始化那些没有初始化的变量。当程序试图读取一个未初始化的局部变量时,程序将读出由编译器写入该变量的固定值,程序的行为从未定义变为了某种意义上的良定义。但是,正如前面所说,读未初始化的变量一定是一个编程错误,因此程序的行为仍然是一种“错误”的行为。在这种情况下,为了不掩盖错误,我们希望实现能够通过某种机制对这种行为进行编译时或运行时的检测,并给出诊断。

这就是 C++ 标准提案 P2795 引入的 erroneous behavior 概念的基本思想:Erroneous behavior 是一种“错误”的“良定义行为”,标准鼓励实现在 erroneous behavior 发生时给出诊断。在引入 erroneous behavior 这一概念的同时,P2795 同时也将读未初始化的局部变量这一行为由未定义行为修改为了 erroneous behavior 。

提案 P2795 已经进入 C++26 标准。

具体行为要求

Erroneous behavior 是一种“错误”的“良定义行为”。一方面,具有 erroneous behavior 的程序,其行为是可以预测的、可以被稳定复现的,呈现出良定义行为的特征。Erroneous behavior 发生时,程序可以按照约定的语义继续执行,也可以报告错误终止执行,只要行为稳定可预测即可。另一方面,erroneous behavior 一定来源于某种编程错误,erroneous behavior 的发生意味着程序出现了 bug 。因此,当程序中可能存在 erroneous behavior 以及 erroneous behavior 真实触发时,标准都“鼓励”实现产生诊断信息以辅助开发者解决该错误。“鼓励”意味着这一要求并不强制,标准也允许实现不尝试对 erroneous behavior 进行任何检测。

一个特殊的例外在常量求值上下文中。在编译期求值一个常量表达式时,如果程序触发了 erroneous behavior,标准要求实现必须中止编译并给出诊断信息。这一点与未定义行为是相同的。

性能影响

前面提到,在引入 erroneous behavior 这一概念后,编译器将必须使用某个固定的值来初始化所有未初始化的局部变量,这势必引入一定的性能开销。当然,对于绝大部分场景来说,这一点性能开销都是可以忽略的。但在少部分情况下,开发者如果确实非常在意这部分开销,也可以通过指定 [[indeterminate]] 属性来避免这一自动初始化的动作:

void foo(int *);
void test() {
    int x[256] [[indeterminate]];
    foo(x);
}

在上面这个例子中,局部数组 x 包含 256 个未初始化的 int 对象,自动使用某个值初始化这些对象在某些场景下可能会引入可观的性能开销。这种情况下,通过指定 [[indeterminate]] 属性,开发者可以命令编译器不要自动初始化这些对象。此时程序的行为将回退到引入 erroneous behavior 之前的“裸奔”状态,如果程序后续读取了某个未初始化的数组元素,那么将导致未定义行为。

实现状态

目前还没有编译器厂商实现了对 erroneous behavior 的完整支持。实现这一支持所需要的修改是两方面的,首先是对程序行为的修改,其次是错误检测机制的实现。对于读未初始化的局部变量这一例子来说,目前还没有编译器会自动地对未初始化的变量进行初始化。某些编译器在调试环境下确实会自动初始化局部变量,例如使用 MSVC 工具链在调试模式下构建程序时,MSVC 会使用 0xcc 填充所有未初始化的局部变量的对象表示(这一行为由 /GZ 编译选项控制),但在优化构建模式下这一点就无法保证了。在错误检测机制方面,可以包括编译时检测以及运行时检测两个部分。在编译时,所有的主流编译器当发现程序中可能存在读未初始化的局部变量的行为时,都会给出相应的警告。在运行时,目前也存在一些动态检测方案可以检测程序中存在的读未初始化局部变量的行为,例如 Valgrind 以及 MemorySanitizer 等。但是,这些动态检测方案都不是轻量级的检测方案,其结果虽然准确,但动辄数倍的性能开销使之几乎无法应用于优化构建场景中。

总结

C++ 中的未定义行为饱受诟病,特别是随着近年来诸如 Rust 等“安全”编程语言的出现,C++ 因其晦涩和不安全的刻板印象数次被推向舆论的风口浪尖。消灭 C++ 中的未定义行为,让 C++ 变得更“安全”和“可预测”,是提升现代 C++ 口碑、造福广大开发者的重要工作。Erroneous behavior 是消灭未定义行为的一个工具,可以遇见在未来逐渐会有更多的未定义行为被 erroneous behavior 逐步取代。相比于未定义行为,erroneous behavior 具有更加稳定的可预测的程序行为,现代编译器、工具链以及运行时也会协助开发者对 erroneous behavior 进行诊断,帮助开发者写出正确、健壮而高效的程序。