真的补集是真的,除非它是假的
在C++中,~true的结果是true,这并非大多数人所预期。本文深入探讨其原因,涵盖无作用域枚举的整型提升、按位取反,以及该行为不再有良好定义的情形。
中文
复制
2026年10月1日 · Matthew Taylor | 编辑
目录
我最近看了 P4313R1,这是一篇标准提案,为枚举添加了一组位掩码操作,通过一个 C++26 注解来选择启用。核心思路是——用提案里的例子来说——对于下面这样的代码:
enum class [[=std::bitmask_type]] Permission {
None = 0,
Read = 1 << 0,
Write = 1 << 1,
Execute = 1 << 2,
};
copy
[[=std::bitmask_type]] 注解会自动为 Permission 赋予一组可用的位运算,这样你作为用户就不必自己把它们写出来。这让我想到 C++ 整数运算里那片阴暗的角落。对任何整型,C++ 编译器都会乐于把位运算当作内建操作来算。这一点也延伸到我们通常不当作整数看的类型,比如 wchar_t、UTF 字符类型 char8_t 到 char32_t,以及 bool。但这里发生的事情比表面上要多一点。因为当你试图执行像 a | b 这样的运算,而 a 和 b 的类型是小于 int 的整型时,语言并不会直接对 a 和 b 的位模式做运算。它们要经历整型提升——先被提升为 int 作为中间状态,两个 int 的位模式在这个状态下合并,然后结果返回给你,仍然是 int。1 看下面这段:
//Two shorts
constexpr short perm_A {1 << 0};
constexpr short perm_B {1 << 1};
//And the result type of running a bitwise operation on them is int
static_assert(std::same_as<decltype(perm_A | perm_B), int>);
copy
大多数情况下这没什么危害——如果你把上面的 perm_A | perm_B 转回 short,多余的字节会被截掉,剩下的 short 所持有的值,与你直接把 perm_A 和 perm_B 当作 short 合并所得的值完全一致。而如果你想确保类型始终一致,避开隐式窄化转换那些阴险的 bug,可以养成习惯,把位运算的结果 static_cast 回它原本的类型。不过这里的关键在于,整型提升不是可选项。与 C++ 的大多数其他领域不同,开发者没有选择权——这些运算中的类型不可避免地会被提升。
让这件事对 bool 构成隐患的第二个原因,是布尔转换。这是一种特殊情况:它不截断,而是显式地把零转换成 false、把非零值转换成 true。对于这些非零值,之前存储的位模式会被丢弃,替换成 true,其整数值为 1。所以,如果我们写这样一段代码:
int x{10};
bool b{static_cast<bool>(x)};
copy
再看生成的汇编(这里用的是未优化的 x86-64 gcc 16.2):
mov DWORD PTR [rbp-4], 10 ;Store the value of 10
cmp DWORD PTR [rbp-4], 0 ;Then compare to 0, set ZF if x is zero
setne al ;Write 1 to AL if ZF is clear
mov BYTE PTR [rbp-5], al ;Store the result
copy
标准(具体是 [
](https://eel.is/c++draft/conv.bool))在把值转换成 bool 时,依据的是 bool 唯一可能持有的值——true 和 false,而不管当初是用什么位模式造出它们的。
综合起来 #
这些都说清楚了,接下来讲讲当你试图求 ~true 的值、再把结果转换成 bool 时会发生什么:
- 值
true被提升为int,值为1。 1作为int的补码被算成-2,因为从 C++20 起就要求采用二进制补码行为。-2的值随后被向下转换回bool,经过布尔转换,由于-2非零,变成true。
就是这样,true 的补码是 true,用 C++ 写出来就是 static_cast<bool>(~true) == true。
这就把我们带回了 enum。enum 可以用任何整型作为其底层类型,包括我们的老朋友 bool。那我们就定义一个:
enum class [[=std::bitmask_type]] boolean : bool{
FALSE,
TRUE,
};
copy
再看看 P4313R1 里给出的按位取反运算符:
template<bitmask-like T> constexpr T operator~ (T lhs) noexcept {
return static_cast<T>(~to_underlying(lhs));
}
copy
到这里,这个运算符的运作方式应该已经很眼熟了——先把 enum 转换成它的底层类型(我们这里是 bool),如果该类型的等级低于 int 就施加整型提升,然后执行按位运算,最后转换回 enum 类型。正如我们所料:
static_assert(~boolean::TRUE == boolean::TRUE);
copy
Godbolt 在这里。
这一切都有可能成为这门语言里一个稍显令人困惑的边角案例。
当它为假时 #
这里有一个例外情况,会让问题更复杂。如果我们试着在 gcc 里跑同一个例子,会得到不同的结果:
static_assert(~boolean::TRUE == boolean::FALSE);
Godbolt 在此。
那么 gcc 究竟出了什么问题?如果把运算挪到运行时,定义两个以它为运行时值的函数:
enum class boolean : bool{
FALSE,
TRUE,
};
void f(int x, bool& out) {
out = static_cast<bool>(x);
}
void g(int x, boolean& out) {
out = static_cast<boolean>(x);
}
copy
再看汇编(同样是未优化的 x86-64),得到的是:
"f(int, bool&)":
push rbp
mov rbp, rsp
mov DWORD PTR [rbp-4], edi
mov QWORD PTR [rbp-16], rsi
cmp DWORD PTR [rbp-4], 0
setne dl ;same setne pattern from [conv.bool] earlier
mov rax, QWORD PTR [rbp-16]
mov BYTE PTR [rax], dl
nop
pop rbp
ret
"g(int, boolean&)":
push rbp
mov rbp, rsp
mov DWORD PTR [rbp-4], edi
mov QWORD PTR [rbp-16], rsi
mov eax, DWORD PTR [rbp-4]
and eax, 1 ;store the low bit in eax
mov rdx, QWORD PTR [rbp-16]
mov BYTE PTR [rdx], al ;and move al to the out-param
nop
pop rbp
ret
copy
可以看到,当类型没有写成 bool 时,gcc 会截断到最低位,而不是做布尔转换。任何偶数值截断后都会得到 boolean::FALSE,任何奇数值都会得到 boolean::TRUE。
这是 enum 类型独有的现象,gcc 直接处理 bool 时表现是一致的:
constexpr boolean b{boolean::TRUE};
static_assert(static_cast<bool>(~b) == false);
static_assert(static_cast<bool>(~true) == true);
static_assert(std::to_underlying(~b) == false);
static_assert(static_cast<bool>(~std::to_underlying(b)) == true);
copy
相比之下,Clang 和 MSVC 对这四种取反加转换组合的结果都求值为 true,这正是标准所要求的。完整的 godbolt 对比在这里。这似乎是 gcc 一个存在已久的符合性 bug。
无作用域枚举、整型提升与未定义行为 #
但标准中有一处,整型提升规则走得更远,在你做这些位掩码运算时引入了一条新的未定义行为入口。看这段代码:
enum nums{
zero,
one,
two,
three,
};
//This is implementation defined but holds on gcc and Clang.
static_assert(std::is_same_v<std::underlying_type_t<nums>, unsigned int>);
//nums uses an unsigned int as its base, therefore the entire domain of representable values is >= 0.
//So let's test this:
static_assert(~one >= 0); //FAILS
copy
Godbolt 在此
看诊断信息,gcc 给出的是:
<source>:15:20: error: static assertion failed
15 | static_assert(~one >= 0); //FAILS
| ~~~~~^~~~
• the comparison reduces to '(-2 >= 0)' copy
那么这里发生了什么?标准中讲整型提升的那段,[
](https://eel.is/c++draft/conv.prom#3),专门为没有固定底层类型的无作用域枚举类型开了一条特例,让它们在提升时与整型类型行为不同。如果全部取值都能存进 int,那就提升到该类型;否则尝试 unsigned int,再不行就按这个先有符号后无符号的模式对 long 和 long long 重复,直到找到合适的类型。但与普通整型不同的是,它只考虑可表示值的实际范围;因此一个底层类型为 unsigned int 的枚举会提升为 int,而不顾通常的转换等级——按那个等级,整型是不允许这样提升的。和前面一样,通过 ~one 调用内建运算符会不可避免地把它提升为 int,其补码按 -2 计算,然后在两个 int 之间做比较,结果失败。如果先转换成底层类型,这种不对称就暴露无遗——static_assert(~std::to_underlying(one) >= 0); 成功,而且成立得如此空洞,连 gcc 都会警告它多余。
但这只是战斗的一半,我们还得谈谈如何把位运算的结果转回枚举类型——UB 就是在这里悄悄溜进来的。无作用域枚举如果没有指定底层类型,它定义的有效值范围由枚举量决定,与编译器实际用来支撑它的类型范围无关。[$$ dcl.enum
```cpp enum small{ //Range of enumerators is 0..1, so value range is that of an unsigned, one-bit integer. a = 0, b = 1, }; enum medium{ //Range of enumerators is 0..7, so value range is that of an unsigned, three-bit integer c = 0, d = 4, e = 7 }; enum large{ //Range of enumerators is -1..100, so value range is that of a signed, eight-bit integer f = -1, g = 10, h = 100, }; ``` copy 尽管编译器很可能把这些枚举存成 `unsigned int` 和 `int`,但其中只有一部分可表示的值是合法的,即那个 M 位整数的值。[$$ expr.static.cast $$](https://eel.is/c++draft/expr.static.cast#8) 在标准中明确指出,把超出该范围的值转换到枚举类型属于未定义行为。那么,用上面例子中的枚举量: ```cpp static_cast<small>(1); //Well-defined static_cast<small>(2); //UB static_cast<medium>(3); //Well-defined: while not a value of an enumerator, it is within the range of an unsigned, three-bit integer static_cast<medium>(8); //UB static_cast<large>(-128); //Well-defined static_cast<large>(128); //UB ``` copy 这就是危险区。位运算符,尤其是 `operator~`,可能产生超出 `enum` 良定义值范围的值,而用户自定义运算符若试图把这个值转回枚举类型,就会触发 UB。 不过你可能会喊:前面那个 `~one` 的例子呢?我们看到它给出了 `-2`,这超出了 `nums` 的值范围。没错,但这里恰恰是整型提升救了我们——`one` 在计算其补码之前先被提升为 `int`,而且之后再也没有被转回去,所以没有触发 UB。只有当我们用用户自定义运算符把运算结果转回原来的枚举类型时,才会进入危险地带。 还有一点很重要:这种 UB 风险只适用于没有固定底层类型的无作用域“普通”枚举。所有有作用域枚举类型都有固定的底层类型,即使没有显式指定(这种情况下是 `int`);而所有显式指定了固定底层类型的无作用域 `enum` 类型,则继承该类型的值范围,而不是自己另造一个。 ## 在自己的代码里避开这个坑 假设你像 P4313 那样,正在为自己的代码加上枚举的位掩码运算。既然 C++ 在这块地方又多了些古怪之处,你该怎么保证自己的代码既正确又不让人困惑?避开 `bool` 那种情况很简单——直接约束底层类型不能是 `bool` 就行,可以针对所有枚举,也可以只针对取反运算符。但标准并没有现成的 type trait 或反射元函数,能专门检测出「没有固定底层类型的作用域外枚举」。好在我们可以自己写一个: ```cpp template<typename E> concept unfixed_enum = std::is_enum_v<E> && !requires { E{0}; }; ``` copy [ ParseError: Can't use function '$' in math mode at position 496: …的,即那个 M 位整数的值。[$̲$ expr.static.c…dcl.init.list
](https://eel.is/c++draft/dcl.init.list#3.8) 只允许底层类型固定的枚举从标量这样初始化,而 0 是所有可能的枚举都能表示的唯一取值。为了覆盖边界情况,标准规定没有枚举项的枚举,其取值范围等同于一个无符号的一位整数;而把 1 排除在可能取值之外的,是 `enum E{ a = -1 };`,它的范围是 `[-1, 0]`。别忘了那个 `std::is_enum_v`。毕竟有很多类型对 `T{0}` 都是非良构的,而它们大多不是枚举。 这篇文章主要围绕按位取反运算符展开,但底层类型不固定的枚举所埋下的 UB 陷阱,同样适用于按位左移运算符。如果你选择按运算符逐个加约束,别把这一点漏了。 这就又一次把我们带回了 P4313R1。在写这篇文章的时候,用来约束这些运算符的 concept 只要求类型是某个带 `[[=std::bitmask_type]]` 标注的枚举类型。因此,它会放任底层类型为 `bool` 的枚举出现令人困惑的行为,也会让没有固定底层类型的普通枚举存在潜在的 UB。如果作者不想把上面那个 init-list 技巧标准化,他们可以保守一点,改为约束 `std::is_scoped_enum`,免得用户轻易踩到 UB——毕竟所有作用域外枚举本来就能用内建的按位运算符(尽管返回的是 `int`)。他们也可以约束底层类型不能是 `bool`,把困惑降到最低;尤其是用户代码里对 `bool` 取反时本该给出的诊断,如果来自系统头文件,默认会被抑制掉。我打算就这件事联系 P4313 的作者,看看他们是否愿意把这些约束加进论文里。 ## 附记:浮点数怎么办? \# 你可能会好奇,标准是如何定义把浮点数转换成这些基于 `bool` 的 `enum` 的。结果大概并不意外——[ ParseError: Expected 'EOF', got '#' at position 40: …t/dcl.init.list#̲3.8) 只允许底层类型固定的…expr.static.cast
](https://eel.is/c++draft/expr.static.cast#8) 要求其行为等价于先把浮点值转换成枚举的底层类型,再转换成枚举类型;它把读者引向 [ ParseError: Expected 'EOF', got '#' at position 43: …xpr.static.cast#̲8) 要求其行为等价于先把浮点…conv.fpint
](https://eel.is/c++draft/conv.fpint#note-1),那里有一条明确的注释,把读者指向我们的老朋友 \[conv.bool]。按照标准,这意味着任何不是恰好等于 `0.0` 或 `-0.0` 的浮点值都会转换成 `boolean::TRUE`,否则我们就又回到 `boolean::FALSE`。 那就从小处开始: ```cpp enum class boolean : bool { FALSE, TRUE, }; static_assert(static_cast<boolean>(0.0) == boolean::FALSE); static_assert(static_cast<boolean>(0.5) == boolean::TRUE); static_assert(static_cast<boolean>(2.5) == boolean::TRUE); ``` copy Godbolt [在这里](https://godbolt.org/z/1aK8Kfadv)。 Clang 和 gcc 都拒绝这段代码。报错说 `(boolean)2.5e+0` 不是常量表达式;而 `static_cast<boolean>(0.5) == boolean::TRUE` 是错的,它其实等于 `boolean::FALSE`。MSVC 接受上面这段代码。 但我们再往前走一步,看看实际存进去的到底是什么。我们写一个函数来检查这些转换生成的位模式,然后试几个值: ```cpp void show(double d) { const boolean e = static_cast<boolean>(d); unsigned char byte {}; std::memcpy(&byte, &e, 1); std::println("static_cast<boolean>({:7.1f}) stored byte {:3} required {}", d, byte, (d != 0.0) ? 1 : 0); } int main() { show(0.0); show(0.5); show(1.0); show(2.5); show(3.0); show(256.0); show(-2.5); } ``` copy Godbolt [在这里](https://godbolt.org/z/Phd8jvcjj)。 上面这段代码在三个编译器上都能无警告地编译通过,但 MSVC 虽然又一次在所有情况下都做对了,gcc 和 Clang 的行为却要令人担忧得多。看输出可以发现,gcc 总是直接把值截断成整数,再把这个位模式装进结果 `boolean` 里。于是 `2.5` 截断成 `2`,得到一个底层位模式为 `2` 的 `boolean`。Clang 在未优化的构建上做同样的事,但一旦打开优化,截断后的值就会照常走一遍 \[conv.bool] 的转换,对所有在 (-1, 1) 范围之外的非零值都给出正确答案。 然而,这比几个古怪的位模式更值得担心。`boolean` 的合法范围是 \[0, 1]。读取位模式落在这个范围之外的对象是 UB。不过,你可能会问,如果我们把这些 `boolean` 再转回 `bool`,触发一次没有初始浮点状态的布尔转换,会发生什么?MSVC 又一次做对了;gcc 不修改位模式,留给我们的是超出该类型范围的 `bool`(因此读取它们是 UB);而 Clang 则是好戏再次上演的地方。把上面的代码改成从 `e` 转换到 `bool`,再把那个 `bool` memcpy 进 `unsigned char`,在未优化的构建上我们得到这样的结果: | | 0.0 | 0.5 | 1.0 | 2.5 | 3.0 | 256.0 | -2.5 | | --- | --- | --- | --- | --- | --- | --- | --- | | required | 0 | 1 | 1 | 1 | 1 | 1 | 1 | | gcc 14 / 15 / 16 | 0 | 0 | 1 | 2 | 3 | 0 | 254 | | clang 19 / 20 / 21 | 0 | 0 | 1 | 0 | 1 | 0 | 0 | | clang 23.1.1 | 0 | 0 | 1 | 1 | 1 | 0 | 1 | Clang 23 之前的版本似乎只是执行 `trunc(d) & 1`;也就是说,截断之后,奇数是 `true`,偶数是 `false`。Clang 23 执行 `(trunc(d) % 256) != 0`,先窄化为一个字节。于是 `256.0` 变成 `FALSE`,`257.0` 变成 `TRUE`。优化构建的行为和以前一样——先截断,再做正确的布尔转换。 这一切最终走向了一个相当荒谬的顶点。看看下面这段代码: ```cpp #include <print> enum class boolean : bool { FALSE, TRUE, }; //Force this to be runtime boolean make(double d) { return static_cast<boolean>(d); } int main() { const boolean e = make(2.5); std::println("e == boolean::TRUE : {}", e == boolean::TRUE); std::println("e == boolean::FALSE : {}", e == boolean::FALSE); const bool b = static_cast<bool>(e); std::println("b == true : {}", b == true); std::println("b == false : {}", b == false); std::println("b + 0 : {}", b + 0); } ``` copy Godbolt [在此](https://godbolt.org/z/T7zMqGo8j)。 MSVC 再次一马当先,给出了正确答案。Clang 错得一丝不差,认为 `e` 是 `FALSE`,`b` 是 `false`。gcc 则更上一层楼:它给出的一个 `enum` 与自己的所有枚举值都相等,还有一个 `bool`,既不是 `true` 也不是 `false`——直到你打开优化器,得到一个既是 `true` 又是 `false` 的 `bool`。 再凑齐任何浮点讨论都绕不开的必备话题:infinity 和 NaN 在 gcc 和 Clang 上显示为 `0`,并以 `boolean` 的形式变成 `FALSE`,因此转换为 `false` 时得到 `bool`;在 MSVC 上则给出 `1`,因而得到 `TRUE` 和 `true`。 轻松一点的消息是:Clang trunk 似乎已经修好了上面这个例子,让它行为正确,所以本节描述的部分问题应该很快就会被补掉。 ## 结论 \# 从这一切里学到了什么?也许是这样:即便是 P4313 这种合理且无争议的纯库层面提案,也该提防某个古怪 C++ 边界情况埋下的坑。或者,说得更实际一些: - 整型提升无法避免。如果你以为自己操作的是一个比 `int` 更小的整数,那它很可能就在你眼皮底下变成了 `int`。 - `bool` 的按位取反永远是 `true`,即使包在 enum 里也一样。但你不该依赖这个行为,因为 gcc 有一个长期存在的 bug,会把它弄错(如果你更看重逻辑而不是 C++,也可以说它弄对了)。 - 没有固定底层类型的不限定作用域枚举,其有效值范围可能小于实际底层类型的范围。 需要说明的是,整型提升的排名顺序远比“转换成 `int`”复杂。对于转换等级(而非大小)低于 `int` 的整数类型,若该类型的所有值都能用 `int` 表示,则选择 `int`;否则选择 `unsigned int`。实践中通常会是 `int`;但在 `int` 与 `short` 大小相同的实现上,`unsigned short` 会直接提升为 `unsigned int`。至于 charN\_t 家族等其他类型,转换还可继续推进到更长的整数类型,例如 `long` 和 `long long`。`bool` 则被单独列为特殊情况,始终提升为 `int`。 [↩︎](#fnref:1) - [C++](https://dryperspective.github.io/tags/c++/) - [Undefined-Behaviour](https://dryperspective.github.io/tags/undefined-behaviour/) - [Enums](https://dryperspective.github.io/tags/enums/) - [Wg21](https://dryperspective.github.io/tags/wg21/) © 2026 [Substitution Failure](https://dryperspective.github.io/) · Powered by [Hugo](https://gohugo.io/?utm_source=papermod) & [PaperMod](https://github.com/adityatelange/hugo-PaperMod/) [Go to Top (Alt + G)](#top) ParseError: Expected 'EOF', got '#' at position 37: …raft/conv.fpint#̲note-1),那里有一条明确…