C++标准允许未初始化的布尔值导致程序崩溃吗?

571
我知道在C++中的{{未定义行为}}可以让编译器做任何它想做的事情。然而,我遇到了一个崩溃问题,这让我感到惊讶,因为我认为代码足够安全。
在这种情况下,真正的问题只发生在使用特定编译器的特定平台上,并且仅在启用优化时才会出现。
我尝试了几种方法来复制问题并将其简化到最大程度。这里有一个名为{{Serialize}}的函数的提取,它将接受一个布尔值参数,并将字符串{{true}}或{{false}}复制到现有目标缓冲区中。
如果这个函数在代码审查中,那么没有办法知道它实际上是否会在布尔参数是未初始化值的情况下崩溃。
// Zero-filled global buffer of 16 characters
char destBuffer[16];

void Serialize(bool boolValue) {
    // Determine which string to print based on boolValue
    const char* whichString = boolValue ? "true" : "false";

    // Compute the length of the string we selected
    const size_t len = strlen(whichString);

    // Copy string into destination buffer, which is zero-filled (thus already null-terminated)
    memcpy(destBuffer, whichString, len);
}

如果使用clang 5.0.0 +优化执行此代码,可能会导致崩溃。

我预期的三元运算符 boolValue ? "true" : "false" 看起来很安全,我假设:"不管 boolValue 中有什么垃圾值都没关系,因为它最终会评估为 true 或 false。"

我已经设置了一个Compiler Explorer example,显示了反汇编中的问题,这是完整的示例。注意:为了重现问题,我发现能够工作的组合是使用带有 -O2 优化的Clang 5.0.0。

#include <iostream>
#include <cstring>

// Simple struct, with an empty constructor that doesn't initialize anything
struct FStruct {
    bool uninitializedBool;

   __attribute__ ((noinline))  // Note: the constructor must be declared noinline to trigger the problem
   FStruct() {};
};

char destBuffer[16];

// Small utility function that allocates and returns a string "true" or "false" depending on the value of the parameter
void Serialize(bool boolValue) {
    // Determine which string to print depending if 'boolValue' is evaluated as true or false
    const char* whichString = boolValue ? "true" : "false";

    // Compute the length of the string we selected
    size_t len = strlen(whichString);

    memcpy(destBuffer, whichString, len);
}

int main()
{
    // Locally construct an instance of our struct here on the stack. The bool member uninitializedBool is uninitialized.
    FStruct structInstance;

    // Output "true" or "false" to stdout
    Serialize(structInstance.uninitializedBool);
    return 0;
}

问题出在优化器上:它足够聪明,能够推断出字符串“true”和“false”的长度只相差1。因此,它并没有真正计算长度,而是使用布尔值本身的值,理论上应该是0或1,并且运行如下:
const size_t len = strlen(whichString); // original code
const size_t len = 5 - boolValue;       // clang clever optimization

虽然这样做“聪明”,但我的问题是:C++标准是否允许编译器假设bool只能具有内部数字表示为'0'或'1'并以这种方式使用它?还是这是一种实现定义的情况,在这种情况下,实现假定所有的bool都只包含0或1,任何其他值都属于未定义行为领域?

243
这是一个很好的问题。这充分说明了未定义行为不仅仅是一个理论上的问题,当人们说任何未定义行为都可能导致各种结果时,这些结果会让人相当惊讶。人们可能认为未定义行为仍以可预测的方式表现出来,但现在随着现代优化器的出现,这完全不是真的。OP花时间创建了一个MCVE,对问题进行了彻底的调查,检查了汇编代码,并提出了一个清晰、直接的问题。再没有更多可以要求的了。 - John Kugelman
9
请注意,“非零为真”是一个规则,适用于布尔运算,包括“将值赋给布尔变量”(具体情况下可能隐式调用static_cast<bool>())。但这并不是关于编译器选择的bool内部表示的要求。 - Euro Micelli
2
评论不适合进行长时间的讨论;此对话已被移至聊天室 - Samuel Liew
4
相关的是,这是一个“有趣”的二进制不兼容性问题。如果你有一个 ABI A,在调用函数之前会用零填充值,但编译函数时假设参数已经被零填充,以及一个相反的 ABI B(不填充零,但不假定参数已经被零填充),它们在大多数情况下都能工作,但如果一个使用 B ABI 的函数调用一个使用 A ABI 的函数,而该函数接受一个“小”的参数,就会出现问题。我IRC在 x86 上使用 clang 和 ICC 时会出现这种情况。 - TLW
2
@TLW:虽然标准并不要求实现提供任何调用或被外部代码调用的方式,但为那些相关的实现指定这样的方式会很有帮助(对于那些不相关的细节可以忽略这些属性)。 - supercat
显示剩余12条评论
6个回答

321
是的,ISO C++允许(但不要求)实现做出这个选择。
但请注意,ISO C++允许编译器故意生成会导致程序崩溃的代码(例如,使用非法指令),如果程序遇到未定义行为(UB),这样可以帮助您找到错误(或者因为它是DeathStation 9000。严格符合标准并不足以使C++实现对任何实际目的有用)。因此,ISO C++允许编译器生成即使在类似的读取未初始化的uint32_t的代码上也会崩溃的汇编代码(出于完全不同的原因)。即使这是一个具有固定布局且没有陷阱表示的类型所必需的。(请注意,C和C++有不同的规则;在C中,未初始化的变量具有不确定的值,可能是陷阱表示,但在C++中,读取未初始化的变量完全是未定义行为。不确定是否有额外的规则适用于C11的_Bool,这可能允许与C++相同的崩溃行为。)
这是一个关于实际实现如何工作的有趣问题,但请记住,即使答案不同,你的代码仍然是不安全的,因为现代C++并不是可移植的汇编语言版本。
你正在编译x86-64 System V ABI,该规范指定寄存器中的函数参数bool由位模式false=0true=1表示,位于寄存器的低8位1。在内存中,bool是一个1字节的类型,其整数值必须为0或1。
ABI(Application Binary Interface)是编译器为同一平台上的代码所达成的一组实现选择,以便它们可以调用彼此的函数,包括类型大小、结构布局规则和调用约定。根据ISO C++标准,违反ABI的对象表示被称为“陷阱表示”,尽管CPU在执行字节指令时并不直接陷入。只有在违反软件假设后才会导致后续故障。在ISO C17的6.2.6.1 #5中,它进一步指出某些对象表示不需要表示对象类型的值。如果存储的对象值具有这样的表示,并且由不具有字符类型的lvalue表达式读取,则行为是未定义的...并且继续说它被称为陷阱表示。我不知道ISO C++中是否存在相同的语言。

ISO C++没有明确规定,但这种ABI决策是普遍的,因为它使得bool->int的转换廉价(只需零扩展)。我不知道有任何ABI不允许编译器假设对于任何架构(不仅仅是x86),bool的值为0或1。它允许像!mybool这样的优化,通过xor eax,1来翻转最低位:任何可以在单个CPU指令中翻转位/整数/bool的可能代码。或者将a&&b编译为位与操作对于bool类型。一些编译器实际上确实利用了这一点编译器中将布尔值作为8位处理,操作是否低效?

通常情况下,仿佛规则允许编译器利用在目标平台上成立的事实,因为最终的结果将是实现与C++源代码相同的外部可见行为的可执行代码。(在所有未定义行为对实际“外部可见性”有限制的情况下,不包括调试器,但在一个良好形式/合法的C++程序的另一个线程中是可见的。)
编译器绝对可以充分利用ABI保证来生成代码,并将像你发现的代码优化为strlen(whichString)变为5U - boolValue。(顺便说一句,这种优化方法相当巧妙,但可能相对于分支和内联memcpy作为立即数据存储来说有点短视。)
或者编译器可以创建一个指针表,并使用bool的整数值作为索引,再次假设它是0或1。(这种可能性正是@Barmar的回答所建议的。)
你的启用优化的__attribute((noinline))构造函数导致clang只是从堆栈中加载一个字节作为uninitializedBool。它在main中为对象腾出空间,使用push rax(比sub rsp, 8更小且出于各种原因同样高效),所以无论在进入main时AL中有什么垃圾值,它都会用作uninitializedBool的值。这就是为什么你实际上得到的值不仅仅是05U - 随机垃圾值很容易变成一个大的无符号值,导致memcpy进入未映射的内存。目标是静态存储而不是堆栈,所以你不会覆盖返回地址或其他东西。
其他实现可能会做出不同的选择,例如false=0true=任何非零值。然后,Clang可能不会为这个特定的未定义行为实例生成导致崩溃的代码。(但如果它愿意的话,仍然可以这样做。)我不知道有哪些实现会选择与x86-64不同的东西来表示bool,但C++标准允许许多在任何类似当前CPU的硬件上都没有人做或者想做的事情。
ISO C++没有规定当您检查或修改bool的对象表示时会得到什么结果。(例如,通过将bool复制到unsigned char中进行memcpy,这是允许的,因为char*可以别名任何类型。而且unsigned char保证没有填充位,所以C++标准确实允许您以十六进制转储对象表示而没有任何未定义行为。将指针强制转换以复制对象表示与赋值char foo = my_bool是不同的,当然,所以不会发生布尔化为0或1,并且您将获得原始对象表示。)
你用`noinline`在这个执行路径上“部分”地将UB(未定义行为)从编译器中“隐藏”起来了。即使它不进行内联,但是跨过函数定义的互操作优化仍然可能生成一个依赖于另一个函数定义的函数版本。(首先,clang正在生成一个可执行文件,而不是可能发生符号重定位的Unix共享库。其次,定义在`class{}`定义内部,因此所有的翻译单元必须具有相同的定义,就像使用`inline`关键字一样。)
因此,编译器可以只发出`ret`或`ud2`(非法指令)作为`main`的定义,因为从`main`顶部开始的执行路径无法避免地遇到未定义行为。(如果编译器决定沿着非内联构造函数的路径进行编译,它可以在编译时看到这一点。)
任何遇到未定义行为(UB)的程序在其整个存在期间都是完全未定义的。但是在一个从未实际运行的函数或if()分支中的UB不会破坏程序的其余部分。实际上,这意味着编译器可以决定发出非法指令、ret指令,或者不发出任何指令并跳转到下一个块/函数,对于在编译时可以证明包含或导致UB的整个基本块。
实际上,GCC和Clang有时会在UB上发出ud2指令,而不是尝试为没有意义的执行路径生成代码。或者在非void函数的末尾掉落的情况下,gcc有时会省略ret指令。如果你认为"我的函数将返回RAX中的任何垃圾",那你大错特错了。现代C++编译器不再将语言视为可移植的汇编语言。你的程序必须是有效的C++,不能对一个独立的非内联版本的函数在汇编中的样子做出假设。
另一个有趣的例子是为什么在AMD64上对未对齐的mmap内存访问有时会导致段错误?。x86不会在未对齐的整数上出错,对吧?那么为什么一个错误对齐的uint16_t*会成为一个问题呢?因为alignof(uint16_t) == 2,违反了这个假设会导致使用SSE2进行自动向量化时发生段错误。
另请参阅《每个C程序员都应该了解的未定义行为》第1/3部分,这是一位clang开发者撰写的文章。
关键点:如果编译器在编译时注意到了未定义行为,即使针对的是一个任意位模式都是有效对象表示的ABI,它也可以“打破”(生成令人惊讶的汇编代码)导致未定义行为的代码路径。
对于程序员的许多错误,尤其是现代编译器警告的事情,应该期望完全的敌意。这就是为什么你应该使用-Wall并修复警告。C++并不是一种用户友好的语言,即使在目标编译平台上的汇编语言中是安全的,C++中的某些内容可能仍然是不安全的。(例如,有符号溢出在C++中是未定义行为,编译器会假设它不会发生,即使在编译为2的补码x86时,除非你使用clang/gcc -fwrapv。)
编译时可见的未定义行为总是危险的,而且很难确定(通过链接时优化)你是否真的将未定义行为隐藏在编译器之外,从而可以推断出它将生成什么样的汇编语言。
不要太夸张,通常编译器确实会让你做一些事情,并生成你期望的代码,即使存在未定义行为。但也许将来会出现问题,如果编译器开发人员实现了一些优化,获得了关于值范围的更多信息(例如,一个变量是非负的,可能允许它在x86-64上优化符号扩展为零扩展)。例如,在当前的gcc和clang中,执行tmp = a+INT_MIN不会将a<0优化为始终为假,只会优化tmp始终为负数。(因为在这个二进制补码目标上,INT_MIN + a=INT_MAX是负数,而a不能比这个更高。)
所以gcc/clang目前不会回溯以推导计算输入的范围信息,只会基于无符号溢出的假设对结果进行回溯:在Godbolt上的示例。我不知道这种优化是出于用户友好性的考虑还是其他原因而故意“忽略”了。
请注意,实现(也称为编译器)可以定义ISO C++未定义的行为。例如,所有支持Intel的内部函数(如_mm_add_ps(__m128, __m128)用于手动SIMD向量化)的编译器必须允许形成未对齐的指针,即使您不对它们进行解引用,这在C++中是未定义的行为。__m128i _mm_loadu_si128(const __m128i *)通过使用未对齐的__m128i*参数而不是void*char*来进行未对齐的加载。在硬件SIMD向量指针和相应类型之间进行reinterpret_cast是否是未定义行为? GNU C/C++还定义了对负有符号数进行左移的行为(即使没有使用-fwrapv),与正常的有符号溢出UB规则分开。这是ISO C++中的UB(这是ISO C++中的UB),而有符号数的右移是实现定义的(逻辑右移还是算术右移);优质的实现会在具有算术右移的硬件上选择算术右移,但ISO C++没有指定。这在GCC手册的整数部分中有详细说明,同时还定义了C标准要求实现以某种方式定义的实现定义行为。
编译器开发人员确实关心实现质量问题;他们通常并不是有意制作敌对的编译器,但利用C++中的所有UB陷阱(除了他们选择定义的陷阱)来进行更好的优化有时几乎是无法区分的。
脚注1:调用方必须忽略高56位的垃圾值,这在比寄存器窄的类型中是常见的做法。
(其他ABI在这里做出了不同的选择。有些要求窄整数类型在传递给函数或从函数返回时,必须进行零扩展或符号扩展以填充寄存器,比如MIPS64和PowerPC64。请参阅{{link1:这个x86-64答案的最后一节,与那些早期的ISA进行比较。))
例如,调用方可能在RDI中计算了`a & 0x01010101`并将其用于其他用途,然后调用`bool_func(a&1)`。调用方可以优化掉`&1`,因为它已经在`and edi, 0x01010101`的过程中对低字节进行了操作,并且它知道被调用方需要忽略高字节。
或者,如果第三个参数传递的是一个布尔值,那么调用者可能会为了代码大小而使用mov dl, [mem]来加载它,而不是movzx edx, [mem],这样可以节省一个字节,但会导致对RDX的旧值产生错误依赖(或其他部分寄存器效果,取决于CPU型号)。或者对于第一个参数,可以使用mov dil, byte [r10]来代替movzx edi, byte [r10],因为两者都需要使用REX前缀。
这就是为什么clang在Serialize中发出movzx eax, dil,而不是sub eax, edi。(对于整数参数,clang违反了这个ABI规则,而是依赖于gcc和clang的未记录行为来将窄整数零扩展或符号扩展为32位。在x86-64 ABI中,将32位偏移添加到指针时是否需要进行符号或零扩展?因此,我很想看到它对bool不做同样的处理。)
脚注2: 在分支之后,你只需要一个4字节的mov-immediate,或者一个4字节+1字节的存储。长度在存储宽度和偏移量中是隐含的。
另一方面,glibc的memcpy将执行两个4字节的加载/存储,重叠取决于长度,因此整个过程确实不需要在布尔值上进行条件分支。请参见glibc的memcpy/memmove中的L(between_4_7): block 。或者,至少在memcpy的分支中,选择一个块大小。
如果进行内联,你可以使用2个mov-immediate + cmov和一个条件偏移量,或者可以将字符串数据保留在内存中。
或者,如果针对英特尔Ice Lake进行调优(使用快速短REP MOV功能),实际上使用rep movsb可能是最佳选择。glibc的memcpy在具备该功能的CPU上,可能会开始使用rep movsb来处理小尺寸的数据,从而节省了很多分支操作。

检测未定义行为和未初始化值的工具

在gcc和clang中,您可以使用-fsanitize=undefined编译选项,添加运行时的仪器,以便在运行时发生未定义行为时发出警告或报错。然而,这不会捕捉到未初始化的变量。(因为它不会增加类型大小以为“未初始化”位腾出空间)。

请参阅https://developers.redhat.com/blog/2014/10/16/gcc-undefined-behavior-sanitizer-ubsan/

要找到未初始化数据的使用情况,可以使用clang/LLVM中的Address Sanitizer和Memory Sanitizer。https://github.com/google/sanitizers/wiki/MemorySanitizer展示了使用clang -fsanitize=memory -fPIE -pie来检测未初始化内存读取的示例。如果您在编译时不进行优化,所有变量的读取都将从内存中加载到汇编中,这可能是最好的方法。他们在一个不会被优化的情况下展示了在-O2下使用它的情况。我自己还没有尝试过。(在某些情况下,例如在对数组求和之前未初始化累加器,clang -O3会生成从未初始化的向量寄存器中求和的代码。因此,在优化时,可能没有与UB相关的内存读取。但是-fsanitize=memory会改变生成的汇编代码,并可能导致对此进行检查。)
它将容忍未初始化内存的复制,以及与之进行的简单逻辑和算术操作。总的来说,MemorySanitizer会在内存中静默跟踪未初始化数据的传播,并在代码分支根据未初始化值被执行(或不被执行)时报告警告。
MemorySanitizer实现了Valgrind(Memcheck工具)中的部分功能。
对于这种情况,它应该能够工作,因为对于使用未初始化内存计算得到的长度调用glibc的memcpy函数将会(在库内部)基于长度进行分支。如果它内联了一个完全无分支的版本,只使用cmov、索引和两个存储操作,可能就无法工作。
Valgrind的memcheck也会寻找这种问题,但如果程序只是简单地复制未初始化数据,它不会抱怨。但它表示它会检测到“条件跳转或移动依赖于未初始化的值”,以尝试捕捉任何依赖于未初始化数据的外部可见行为。
或许不对仅仅一个负载进行标记的背后的想法是,结构体可以有填充,使用宽向量加载/存储复制整个结构体(包括填充)即使每个成员只写入一次也不会出错。在汇编级别上,关于哪些是填充和哪些实际上是值的信息已经丢失。

14
此外,这也说明了为什么在设计语言C和C++时引入了UB特性/bug:因为它为编译器提供了恰好这种自由,从而使现代编译器能够执行高质量的优化,使得C/C++成为高性能中级语言。 - The_Sympathizer
7
C++编译器开发人员和程序员之间,为了写出有用的程序而进行的战争仍在继续。这个回答对于解答问题非常详尽,同时也可以直接作为静态分析工具厂商的有力广告文案。 - davidbak
6
@The_Sympathizer: UB这一概念被引入是为了让实现能够以最有用的方式来满足其客户的需求。它并不意味着所有行为都应该被认为同样有用。 - supercat
3
在某些实现中,许多形式的未定义行为都会因设计而导致非常高的崩溃率(有时达到100%)。可靠地捕获各种错误操作通常会带来显著的运行时性能损失,但如果例如正在为一座公路桥梁进行负载计算,保证溢出不能导致程序产生错误结果可能值得增加执行时间,而标准的作者也不希望禁止这样的实现。 - supercat
3
@VioletGiraffe - 在C++中,“未定义行为”的定义非常宽泛,这使得许多程序员时不时地陷入困境。我也曾被它抓住过几次。我希望我能像你一样总是能够完美地编写符合C++标准的程序,但事实上我做不到。公平地说,我曾经和一些顶尖的人一起工作,他们也做不到。 - davidbak
显示剩余9条评论

60
编译器允许假定作为参数传递的布尔值是有效的布尔值(即已初始化或转换为truefalse)。true值不必与整数1相同,实际上可以有各种表示truefalse的方式 - 但参数必须是这两个值之一的某个有效表示,其中“有效表示”是实现定义的。
因此,如果您未初始化布尔值,或者通过某些不同类型的指针覆盖它,那么编译器的假设将是错误的,并且会导致未定义行为。你被警告了:
  

50)按照本国际标准所描述的使用布尔值的方式产生“未定义”行为,例如检查未初始化的自动对象的值,可能会导致其行为表现为既不是true也不是false。(第6.9.1节第6段注释脚注,基本类型)


11
"true 的值不一定和整数1相同" 这种说法有点误导人。确实,实际的比特模式可能是其他值,但当隐式转换/提升时(你只能看到 true / false 之外的值),true 总是 1false 总是 0。当然,这样的编译器也无法使用该编译器尝试使用的技巧(利用 bool 的实际比特模式只能为 01),因此对于 OP 的问题来说,这有点无关紧要。 - ShadowRanger
4
你可以直接检查对象的表示形式。 - T.C.
7
我的观点是实现负责。如果它将“true”的有效表示限制为位模式“1”,那就是它的权利。如果它选择其他一些表示,那么它确实不能使用这里提到的优化。如果它选择了特定的表示方式,那么它可以。它只需要内部一致即可。你可以通过将布尔值复制到字节数组中来检查它的表示形式;这不是未定义行为(但它是实现定义的)。 - rici
3
是的,优化编译器(即实际的 C++ 实现)经常会生成依赖于布尔值具有“0”或“1”位模式的代码。它们不会每次从内存中读取一个布尔值(或者寄存器中保存的函数参数)时都重新将其转换为布尔类型。这就是这个答案所说的。例如,gcc4.7+ 可以将返回类型为 bool 的函数中的 return a||b 优化为 or eax, edi,而 MSVC 可以将 a&b 优化为 test cl, dl。x86 的 test 是一种按位与运算,因此如果 cl=1 并且 dl=2,则 test 按照 cl&dl = 0 设置标志位。 - Peter Cordes
7
关于“未定义行为”的关键是编译器可以对它做出更多的推断,例如假设一个导致访问未初始化值的代码路径根本就没有被执行过,因为确保这一点正是程序员的责任。因此,“未定义行为”不仅仅是指底层值可能与零或一不同的可能性。 - Holger
显示剩余6条评论

53

该函数本身是正确的,但在您的测试程序中,调用该函数的语句会使用未初始化变量的值导致未定义行为。

错误出现在调用函数中,可以通过代码审查或静态分析调用函数来检测。使用您提供的编译器探索链接,gcc 8.2编译器确实可以检测到此错误。(也许您可以向clang报告此问题,以便其能够发现问题)。

未定义行为意味着任何事情都可能发生,包括程序在触发未定义行为事件后几行代码之后崩溃。

NB。“未定义行为是否会导致_____?” 的答案总是“是”。这就是未定义行为的定义所在。


2
第一个子句是真的吗?仅仅复制一个未初始化的 bool 是否会触发 UB? - Joshua Green
12
@JoshuaGreen请参阅[dcl.init]/12:“如果评估产生不确定值,则除以下情况外,行为未定义:”(这些情况都没有针对bool的例外)。复制需要评估源。 - M.M
8
由于您可能会使用一些类型的无效值访问平台,从而导致硬件故障,这就是这种情况发生的原因。这些被称为“陷阱表示”。 - David Schwartz
8
Itanium虽然不太为人所知,但它是一种仍在生产中的CPU,具有陷阱值,并且至少有两个半现代的C++编译器(Intel/HP)。它字面上拥有布尔值的“真”、“假”和“非存在”值。 - MSalters
3
相反,回答“标准是否要求所有编译器按某种方式处理某些内容”的通常是“不需要”,尤其是在一些显然任何高质量编译器都应该这样做的情况下;某些事情越显而易见,标准的作者就越没必要明确说明。 - supercat
显示剩余3条评论

23

布尔类型仅允许保存内部用于表示 truefalse 的实现依赖值。生成的代码可以假定它只会保存这两个值中的一个。

通常,实现会使用整数 0 表示 false,使用整数 1 表示 true,以简化 boolint 之间的转换,并使得 if (boolvar) 生成与 if (intvar) 相同的代码。在这种情况下,我们可以想象分配语句中三元表达式生成的代码将使用该值作为索引来访问指向这两个字符串的指针数组,例如:

// the compile could make asm that "looks" like this, from your source
const static char *strings[] = {"false", "true"};
const char *whichString = strings[boolValue];

如果boolValue未初始化,则它实际上可以保存任何整数值,这将导致访问strings数组范围之外的内容。

1
@SidS 谢谢。理论上,内部表示可能与它们转换为/从整数的方式相反,但那将是不合适的。 - Barmar
3
我只是使用数组来展示生成的代码可能等同于什么,并不建议任何人真的编写那样的代码。 - Barmar
1
@Remz 将 bool 转换为 int,使用 *(int *)&boolValue 并打印出来以进行调试,看看在崩溃时它是否是除了 01 之外的任何值。如果是这种情况,那么基本上可以确认编译器将内联 if 优化为数组,这就解释了为什么会崩溃。 - Havenard
2
@Havenard,int 可能比 bool 更大,所以这并不能证明什么。 - Sid S
2
@MSalters:std::bitset<8>不能为我所有不同的标志提供漂亮的名称。根据它们的用途,这可能很重要。 - Martin Bonner supports Monica
显示剩余9条评论

16

总的来说,你在问C++标准是否允许编译器假定bool只能有内部数字表示为'0'或'1'并以这种方式使用它。

标准对于bool的内部表示没有任何规定。它只定义了将bool转换为int(或反之)时会发生什么。主要是因为这些整数转换(以及人们相当依赖它们),编译器会使用0和1,但不必须(尽管它必须遵守它使用的任何低级ABI的限制)。

因此,编译器在看到bool时可以认为该bool包含“true”或“false”比特模式之一,并执行任何它想要的操作。因此,如果truefalse的值分别为1和0,则编译器确实可以将strlen优化为5- <boolean value>。其他有趣的行为也可能存在!

正如在这里反复提到的,未定义行为具有未定义的结果,包括但不限于:

  • 代码按预期工作
  • 代码在随机时间失败
  • 代码根本没有运行。

请参阅每个程序员都应该了解的关于未定义行为的内容


3
C++标准允许编译器假定bool类型只有内部数值表示为'0'或'1',并以这种方式使用它吗?
是的,确实如此。如果有用的话,下面是另一个真实世界的例子。
我曾经花费数周时间在大型代码库中跟踪一个晦涩的bug。有几个方面使它具有挑战性,但根本原因是类变量的一个未初始化的布尔成员。
有一个测试条件包含了这个成员变量的复杂表达式:
if(COMPLICATED_EXPRESSION_INVOLVING(class->member)) {
    ...
}

我开始怀疑这个测试在应该时没有评估出“true”。我不记得是否不方便在调试器下运行代码,或者我不信任调试器,还是其他原因,但我采取了一些调试打印输出的野蛮技巧来增强代码:

printf("%s\n", COMPLICATED_EXPRESSION_INVOLVING(class->member) ? "yes" : "no");

if(COMPLICATED_EXPRESSION_INVOLVING(class->member)) {
    printf("doing the thing\n");
    ...
}

当代码打印出"no"后又接着打印了"doing the thing",我感到非常惊讶。

经过查看汇编代码,发现编译器(gcc)有时候通过将布尔成员与0进行比较来测试它,而其他情况下则使用测试最低有效位的指令。当出现错误时,未初始化的布尔变量恰好包含值2。因此,在机器语言中等效于以下测试:

if(class->member != 0)

成功了,但是测试等效于

if(class->member % 2 != 0)

失败了。 布尔变量在同一时间内既是true又是false!如果这不算未定义行为,我不知道什么才是未定义行为!


必须是量子比特!欢迎来到21世纪! :-) - Alexis Wilke

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接