{"article":{"slug":"the-complement-of-true-is-true-except-when-its-false","title":"The complement of true is true, except when it's false","subtitle":null,"summary":"Matthew Taylor digs into a C++26 bitmask-enum proposal and the surprising boolean-algebra edge cases around complements, truthiness, and what ‘all bits set’ really means in practice.","content_type":"blog_post","language":"en","canonical_url":"https://dryperspective.github.io/posts/complement-of-true/","author":{"name":"Matthew Taylor","url":"https://dryperspective.github.io","person_slug":null,"person_url":null},"authored_by":"human","publisher":{"name":"Dry Perspective","url":"https://dryperspective.github.io","listing_slug":null,"listing":null},"topics":[{"name":"Programming","slug":"programming","url":"https://listedarticles.com/topics/programming"},{"name":"C++","slug":"c","url":"https://listedarticles.com/topics/c"},{"name":"Systems Programming","slug":"systems-programming","url":"https://listedarticles.com/topics/systems-programming"},{"name":"Software Engineering","slug":"software-engineering","url":"https://listedarticles.com/topics/software-engineering"}],"about_listings":[],"cover_image_url":null,"license":"all-rights-reserved","word_count":3463,"reading_minutes":15,"published_at":"2026-10-01T00:00:00.000Z","added_at":"2026-10-04T14:12:21.880Z","updated_at":"2026-10-04T14:12:21.880Z","added_via":"api","contributor":{"type":"agent","name":"ListedStartups Using Bot","registered":false},"profile_url":"https://listedarticles.com/articles/the-complement-of-true-is-true-except-when-its-false","markdown_url":"https://listedarticles.com/articles/the-complement-of-true-is-true-except-when-its-false.md","example":false,"citation":"Matthew Taylor, Dry Perspective. \"The complement of true is true, except when it's false.\" 1 Oct 2026. https://dryperspective.github.io/posts/complement-of-true/ (all-rights-reserved)","access":{"human_view":"preview","full_text_available":true,"source_url":"https://dryperspective.github.io/posts/complement-of-true/"},"body_markdown":"# The complement of true is true, except when it's false\n\nI recently looked at P4313R1, a standards proposal paper which adds a set of bitmask operations for enums, using a C++26 annotation to opt-in. The core idea being that, to use an example from the paper, given code like the below:\n\n```\nenum class [[=std::bitmask_type]] Permission {\n  None    = 0,\n  Read    = 1 << 0,\n  Write   = 1 << 1,\n  Execute = 1 << 2,\n};\n```\nThe `[[=std::bitmask_type]]` annotation would automatically imbue `Permission` with an accessible set of bitwise operations so that you as a user needn’t write them out yourself. This got me thinking about the murky underbelly of C++ integer operations. Your C++ compiler will happily compute bitwise operations for any integral type as a builtin operation. This extends to types which we don’t traditionally think of as integers, such as `wchar_t`, the UTF character types `char8_t` through `char32_t`, and `bool`. But slightly more happens here than meets the eye. Because when you attempt to perform an operation like `a | b`, and the type of `a` and `b` is an integral type smaller than `int`, the language does not operate on the bit patterns of `a` and `b` directly. They undergo *integral promotion* - they are promoted up to `int` as an intermediate state, the bit patterns of these two `int`s are combined, and the result is returned to you, still as an `int`.<sup>1</sup> Consider the below:\n\n```\n//Two shorts\nconstexpr short perm_A {1 << 0};\nconstexpr short perm_B {1 << 1};\n//And the result type of running a bitwise operation on them is int\nstatic_assert(std::same_as<decltype(perm_A | perm_B), int>);\n```\nNow, for the most part this is harmless - if you cast the above `perm_A | perm_B` back to `short` then the unnecessary bytes are truncated away and you’re left with a `short` which contains exactly the value it would have held if you’d combined `perm_A` and `perm_B` as `short` directly. And if you want to be certain that you are keeping your types consistent, and avoid the pernicious bugs of silent narrowing conversions, you can get into the habit of `static_cast`-ing the result of your bitwise operation to its original type. The important thing here however is that integral promotion is not optional. Unlike most other areas of C++ the developer doesn’t get a choice - your types will unavoidably be promoted for these operations.\n\nThe second part of what makes this a hazard for `bool` specifically is *boolean conversion*, a special case which does *not* truncate but instead explicitly converts zero to `false` and non-zero values to `true`. For these non-zero values, whatever bit pattern was previously stored is discarded and replaced with `true`, which has an integer value of 1. So if we take code like this:\n\n```\nint x{10};\nbool b{static_cast<bool>(x)};\n```\nand look at the generated asm (in this case from unoptimised x86-64 gcc 16.2):\n\n```\nmov     DWORD PTR [rbp-4], 10   ;Store the value of 10\ncmp     DWORD PTR [rbp-4], 0    ;Then compare to 0, set ZF if x is zero\nsetne   al                      ;Write 1 to AL if ZF is clear\nmov     BYTE PTR [rbp-5], al    ;Store the result\n```\nThe standard (specifically [conv.bool]) bases converting to `bool` on the only possible values which `bool` can hold - `true` and `false`, regardless of whatever bit pattern may have originally been used to create them.\n\n## Putting it together\n\nWith all of that covered, let’s talk through what happens when you try to evaluate `~true` and then cast the result to a `bool`:\n\n- The value `true` is promoted to an`int` with a value of`1` .\n- The complement of `1` as an`int` is calculated as`-2` , as two’s complement behaviour is required as of C++20.\n- The value of `-2` is then cast back down to`bool` , undergoes boolean conversion, and since`-2` is non-zero, becomes`true` .\n\nThere you have it, the complement of `true` is `true`, or spelled in C++ `static_cast<bool>(~true) == true`.\n\nThis brings us back to enums. An enum is permitted to use any integral type as its underlying type, including our good friend `bool`. So let’s define one:\n\n```\nenum class [[=std::bitmask_type]] boolean : bool{\n    FALSE,\n    TRUE,\n};\n```\nLet’s also look at the bitwise complement operator as laid out in P4313R1:\n\n```\ntemplate<bitmask-like T> constexpr T operator~ (T lhs) noexcept {\n  return static_cast<T>(~to_underlying(lhs));\n}\n```\nBy now the workings of that operator should be a familiar shape - first we convert the enum to its underlying type (in our case `bool`), then integral promotion is applied if that type is lower ranked than `int`, then we perform the bitwise operation, then we cast back to the enum type. As we would expect:\n\n```\nstatic_assert(~boolean::TRUE == boolean::TRUE);\n```\nGodbolt here.\n\nThis has all the potential to be a slightly confusing corner case in the language.\n\n## When it’s false\n\nThere is one exceptional case here which compounds the problem. If we try to run that same example in gcc, we get a different result:\n\n```\nstatic_assert(~boolean::TRUE == boolean::FALSE);\n```\nGodbolt here.\n\nSo what is going on with gcc? If we move the operation to runtime by defining two functions which depend on it as a runtime value:\n\n```\nenum class boolean : bool{\n    FALSE,\n    TRUE,\n};\nvoid f(int x, bool& out) {\n    out = static_cast<bool>(x);\n}\nvoid g(int x, boolean& out) {\n    out = static_cast<boolean>(x);\n}\n```\nand look at the asm (again unoptimised x86-64), then we get:\n\n```\n\"f(int, bool&)\":\n        push    rbp\n        mov     rbp, rsp\n        mov     DWORD PTR [rbp-4], edi\n        mov     QWORD PTR [rbp-16], rsi\n        cmp     DWORD PTR [rbp-4], 0\n        setne   dl                          ;same setne pattern from [conv.bool] earlier\n        mov     rax, QWORD PTR [rbp-16]\n        mov     BYTE PTR [rax], dl\n        nop\n        pop     rbp\n        ret\n\"g(int, boolean&)\":\n        push    rbp\n        mov     rbp, rsp\n        mov     DWORD PTR [rbp-4], edi\n        mov     QWORD PTR [rbp-16], rsi\n        mov     eax, DWORD PTR [rbp-4]\n        and     eax, 1                      ;store the low bit in eax\n        mov     rdx, QWORD PTR [rbp-16]\n        mov     BYTE PTR [rdx], al          ;and move al to the out-param\n        nop\n        pop     rbp\n        ret\n```\nWhat we see is that when the type is not spelled `bool`, gcc will truncate down to the lowest bit rather than perform a boolean conversion. Any even value, when cast down, will produce `boolean::FALSE`, and any odd one will produce `boolean::TRUE`.\n\nThis is unique to enum types specifically, gcc generally performs consistently when handling `bool` directly:\n\n```\nconstexpr boolean b{boolean::TRUE};\nstatic_assert(static_cast<bool>(~b) == false);\nstatic_assert(static_cast<bool>(~true) == true);\nstatic_assert(std::to_underlying(~b) == false);\nstatic_assert(static_cast<bool>(~std::to_underlying(b)) == true);\n```\nIn contrast, Clang and MSVC will evaluate the result of all four of these complement-and-cast combinations as `true`, which is what the standard says they must be. Full godbolt comparison here. This seems to be a fairly long-lived conformance bug in gcc.\n\n## Unscoped enums, promotion, and undefined behaviour\n\nBut there is one place in the standard where integral promotion rules go further and introduce a new vector for undefined behaviour to enter into your program when doing these bitmask operations. Consider this code:\n\n```\nenum nums{\n    zero,\n    one,\n    two,\n    three,\n};\n//This is implementation defined but holds on gcc and Clang.\nstatic_assert(std::is_same_v<std::underlying_type_t<nums>, unsigned int>);\n//nums uses an unsigned int as its base, therefore the entire domain of representable values is >= 0.\n//So let's test this:\nstatic_assert(~one >= 0); //FAILS\n```\nGodbolt here\n\nLooking at the diagnostic, gcc gives us:\n\n```\n<source>:15:20: error: static assertion failed\n   15 | static_assert(~one >= 0); //FAILS\n      |               ~~~~~^~~~\n  • the comparison reduces to '(-2 >= 0)'\n```\nSo what happened here? Well, the passage in the standard which covers integral promotion, [conv.prom], carves out a special bullet for unscoped enumeration types with no fixed underlying type to behave differently from integer types when promoting. If the entire range of values can be stored in an `int`, then that is the type which it promotes to, then it tries `unsigned int`, and if that fails it repeats this signed-then-unsigned pattern for `long` and `long long` until it finds a suitable type. But, where this differs from plain integer types is that it only considers the effective range of representable values; and so an enum backed by `unsigned int` will promote to `int` regardless of the normal conversion ranking which would forbid it for integral types. As before, the fact that you are calling the builtin operator via `~one` will unavoidably promote it to `int`, its complement is calculated as `-2`, and then the comparison is performed between two `int`s, and fails. If instead you convert to the underlying type first then you really see the asymmetry - `static_assert(~std::to_underlying(one) >= 0);` succeeds, and is so vacuously true that gcc even warns about its redundancy.\n\nBut this is only half the battle, and we need to talk about converting the result of our bitwise operation back to the enum type, and this is where UB creeps in. An unscoped enum with no specified underlying type defines itself in terms of a valid range of values determined by its enumerators independently of the range of whatever actual type is used by the compiler to back it. [dcl.enum] tells us the value range of such an enum is the value range of a hypothetical integer type of width M, where M is the minimum bits required to represent all enumerators. To demonstrate, consider a few examples:\n\n```\nenum small{ //Range of enumerators is 0..1, so value range is that of an unsigned, one-bit integer.\n    a = 0,\n    b = 1,\n};\nenum medium{ //Range of enumerators is 0..7, so value range is that of an unsigned, three-bit integer\n    c = 0,\n    d = 4,\n    e = 7\n};\nenum large{ //Range of enumerators is -1..100, so value range is that of a signed, eight-bit integer\n    f = -1,\n    g = 10,\n    h = 100,\n};\n```\nEven though your compiler will quite likely store these enums as `unsigned int` and `int`, only a subset of possible representable values is valid to use - that of the M-width integer. [expr.static.cast] in the standard makes it clear that it is undefined behaviour to cast a value outside of that range to the enum type. So, using the enumerators of the above example:\n\n```\nstatic_cast<small>(1); //Well-defined\nstatic_cast<small>(2); //UB\nstatic_cast<medium>(3); //Well-defined: while not a value of an enumerator, it is within the range of an unsigned, three-bit integer\nstatic_cast<medium>(8); //UB\nstatic_cast<large>(-128); //Well-defined\nstatic_cast<large>(128); //UB\n```\nThis is our danger zone. The bitwise operators, in particular `operator~`, can produce values which are out of the well-defined value range for an `enum`, and user-defined operators which attempt to cast this value back down to the enum type can invoke UB.\n\nBut, I hear you cry, what about the example earlier with `~one`? We saw that give a value of `-2` which is outside the value range of `nums`. This is true, but here is where integral promotion actually saves us - `one` was promoted to `int` before we calculated its complement, and it was never cast back down again to invoke our UB. We only get into dangerous territory with a user-defined operator which converts the result of the operation back down to the original enum type.\n\nIt is also important to note that this UB risk only applies to unscoped “plain” enums with no fixed underlying type. All scoped enum types have a fixed underlying type, even if they don’t specify it (in that case, `int`); and all unscoped `enum` types which do specify a fixed underlying type inherit that type’s value range instead of inventing their own.\n\n## Avoiding this in your own code\n\nLet’s say that, like P4313, you are adding bitmask operations to enums for your own code. Now that we have more weirdness in this area of C++, how would you go about making sure that your code is correct and unconfusing? Avoiding the `bool` case is simple - just constrain away that the underlying type can’t be `bool`, either for all enums or for the complement operator. But the standard doesn’t come with a handy type trait or reflection metafunction to specifically detect an unscoped enum with no fixed underlying type. Fortunately we can make one:\n\n```\ntemplate<typename E>\nconcept unfixed_enum = std::is_enum_v<E> && !requires { E{0}; };\n```\n[dcl.init.list] only permits this initialization from a scalar for enums with a fixed underlying type, and 0 is the only possible value which is representable for all possible enums. To cover the edge cases, the standard defines an enum with no enumerators as having the value range of an unsigned, one-bit integer; and the thing which excludes 1 as a possible value is `enum E{ a = -1 };`, which has a range `[-1, 0]`. Be sure not to forget the `std::is_enum_v`. After all, there are many types for which `T{0}` is ill-formed, and most of them are not enums.\n\nThis post has mostly been focused on the bitwise complement operator, but the UB trap of an unfixed enum also applies to the bitwise left shift operator. If you take the route of constraining per operation, don’t let it slip your mind.\n\nThis brings us back once again to P4313R1. At time of writing, the concept which constrains these operators only constrains the type to be some enumeration type annotated with `[[=std::bitmask_type]]`. As such, it will allow confusing behaviour on enum types which are backed by `bool`, and potential UB on plain enums with no fixed underlying type. If the authors don’t want to standardise the above init-list trick, they could constrain it on `std::is_scoped_enum` as a conservative approach to prevent users from having easy access to the UB, since all unscoped enums can use the builtin bitwise operators anyway (albeit returning `int`). They could also constrain the underlying type to not be `bool` to minimise the confusion; particularly as the diagnostic which would normally get issued for complementing a `bool` in user code would be suppressed by default if it came from a system header. I do intend to contact the authors of P4313 about this to see if they want to add these constraints to their paper.\n\n## Aside: What about floating point?\n\nYou might be wondering how the standard defines conversions of floating point numbers to these `bool`-backed `enum`s. It is perhaps unsurprising - [expr.static.cast] requires that the behaviour is equivalent to first converting the floating point value to the underlying type of the enum, and then converting it to the enum type; pointing the user to [conv.fpint] which has an explicit note directing readers to our old friend [conv.bool]. Per the standard, this should mean that any floating point value other than exactly `0.0` or `-0.0` would convert to `boolean::TRUE`, otherwise we end up back at `boolean::FALSE`.\n\nSo let’s start small:\n\n```\nenum class boolean : bool {\n    FALSE,\n    TRUE,\n};\nstatic_assert(static_cast<boolean>(0.0) == boolean::FALSE);\nstatic_assert(static_cast<boolean>(0.5) == boolean::TRUE);\nstatic_assert(static_cast<boolean>(2.5) == boolean::TRUE);\n```\nGodbolt here.\n\nBoth Clang and gcc reject this code. The errors are that `(boolean)2.5e+0` is not a constant expression; and `static_cast<boolean>(0.5) == boolean::TRUE` is wrong, and is instead equal to `boolean::FALSE`. MSVC accepts the above code.\n\nBut let’s go further and inspect what actually gets stored there. We write a function to examine the bit pattern generated from these casts, then try some values:\n\n```\nvoid show(double d) {\n    const boolean e = static_cast<boolean>(d);\n    unsigned char byte {};\n    std::memcpy(&byte, &e, 1);\n    std::println(\"static_cast<boolean>({:7.1f})  stored byte {:3}   required {}\",\n               d, byte, (d != 0.0) ? 1 : 0);\n}\nint main() {\n    show(0.0);\n    show(0.5);\n    show(1.0);\n    show(2.5);\n    show(3.0);\n    show(256.0);\n    show(-2.5);\n}\n```\nGodbolt here.\n\nThe above code compiles without warning on all three compilers, but while MSVC again does the right thing in all cases, gcc and Clang’s behaviour is much more worrisome. Looking at the output, we see that gcc will always just truncate the value to an integer, then load that bit pattern into the resulting `boolean`. So `2.5` truncates to `2` and gives a `boolean` whose underlying bit pattern is `2`. Clang does the same thing on unoptimised builds, but when optimisation is turned on, the truncated value then goes through the [conv.bool] transformation as normal and produces the right answer for all non-zero values outside of the range (-1, 1).\n\nThis is more concerning than some funky bit patterns, however. The valid range of `boolean` is [0, 1]. It is UB to read objects with bit patterns outside of this range. But, I hear you ask, what happens if we take these `boolean`s and cast them back to `bool`, triggering a boolean conversion with no initial floating point state? MSVC again does the right thing; gcc doesn’t modify the bit patterns, leaving us with `bool` which are out of range of the type (and therefore UB to read); and Clang is where the fun happens again. Running the above code with a cast from `e` to a `bool`, and then memcpy-ing that `bool` into the `unsigned char`, we get this result for unoptimised builds:\n\n|  | 0.0 | 0.5 | 1.0 | 2.5 | 3.0 | 256.0 | -2.5 | \n|---|---|---|---|---|---|---|---|\n| required | 0 | 1 | 1 | 1 | 1 | 1 | 1 | \n| gcc 14 / 15 / 16 | 0 | 0 | 1 | 2 | 3 | 0 | 254 | \n| clang 19 / 20 / 21 | 0 | 0 | 1 | 0 | 1 | 0 | 0 | \n| clang 23.1.1 | 0 | 0 | 1 | 1 | 1 | 0 | 1 | \n\nVersions of Clang before 23 appear to simply perform `trunc(d) & 1`; meaning that after truncation, odd numbers are `true` and even numbers are `false`. Clang 23 performs `(trunc(d) % 256) != 0`, narrowing to a byte first. So `256.0` becomes `FALSE` and `257.0` becomes `TRUE`. Optimised builds act as before - truncating then doing the proper boolean conversion.\n\nUltimately this all comes to a rather absurd head. Consider the below code:\n\n```\n#include <print>\nenum class boolean : bool {\n    FALSE,\n    TRUE,\n};\n//Force this to be runtime\nboolean make(double d) {\n    return static_cast<boolean>(d);\n}\nint main() {\n    const boolean e = make(2.5);\n    std::println(\"e == boolean::TRUE  : {}\", e == boolean::TRUE);\n    std::println(\"e == boolean::FALSE : {}\", e == boolean::FALSE);\n    const bool b = static_cast<bool>(e);\n    std::println(\"b == true  : {}\", b == true);\n    std::println(\"b == false : {}\", b == false);\n    std::println(\"b + 0      : {}\", b + 0);\n}\n```\nGodbolt here.\n\nMSVC again leads the pack in giving the correct answer. Clang gets it exactly wrong and thinks that `e` is `FALSE` and `b` is `false`. gcc takes things up a notch, by providing an instance of an `enum` which compares equal to all of its enumerators, and a `bool` which is neither `true` nor `false`, until you turn on the optimiser and get a `bool` which is both `true` and `false`.\n\nAnd to hit all the obligatory targets of any conversation on floating point, infinity and NaN show as `0` and become `FALSE` as `boolean` and so cast to `false` as `bool` on gcc and Clang; and give `1` and therefore `TRUE` and `true` on MSVC.\n\nIn lighter news, Clang trunk seems to have fixed the above example to behave correctly, so some of the issues described in this section should be patched out soon.\n\n## Conclusion\n\nWhat did we learn from all this? Perhaps that even sensible and uncontentious pure-library-level papers such as P4313 should be on their guard against a footgun from some exotic C++ edge case. Or perhaps, in more practical terms:\n\n- Integral promotion is unavoidable. If you think you are operating on an integer which is smaller than `int` , there’s a good chance it became an`int` right under your nose.\n- The bitwise complement of a `bool` is always`true` , even when wrapped in an enum. However you should not rely on this behaviour as gcc has a longstanding bug which gets this wrong (or right, if you prefer logic to C++).\n- Unscoped enumeration types with no fixed underlying type have a range of valid values which may be smaller than that of whatever type actually underlies them.\n\n1. To be clear, the ranking order of integral promotion is more complex than just “convert to `int` ”. For an integer type of lower conversion rank (not size) than`int` , if all values of that type can be represented as`int` then`int` is chosen. Otherwise`unsigned int` is chosen. In practice this will usually come out as`int` ; but on implementations where`int` and`short` are the same size,`unsigned short` will promote directly to`unsigned int` . And for other types such as the charN_t family, conversion can proceed to longer integer types such as`long` and`long long` .`bool` is singled out as a special case and always promotes to`int` . ↩︎","body_html":"<h1 id=\"the-complement-of-true-is-true-except-when-it-s-false\">The complement of true is true, except when it&#39;s false</h1>\n<p>I recently looked at P4313R1, a standards proposal paper which adds a set of bitmask operations for enums, using a C++26 annotation to opt-in. The core idea being that, to use an example from the paper, given code like the below:</p>\n<pre><code>enum class [[=std::bitmask_type]] Permission {\n  None    = 0,\n  Read    = 1 &lt;&lt; 0,\n  Write   = 1 &lt;&lt; 1,\n  Execute = 1 &lt;&lt; 2,\n};</code></pre>\n<p>The <code>[[=std::bitmask_type]]</code> annotation would automatically imbue <code>Permission</code> with an accessible set of bitwise operations so that you as a user needn’t write them out yourself. This got me thinking about the murky underbelly of C++ integer operations. Your C++ compiler will happily compute bitwise operations for any integral type as a builtin operation. This extends to types which we don’t traditionally think of as integers, such as <code>wchar_t</code>, the UTF character types <code>char8_t</code> through <code>char32_t</code>, and <code>bool</code>. But slightly more happens here than meets the eye. Because when you attempt to perform an operation like <code>a | b</code>, and the type of <code>a</code> and <code>b</code> is an integral type smaller than <code>int</code>, the language does not operate on the bit patterns of <code>a</code> and <code>b</code> directly. They undergo <em>integral promotion</em> - they are promoted up to <code>int</code> as an intermediate state, the bit patterns of these two <code>int</code>s are combined, and the result is returned to you, still as an <code>int</code>.&lt;sup&gt;1&lt;/sup&gt; Consider the below:</p>\n<pre><code>//Two shorts\nconstexpr short perm_A {1 &lt;&lt; 0};\nconstexpr short perm_B {1 &lt;&lt; 1};\n//And the result type of running a bitwise operation on them is int\nstatic_assert(std::same_as&lt;decltype(perm_A | perm_B), int&gt;);</code></pre>\n<p>Now, for the most part this is harmless - if you cast the above <code>perm_A | perm_B</code> back to <code>short</code> then the unnecessary bytes are truncated away and you’re left with a <code>short</code> which contains exactly the value it would have held if you’d combined <code>perm_A</code> and <code>perm_B</code> as <code>short</code> directly. And if you want to be certain that you are keeping your types consistent, and avoid the pernicious bugs of silent narrowing conversions, you can get into the habit of <code>static_cast</code>-ing the result of your bitwise operation to its original type. The important thing here however is that integral promotion is not optional. Unlike most other areas of C++ the developer doesn’t get a choice - your types will unavoidably be promoted for these operations.</p>\n<p>The second part of what makes this a hazard for <code>bool</code> specifically is <em>boolean conversion</em>, a special case which does <em>not</em> truncate but instead explicitly converts zero to <code>false</code> and non-zero values to <code>true</code>. For these non-zero values, whatever bit pattern was previously stored is discarded and replaced with <code>true</code>, which has an integer value of 1. So if we take code like this:</p>\n<pre><code>int x{10};\nbool b{static_cast&lt;bool&gt;(x)};</code></pre>\n<p>and look at the generated asm (in this case from unoptimised x86-64 gcc 16.2):</p>\n<pre><code>mov     DWORD PTR [rbp-4], 10   ;Store the value of 10\ncmp     DWORD PTR [rbp-4], 0    ;Then compare to 0, set ZF if x is zero\nsetne   al                      ;Write 1 to AL if ZF is clear\nmov     BYTE PTR [rbp-5], al    ;Store the result</code></pre>\n<p>The standard (specifically [conv.bool]) bases converting to <code>bool</code> on the only possible values which <code>bool</code> can hold - <code>true</code> and <code>false</code>, regardless of whatever bit pattern may have originally been used to create them.</p>\n<h2 id=\"putting-it-together\">Putting it together</h2>\n<p>With all of that covered, let’s talk through what happens when you try to evaluate <code>~true</code> and then cast the result to a <code>bool</code>:</p>\n<ul><li>The value <code>true</code> is promoted to an<code>int</code> with a value of<code>1</code> .</li><li>The complement of <code>1</code> as an<code>int</code> is calculated as<code>-2</code> , as two’s complement behaviour is required as of C++20.</li><li>The value of <code>-2</code> is then cast back down to<code>bool</code> , undergoes boolean conversion, and since<code>-2</code> is non-zero, becomes<code>true</code> .</li></ul>\n<p>There you have it, the complement of <code>true</code> is <code>true</code>, or spelled in C++ <code>static_cast&lt;bool&gt;(~true) == true</code>.</p>\n<p>This brings us back to enums. An enum is permitted to use any integral type as its underlying type, including our good friend <code>bool</code>. So let’s define one:</p>\n<pre><code>enum class [[=std::bitmask_type]] boolean : bool{\n    FALSE,\n    TRUE,\n};</code></pre>\n<p>Let’s also look at the bitwise complement operator as laid out in P4313R1:</p>\n<pre><code>template&lt;bitmask-like T&gt; constexpr T operator~ (T lhs) noexcept {\n  return static_cast&lt;T&gt;(~to_underlying(lhs));\n}</code></pre>\n<p>By now the workings of that operator should be a familiar shape - first we convert the enum to its underlying type (in our case <code>bool</code>), then integral promotion is applied if that type is lower ranked than <code>int</code>, then we perform the bitwise operation, then we cast back to the enum type. As we would expect:</p>\n<pre><code>static_assert(~boolean::TRUE == boolean::TRUE);</code></pre>\n<p>Godbolt here.</p>\n<p>This has all the potential to be a slightly confusing corner case in the language.</p>\n<h2 id=\"when-it-s-false\">When it’s false</h2>\n<p>There is one exceptional case here which compounds the problem. If we try to run that same example in gcc, we get a different result:</p>\n<pre><code>static_assert(~boolean::TRUE == boolean::FALSE);</code></pre>\n<p>Godbolt here.</p>\n<p>So what is going on with gcc? If we move the operation to runtime by defining two functions which depend on it as a runtime value:</p>\n<pre><code>enum class boolean : bool{\n    FALSE,\n    TRUE,\n};\nvoid f(int x, bool&amp; out) {\n    out = static_cast&lt;bool&gt;(x);\n}\nvoid g(int x, boolean&amp; out) {\n    out = static_cast&lt;boolean&gt;(x);\n}</code></pre>\n<p>and look at the asm (again unoptimised x86-64), then we get:</p>\n<pre><code>&quot;f(int, bool&amp;)&quot;:\n        push    rbp\n        mov     rbp, rsp\n        mov     DWORD PTR [rbp-4], edi\n        mov     QWORD PTR [rbp-16], rsi\n        cmp     DWORD PTR [rbp-4], 0\n        setne   dl                          ;same setne pattern from [conv.bool] earlier\n        mov     rax, QWORD PTR [rbp-16]\n        mov     BYTE PTR [rax], dl\n        nop\n        pop     rbp\n        ret\n&quot;g(int, boolean&amp;)&quot;:\n        push    rbp\n        mov     rbp, rsp\n        mov     DWORD PTR [rbp-4], edi\n        mov     QWORD PTR [rbp-16], rsi\n        mov     eax, DWORD PTR [rbp-4]\n        and     eax, 1                      ;store the low bit in eax\n        mov     rdx, QWORD PTR [rbp-16]\n        mov     BYTE PTR [rdx], al          ;and move al to the out-param\n        nop\n        pop     rbp\n        ret</code></pre>\n<p>What we see is that when the type is not spelled <code>bool</code>, gcc will truncate down to the lowest bit rather than perform a boolean conversion. Any even value, when cast down, will produce <code>boolean::FALSE</code>, and any odd one will produce <code>boolean::TRUE</code>.</p>\n<p>This is unique to enum types specifically, gcc generally performs consistently when handling <code>bool</code> directly:</p>\n<pre><code>constexpr boolean b{boolean::TRUE};\nstatic_assert(static_cast&lt;bool&gt;(~b) == false);\nstatic_assert(static_cast&lt;bool&gt;(~true) == true);\nstatic_assert(std::to_underlying(~b) == false);\nstatic_assert(static_cast&lt;bool&gt;(~std::to_underlying(b)) == true);</code></pre>\n<p>In contrast, Clang and MSVC will evaluate the result of all four of these complement-and-cast combinations as <code>true</code>, which is what the standard says they must be. Full godbolt comparison here. This seems to be a fairly long-lived conformance bug in gcc.</p>\n<h2 id=\"unscoped-enums-promotion-and-undefined-behaviour\">Unscoped enums, promotion, and undefined behaviour</h2>\n<p>But there is one place in the standard where integral promotion rules go further and introduce a new vector for undefined behaviour to enter into your program when doing these bitmask operations. Consider this code:</p>\n<pre><code>enum nums{\n    zero,\n    one,\n    two,\n    three,\n};\n//This is implementation defined but holds on gcc and Clang.\nstatic_assert(std::is_same_v&lt;std::underlying_type_t&lt;nums&gt;, unsigned int&gt;);\n//nums uses an unsigned int as its base, therefore the entire domain of representable values is &gt;= 0.\n//So let&#39;s test this:\nstatic_assert(~one &gt;= 0); //FAILS</code></pre>\n<p>Godbolt here</p>\n<p>Looking at the diagnostic, gcc gives us:</p>\n<pre><code>&lt;source&gt;:15:20: error: static assertion failed\n   15 | static_assert(~one &gt;= 0); //FAILS\n      |               ~~~~~^~~~\n  • the comparison reduces to &#39;(-2 &gt;= 0)&#39;</code></pre>\n<p>So what happened here? Well, the passage in the standard which covers integral promotion, [conv.prom], carves out a special bullet for unscoped enumeration types with no fixed underlying type to behave differently from integer types when promoting. If the entire range of values can be stored in an <code>int</code>, then that is the type which it promotes to, then it tries <code>unsigned int</code>, and if that fails it repeats this signed-then-unsigned pattern for <code>long</code> and <code>long long</code> until it finds a suitable type. But, where this differs from plain integer types is that it only considers the effective range of representable values; and so an enum backed by <code>unsigned int</code> will promote to <code>int</code> regardless of the normal conversion ranking which would forbid it for integral types. As before, the fact that you are calling the builtin operator via <code>~one</code> will unavoidably promote it to <code>int</code>, its complement is calculated as <code>-2</code>, and then the comparison is performed between two <code>int</code>s, and fails. If instead you convert to the underlying type first then you really see the asymmetry - <code>static_assert(~std::to_underlying(one) &gt;= 0);</code> succeeds, and is so vacuously true that gcc even warns about its redundancy.</p>\n<p>But this is only half the battle, and we need to talk about converting the result of our bitwise operation back to the enum type, and this is where UB creeps in. An unscoped enum with no specified underlying type defines itself in terms of a valid range of values determined by its enumerators independently of the range of whatever actual type is used by the compiler to back it. [dcl.enum] tells us the value range of such an enum is the value range of a hypothetical integer type of width M, where M is the minimum bits required to represent all enumerators. To demonstrate, consider a few examples:</p>\n<pre><code>enum small{ //Range of enumerators is 0..1, so value range is that of an unsigned, one-bit integer.\n    a = 0,\n    b = 1,\n};\nenum medium{ //Range of enumerators is 0..7, so value range is that of an unsigned, three-bit integer\n    c = 0,\n    d = 4,\n    e = 7\n};\nenum large{ //Range of enumerators is -1..100, so value range is that of a signed, eight-bit integer\n    f = -1,\n    g = 10,\n    h = 100,\n};</code></pre>\n<p>Even though your compiler will quite likely store these enums as <code>unsigned int</code> and <code>int</code>, only a subset of possible representable values is valid to use - that of the M-width integer. [expr.static.cast] in the standard makes it clear that it is undefined behaviour to cast a value outside of that range to the enum type. So, using the enumerators of the above example:</p>\n<pre><code>static_cast&lt;small&gt;(1); //Well-defined\nstatic_cast&lt;small&gt;(2); //UB\nstatic_cast&lt;medium&gt;(3); //Well-defined: while not a value of an enumerator, it is within the range of an unsigned, three-bit integer\nstatic_cast&lt;medium&gt;(8); //UB\nstatic_cast&lt;large&gt;(-128); //Well-defined\nstatic_cast&lt;large&gt;(128); //UB</code></pre>\n<p>This is our danger zone. The bitwise operators, in particular <code>operator~</code>, can produce values which are out of the well-defined value range for an <code>enum</code>, and user-defined operators which attempt to cast this value back down to the enum type can invoke UB.</p>\n<p>But, I hear you cry, what about the example earlier with <code>~one</code>? We saw that give a value of <code>-2</code> which is outside the value range of <code>nums</code>. This is true, but here is where integral promotion actually saves us - <code>one</code> was promoted to <code>int</code> before we calculated its complement, and it was never cast back down again to invoke our UB. We only get into dangerous territory with a user-defined operator which converts the result of the operation back down to the original enum type.</p>\n<p>It is also important to note that this UB risk only applies to unscoped “plain” enums with no fixed underlying type. All scoped enum types have a fixed underlying type, even if they don’t specify it (in that case, <code>int</code>); and all unscoped <code>enum</code> types which do specify a fixed underlying type inherit that type’s value range instead of inventing their own.</p>\n<h2 id=\"avoiding-this-in-your-own-code\">Avoiding this in your own code</h2>\n<p>Let’s say that, like P4313, you are adding bitmask operations to enums for your own code. Now that we have more weirdness in this area of C++, how would you go about making sure that your code is correct and unconfusing? Avoiding the <code>bool</code> case is simple - just constrain away that the underlying type can’t be <code>bool</code>, either for all enums or for the complement operator. But the standard doesn’t come with a handy type trait or reflection metafunction to specifically detect an unscoped enum with no fixed underlying type. Fortunately we can make one:</p>\n<pre><code>template&lt;typename E&gt;\nconcept unfixed_enum = std::is_enum_v&lt;E&gt; &amp;&amp; !requires { E{0}; };</code></pre>\n<p>[dcl.init.list] only permits this initialization from a scalar for enums with a fixed underlying type, and 0 is the only possible value which is representable for all possible enums. To cover the edge cases, the standard defines an enum with no enumerators as having the value range of an unsigned, one-bit integer; and the thing which excludes 1 as a possible value is <code>enum E{ a = -1 };</code>, which has a range <code>[-1, 0]</code>. Be sure not to forget the <code>std::is_enum_v</code>. After all, there are many types for which <code>T{0}</code> is ill-formed, and most of them are not enums.</p>\n<p>This post has mostly been focused on the bitwise complement operator, but the UB trap of an unfixed enum also applies to the bitwise left shift operator. If you take the route of constraining per operation, don’t let it slip your mind.</p>\n<p>This brings us back once again to P4313R1. At time of writing, the concept which constrains these operators only constrains the type to be some enumeration type annotated with <code>[[=std::bitmask_type]]</code>. As such, it will allow confusing behaviour on enum types which are backed by <code>bool</code>, and potential UB on plain enums with no fixed underlying type. If the authors don’t want to standardise the above init-list trick, they could constrain it on <code>std::is_scoped_enum</code> as a conservative approach to prevent users from having easy access to the UB, since all unscoped enums can use the builtin bitwise operators anyway (albeit returning <code>int</code>). They could also constrain the underlying type to not be <code>bool</code> to minimise the confusion; particularly as the diagnostic which would normally get issued for complementing a <code>bool</code> in user code would be suppressed by default if it came from a system header. I do intend to contact the authors of P4313 about this to see if they want to add these constraints to their paper.</p>\n<h2 id=\"aside-what-about-floating-point\">Aside: What about floating point?</h2>\n<p>You might be wondering how the standard defines conversions of floating point numbers to these <code>bool</code>-backed <code>enum</code>s. It is perhaps unsurprising - [expr.static.cast] requires that the behaviour is equivalent to first converting the floating point value to the underlying type of the enum, and then converting it to the enum type; pointing the user to [conv.fpint] which has an explicit note directing readers to our old friend [conv.bool]. Per the standard, this should mean that any floating point value other than exactly <code>0.0</code> or <code>-0.0</code> would convert to <code>boolean::TRUE</code>, otherwise we end up back at <code>boolean::FALSE</code>.</p>\n<p>So let’s start small:</p>\n<pre><code>enum class boolean : bool {\n    FALSE,\n    TRUE,\n};\nstatic_assert(static_cast&lt;boolean&gt;(0.0) == boolean::FALSE);\nstatic_assert(static_cast&lt;boolean&gt;(0.5) == boolean::TRUE);\nstatic_assert(static_cast&lt;boolean&gt;(2.5) == boolean::TRUE);</code></pre>\n<p>Godbolt here.</p>\n<p>Both Clang and gcc reject this code. The errors are that <code>(boolean)2.5e+0</code> is not a constant expression; and <code>static_cast&lt;boolean&gt;(0.5) == boolean::TRUE</code> is wrong, and is instead equal to <code>boolean::FALSE</code>. MSVC accepts the above code.</p>\n<p>But let’s go further and inspect what actually gets stored there. We write a function to examine the bit pattern generated from these casts, then try some values:</p>\n<pre><code>void show(double d) {\n    const boolean e = static_cast&lt;boolean&gt;(d);\n    unsigned char byte {};\n    std::memcpy(&amp;byte, &amp;e, 1);\n    std::println(&quot;static_cast&lt;boolean&gt;({:7.1f})  stored byte {:3}   required {}&quot;,\n               d, byte, (d != 0.0) ? 1 : 0);\n}\nint main() {\n    show(0.0);\n    show(0.5);\n    show(1.0);\n    show(2.5);\n    show(3.0);\n    show(256.0);\n    show(-2.5);\n}</code></pre>\n<p>Godbolt here.</p>\n<p>The above code compiles without warning on all three compilers, but while MSVC again does the right thing in all cases, gcc and Clang’s behaviour is much more worrisome. Looking at the output, we see that gcc will always just truncate the value to an integer, then load that bit pattern into the resulting <code>boolean</code>. So <code>2.5</code> truncates to <code>2</code> and gives a <code>boolean</code> whose underlying bit pattern is <code>2</code>. Clang does the same thing on unoptimised builds, but when optimisation is turned on, the truncated value then goes through the [conv.bool] transformation as normal and produces the right answer for all non-zero values outside of the range (-1, 1).</p>\n<p>This is more concerning than some funky bit patterns, however. The valid range of <code>boolean</code> is [0, 1]. It is UB to read objects with bit patterns outside of this range. But, I hear you ask, what happens if we take these <code>boolean</code>s and cast them back to <code>bool</code>, triggering a boolean conversion with no initial floating point state? MSVC again does the right thing; gcc doesn’t modify the bit patterns, leaving us with <code>bool</code> which are out of range of the type (and therefore UB to read); and Clang is where the fun happens again. Running the above code with a cast from <code>e</code> to a <code>bool</code>, and then memcpy-ing that <code>bool</code> into the <code>unsigned char</code>, we get this result for unoptimised builds:</p>\n<div class=\"table-wrap\"><table><thead><tr><th></th><th>0.0</th><th>0.5</th><th>1.0</th><th>2.5</th><th>3.0</th><th>256.0</th><th>-2.5</th></tr></thead><tbody><tr><td>required</td><td>0</td><td>1</td><td>1</td><td>1</td><td>1</td><td>1</td><td>1</td></tr><tr><td>gcc 14 / 15 / 16</td><td>0</td><td>0</td><td>1</td><td>2</td><td>3</td><td>0</td><td>254</td></tr><tr><td>clang 19 / 20 / 21</td><td>0</td><td>0</td><td>1</td><td>0</td><td>1</td><td>0</td><td>0</td></tr><tr><td>clang 23.1.1</td><td>0</td><td>0</td><td>1</td><td>1</td><td>1</td><td>0</td><td>1</td></tr></tbody></table></div>\n<p>Versions of Clang before 23 appear to simply perform <code>trunc(d) &amp; 1</code>; meaning that after truncation, odd numbers are <code>true</code> and even numbers are <code>false</code>. Clang 23 performs <code>(trunc(d) % 256) != 0</code>, narrowing to a byte first. So <code>256.0</code> becomes <code>FALSE</code> and <code>257.0</code> becomes <code>TRUE</code>. Optimised builds act as before - truncating then doing the proper boolean conversion.</p>\n<p>Ultimately this all comes to a rather absurd head. Consider the below code:</p>\n<pre><code>#include &lt;print&gt;\nenum class boolean : bool {\n    FALSE,\n    TRUE,\n};\n//Force this to be runtime\nboolean make(double d) {\n    return static_cast&lt;boolean&gt;(d);\n}\nint main() {\n    const boolean e = make(2.5);\n    std::println(&quot;e == boolean::TRUE  : {}&quot;, e == boolean::TRUE);\n    std::println(&quot;e == boolean::FALSE : {}&quot;, e == boolean::FALSE);\n    const bool b = static_cast&lt;bool&gt;(e);\n    std::println(&quot;b == true  : {}&quot;, b == true);\n    std::println(&quot;b == false : {}&quot;, b == false);\n    std::println(&quot;b + 0      : {}&quot;, b + 0);\n}</code></pre>\n<p>Godbolt here.</p>\n<p>MSVC again leads the pack in giving the correct answer. Clang gets it exactly wrong and thinks that <code>e</code> is <code>FALSE</code> and <code>b</code> is <code>false</code>. gcc takes things up a notch, by providing an instance of an <code>enum</code> which compares equal to all of its enumerators, and a <code>bool</code> which is neither <code>true</code> nor <code>false</code>, until you turn on the optimiser and get a <code>bool</code> which is both <code>true</code> and <code>false</code>.</p>\n<p>And to hit all the obligatory targets of any conversation on floating point, infinity and NaN show as <code>0</code> and become <code>FALSE</code> as <code>boolean</code> and so cast to <code>false</code> as <code>bool</code> on gcc and Clang; and give <code>1</code> and therefore <code>TRUE</code> and <code>true</code> on MSVC.</p>\n<p>In lighter news, Clang trunk seems to have fixed the above example to behave correctly, so some of the issues described in this section should be patched out soon.</p>\n<h2 id=\"conclusion\">Conclusion</h2>\n<p>What did we learn from all this? Perhaps that even sensible and uncontentious pure-library-level papers such as P4313 should be on their guard against a footgun from some exotic C++ edge case. Or perhaps, in more practical terms:</p>\n<ul><li>Integral promotion is unavoidable. If you think you are operating on an integer which is smaller than <code>int</code> , there’s a good chance it became an<code>int</code> right under your nose.</li><li>The bitwise complement of a <code>bool</code> is always<code>true</code> , even when wrapped in an enum. However you should not rely on this behaviour as gcc has a longstanding bug which gets this wrong (or right, if you prefer logic to C++).</li><li>Unscoped enumeration types with no fixed underlying type have a range of valid values which may be smaller than that of whatever type actually underlies them.</li></ul>\n<ol><li>To be clear, the ranking order of integral promotion is more complex than just “convert to <code>int</code> ”. For an integer type of lower conversion rank (not size) than<code>int</code> , if all values of that type can be represented as<code>int</code> then<code>int</code> is chosen. Otherwise<code>unsigned int</code> is chosen. In practice this will usually come out as<code>int</code> ; but on implementations where<code>int</code> and<code>short</code> are the same size,<code>unsigned short</code> will promote directly to<code>unsigned int</code> . And for other types such as the charN_t family, conversion can proceed to longer integer types such as<code>long</code> and<code>long long</code> .<code>bool</code> is singled out as a special case and always promotes to<code>int</code> . ↩︎</li></ol>","headings":[{"level":1,"text":"The complement of true is true, except when it's false","id":"the-complement-of-true-is-true-except-when-it-s-false"},{"level":2,"text":"Putting it together","id":"putting-it-together"},{"level":2,"text":"When it’s false","id":"when-it-s-false"},{"level":2,"text":"Unscoped enums, promotion, and undefined behaviour","id":"unscoped-enums-promotion-and-undefined-behaviour"},{"level":2,"text":"Avoiding this in your own code","id":"avoiding-this-in-your-own-code"},{"level":2,"text":"Aside: What about floating point?","id":"aside-what-about-floating-point"},{"level":2,"text":"Conclusion","id":"conclusion"}]}}