真の補集合は、それが偽でない限り真である
C++で~trueの結果がtrueになるのは、ほとんどの人の予想に反します。本記事ではその原因を掘り下げ、スコープなし列挙型の整数昇格、ビット単位の否定、そしてこの動作がもはや適切に定義されなくなるケースについて解説します。
日本語
コピー
2026年10月1日 · Matthew Taylor | 編集
目次
最近 P4313R1 を読んだ。enum にビットマスク演算の一式を追加する標準提案で、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 に昇格し、その状態で 2 つの 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 として直接統合した値と完全に一致する。型を常に一致させ、暗黙の縮小変換という厄介なバグを避けたいなら、ビット演算の結果 static_cast を元の型に戻す習慣をつければいい。だが要点は、整数昇格が任意ではないことだ。C++ の他のほとんどの領域と違い、開発者に選択権はない。これらの演算では型が不可避的に昇格される。
これが bool にとって危険な二つ目の理由はブール変換だ。これは特殊なケースで、切り捨ては行わず、ゼロを false に、非ゼロ値を true に明示的に変換する。これらの非ゼロ値では、以前格納されていたビットパターンが破棄され、整数値 1 を持つ true に置き換わる。では、次のコードを書いたとして:
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 はこちら。
これらはすべて、この言語のやや紛らわしい辺角ケースになりうる。
false になる場合 #
問題を複雑にする例外が一つある。同じ例を 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 は、これら4通りの反転と変換の組み合わせをすべて true と評価する。標準が要求しているのはこちらだ。godbolt での比較はこちら。gcc のかねてからの準拠性バグと思われる。
unscoped enum、整数昇格、未定義動作 #
だが標準には、整数昇格の規則がさらに踏み込んでくる箇所があり、こうしたビットマスク演算をするときに新たな未定義動作の入り口を持ち込む。次のコードを見てほしい:
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) は、固定の基底型を持たない unscoped enum 型のために特別扱いを設けていて、昇格時に整数型とは違う振る舞いをさせる。すべての値が int に収まるならその型に昇格し、収まらなければ unsigned int を試し、それでもだめなら同じ「まず符号付き、次に符号なし」のパターンを long と long long に対して繰り返し、適切な型が見つかるまで続ける。ただし通常の整数と違って、考慮されるのは表現可能な値の実際の範囲だけだ。だから基底型が unsigned int の enum は、通常の変換ランクを無視して int に昇格する。そのランクに従えば、整数はこう昇格できないことになっているのに。前と同じく、~one 経由で組み込み演算子を呼ぶと必ず int に昇格し、その補数は -2 で計算され、その後 int 同士の比較になって失敗する。先に基底型へ変換すれば、この非対称性はあからさまになる——static_assert(~std::to_underlying(one) >= 0); は成功し、しかもあまりに空しく成立するので gcc でさえ冗長だと警告する。
しかしこれは戦いの半分で、ビット演算の結果を enum 型に戻す方法についても話さなければならない。UB が忍び込むのはここだ。基底型を指定していない unscoped enum では、定義される有効値の範囲は列挙子によって決まり、コンパイラが実際にそれを支えるために使う型の範囲とは無関係だ。[$$ 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 コンパイラはこれらの enum をまず間違いなく `unsigned int` や `int` として格納するが、表現可能な値のうち合法なのはその一部、すなわち M ビット整数の値だけだ。[$$ expr.static.cast $$](https://eel.is/c++draft/expr.static.cast#8) は、その範囲を超える値を enum 型に変換するのは未定義動作だと標準で明言している。上の例の列挙子を使うと: ```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` の well-defined な値範囲を超える値を生み得るし、ユーザー定義演算子がその値を enum 型に戻そうとすれば UB を踏む。 とはいえ、前の `~one` の例はどうなんだ、と言いたくなるかもしれない。あれは `-2` を返し、`nums` の値範囲を超えていた。その通りだが、ここでまさに整数昇格が救ってくれる——`one` は補数を計算する前に `int` へ昇格し、その後 enum 型に戻されることはないので UB にはならない。危険地帯に入るのは、ユーザー定義演算子で演算結果を元の enum 型に戻すときだけだ。 もう一点重要だ。この UB リスクは固定の基底型を持たない unscoped な「普通の」enum にしか当てはまらない。scoped enum 型はすべて固定の基底型を持ち、明示的に指定しなくてもそうなる(その場合は `int`)。また、固定の基底型を明示的に指定した unscoped な `enum` 型はすべて、独自の範囲を作らずその型の値範囲を継承する。 ## 自分のコードでこの罠を避ける P4313 のように、自分のコードに enum のビットマスク演算を追加しようとしているとしよう。C++ はこの領域にも奇妙さを追加しているのだから、コードを正しく、かつ混乱させないものにするにはどうすればいいか。`bool` のケースを避けるのは簡単だ——基底型を `bool` にできないよう制約すればいい。すべての enum に対してでも、反転演算子だけに対してでも構わない。だが標準には、「固定の基底型を持たない unscoped enum」を検出するための既製の 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 521: … M ビット整数の値だけだ。[$̲$ expr.static.c…dcl.init.list
](https://eel.is/c++draft/dcl.init.list#3.8) は基底型が固定された enum だけがスカラーからこう初期化することを許し、0 はすべてのあり得る enum が表現できる唯一の値だ。境界ケースをカバーするため、標準は列挙子を持たない enum の値範囲を符号なし1ビット整数と同等と定めている。そして 1 を可能な値から排除しているのが `enum E{ a = -1 };` で、その範囲は `[-1, 0]` だ。あの `std::is_enum_v` も忘れずに。`T{0}` に対して非 well-formed になる型は多く、その大半は enum ではない。 この記事は主にビット反転演算子を中心にしているが、基底型が固定されていない enum が仕込む UB の罠は、ビット左シフト演算子にも同様に当てはまる。演算子ごとに制約を加えるなら、これを漏らさないように。 ここでまた P4313R1 に戻る。この記事を書いている時点で、これらの演算子を制約する concept は、型が `[[=std::bitmask_type]]` で注釈された enum 型であることしか要求していない。したがって、基底型が `bool` の enum の紛らわしい挙動を放置し、固定の基底型を持たない普通の enum に潜在的な UB を許すことになる。著者が先ほどの init-list のトリックを標準化したくないなら、保守的に `std::is_scoped_enum` を制約して、ユーザーが UB を踏みにくくすることもできる——そもそも unscoped enum はすべて組み込みのビット演算子を使えるのだから(返るのは `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) は、まず浮動小数点値を enum の基底型に変換し、次に enum 型に変換するのと等価な挙動を要求している。読者は [ 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)。 このコードは 3 つのコンパイラすべてで警告なしにコンパイルできるが、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` を `unsigned char` に memcpy すると、最適化なしのビルドでは次のようになる: | | 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` を実行し、まず 1 バイトに窄化する。すると `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 はさらにその上を行く。gcc が出す `enum` は自分自身のすべての 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 にはこれを間違える長年のバグがあるからだ(論理を C++ より重んじるなら、正しくやっていると言ってもいい)。 - 固定の基底型を持たないスコープなし enum では、有効な値の範囲が実際の基底型の範囲より小さくなることがある。 ここで注意しておきたいのは、整数昇格の順位付けは「`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) へと導かれ、そ…