C++ 程序的行为由 C++ 标准定义。但是,它没有完全描述行为,而是将其中一些行为悬而未决: 实现定义的、 未指定的和未定义的行为 。
- 实现定义的行为是由实现(即编译器或 CPU)决定的行为。一个例子是
sizeof(int)的结果。这个想法是 C++ 应该可以在广泛的平台上使用,并且能够适应他们各自的怪癖以更快地执行。需要符合要求的实现来记录其实际行为,以便程序员可以安全地依赖实现定义的行为 。他们只需要意识到这会使他们的程序不可移植。 - 未指定的行为也取决于实现,但不需要记录。因此,虽然编写具有未指定行为的代码不是错误,但我们不能依赖特定行为。一个例子是在调用
f(g(), h())时首先计算g()还是h()。 - 未定义的行为是指标准对实现完全没有要求的行为。与未指定行为的区别在于,如果程序执行以未定义的行为表现出来,则该程序员错误。一个例子是空指针的取消引用。
应该很清楚为什么 C++(以及在类似设计空间中运行的语言)需要实现定义的行为 :如果标准完全指定了所有内容,那么在需要模拟该行为的平台上性能会受到影响。出于类似的原因, 未指定的行为是必要的,但可以通过定义所有未指定的行为实现来删除该类别。
未定义的行为更具争议性。有些人认为应该删除它,因为它会导致危险的编译器优化;有些人将其与实现定义的行为混为一谈(“如果我知道硬件,就没有未定义的行为”)。虽然我同意第一点,并且删除一些未定义的行为可能是有意义的,但删除所有未定义的行为会使代码变得不可能。
未定义的行为是必不可少的
考虑以下标识函数:复制
int identity(int x) {
return x;
}
根据 C++ 标准,x 是一个对象,它有一个地址。但是,在程序集级别,x 的值是在没有地址的 CPU 寄存器中传递的。忽略任何优化,为了满足标准,编译器需要生成汇编代码,为 x 分配内存并将寄存器值存储在其中。返回需要从内存中加载值,然后将其放入结果寄存器中:复制
identity(int):
// function setup
push rbp
mov rbp, rsp
// allocate memory for x and store it
mov dword ptr [rbp - 4], edi
// load value of x from memory
mov eax, dword ptr [rbp - 4]
// return
pop rbp
ret
身份的未优化版本
这有点愚蠢——没有人关心 x 是否有地址;任何地方都没有运营商。 一个明智的优化是消除内存中的存储/加载并直接使用 mov eax、edi。那么你也不需要担心函数设置:复制
identity(int):
mov eax, edi
ret
身份的优化版本
在标准的假设规则下允许这种优化。身份的未优化版本和优化版本具有相同的可观察行为。即使在优化的版本中 ,x 不再有地址,程序员无法观察到,我们仍然在遵守。
请注意,优化是否由优化器标志启用(如 clang 的情况),或者编译器是否直接生成此类程序集,与讨论无关。严格来说,优化的程序集不是函数的 1:1 表示,但没有人能说出来,这并不重要。
但是,如果没有未定义的行为 ,我们将能够判断优化发生了!
假设我们有一个 main 函数,如下所示:
int main() {
std::jthread thread([]{ *reinterpret_cast<int*>(0x12345678) = 42; }); // don't mind me
return identity(0);
}
在后台执行其他作时调用标识
在调用 identity 的同时,我们还在执行一个在地址 0x12345678 存储 42 的后台线程。如果没有额外的知识,这是 C++ 中未定义的行为 。因此,让我们假设我们正在编写一个 C++ 版本,其中所有未定义的内容都是实现定义的 。
如果恰好 0x12345678 是未优化版本中 x 函数参数的地址,恰好后台线程在身份的实现中存储和 load 之间存储了 42,那么优化改变了程序行为!未优化的程序返回 42,而优化的程序返回 0。
是的,这个例子是人为的(这是一个更现实的例子 ,它不依赖于后台线程和凭空提取数字),没有人应该编写这样的代码,但这不是重点: 像这样的代码的可能存在完全禁用了许多关键的优化。 优化器不允许更改程序语义,因此需要进行整个程序分析来确定是否正在发生此类恶作剧。
如果是实现定义的 ,则 *reinterpret_cast<int*>(0x12345678) = 42 需要优化的实现可以将其定义为“将 42 存储到该地址中存储的任何内容,这可能会触发访问冲突,并且其行为可能会随着优化而改变”。然而,这只是一种拼写未定义行为的复杂方式。出于营销原因,不将未定义的行为称为“未定义的行为”可能是有意义的,但它本质上仍然是一回事。
因此,如果一种语言具有不受限制的指针,并且实现想要进行加载/存储消除,则规范需要为其引入(类似) 未定义的行为 。出于类似的原因,基本未定义行为的列表还包括竞争条件和可能的更多作。
未定义的行为太过分了
然而,并非所有未定义行为的情况都是必不可少的,有些是有问题的。考虑有符号整数溢出:由于它是未定义的,编译器可以对表达式进行一些算术简化 ,例如 x * c1 / c2 可以优化为 x * c1_div_c2(如果 c1 能被 c2 整除)。
另一方面,它也可能导致突然的无限循环:复制
void foo(int* ptr) {
for (auto i = 0; i != 5; ++i) {
std::printf("%d\n", i * 1000 * 1000 * 1000);
*ptr++ = i * 1000 * 1000 * 1000;
}
}
由于整数溢出,优化器可以变成无限循环的有限循环
在这里,GCC 将 for (auto i = 0; i != 5; ++i) 转换为 for (auto i = 0; i != 5'000'000'000; i += 1'000'000'000) ,并实现循环条件始终为 true,从而产生无限循环。
这并不理想。
使整数溢出实现定义而不是未定义可能是一个可以接受的权衡:虽然算术简化很好,但它们不是必需的,因为程序员可以在需要时手动完成它们(与加载/存储消除不同!无符号整数无论如何都没有它们。
但是,意外的整数溢出是您要捕获的错误。将其设置为未定义的优点是您可以使用 -fsanitize=undefined 进行编译,并且您将对整数溢出进行运行时检查。如果它是实现定义的,则 C++ 标准不再将其视为错误。
同样,读取未初始化的变量也是未定义的行为 。可以通过要求零初始化或指定获取未指定的值来将其更改为明确定义 ,但此更改可能会隐藏程序错误。我们希望防止危险的编译器优化,同时将其保留为错误。
错误行为
这就是 P2795 提出的错误行为背后的想法。
错误行为:建议实现诊断的定义良好的行为(包括实现定义的行为和未指定的行为)第 2795 页
至关重要的是,执行错误行为是一个错误,但结果是实现定义的, 而不是未定义的。这样,我们就两全其美:实现可以生成对其平台最有效的代码,用户可以使用工具来捕获错误,并且不允许优化器更改程序行为。
对于整数溢出示例,编译器仍然可以发出有关整数溢出的诊断警告,但不允许将程序转换为无限循环。自动变量仍然不需要初始化,但编译器可以选择使用特定模式初始化它来捕获错误。
值得注意的是, 错误行为允许在调试模式下插入运行时检查并在发布中生成最佳程序集的实现。
我认为大多数未定义行为的情况应该是错误行为 。
- 未初始化的变量读取
- 有符号整数溢出、无法表示的算术转换、无效的位移
- 在抽象基构造函数中调用纯虚函数
更好的语言设计
从 C 和 C++ 的非标准行为历史中学习,更细致的方法似乎很有用。
- 用于未检查的内存作的最小未定义行为集,以实现基本优化。
- 具有可检查前置条件(整数溢出、空指针取消引用等)的语言作的各种错误行为 。如有疑问,错误行为应优先于未定义行为 。
- 实现定义的平台相关功能的行为。
这样,我们仍然拥有积极优化的 C++ 编译器的大部分性能,同时保证程序行为。