プログラミング
trueの補数はfalseの時を除いてtrueである
The complement of true is true, except when it's false (dryperspective.github.io)
要約
C++における整数昇格(integral promotion)とブーリアン変換の挙動について解説しています。特に、`bool`型や`bool`を基底型とするenum型に対してビット演算子`~`(補数)を適用した場合、期待される結果と異なる挙動を示すケースがあることを、具体的なコード例とコンパイラの挙動を交えながら説明しています。
全文翻訳
目次
まとめ
falseの場合
スコープ外のenum、昇格、未定義の動作
自身のコードでこれを避ける
補足: 浮動小数点数はどうか?
結論
最近、P4313R1という標準提案ペーパーを見ました。これは、C++26のアノテーションを使ってオプトインする、enumのためのビットマスク演算のセットを追加するものです。そのペーパーからの例を挙げると、以下のようなコードがあるとします。
enum class [[=std::bitmask_type]] Permission {
None = 0,
Read = 1 << 0,
Write = 1 << 1,
Execute = 1 << 2,
};
[[=std::bitmask_type]]アノテーションは、Permissionにアクセス可能なビット演算子のセットを自動的に付与し、ユーザーが自分でそれらを記述する必要がなくなります。これが、C++の整数演算の曖昧なアンダーベリーについて考えさせられました。C++コンパイラは、組み込み操作として、任意の整数型に対してビット演算を喜んで計算します。これは、wchar_t、UTF文字型char8_tからchar32_t、そしてboolのような、伝統的に整数とは考えない型にも及びます。しかし、実際には見た目よりも少し多くのことが起こっています。なぜなら、a | bのような操作を実行しようとしたとき、aとbの型がintより小さい整数型の場合、言語はaとbのビットパターンを直接操作するわけではないからです。それらは整数昇格(integral promotion)を受けます。つまり、intに昇格された中間状態になり、これらの2つのintのビットパターンが結合され、結果はintとして返されます。
1 以下のようなコードを考えてみましょう。
// 2つのshort
constexpr short perm_A {1 << 0};
constexpr short perm_B {1 << 1};
// そしてそれらのビット演算の結果の型はintです
static_assert(std::same_as<decltype(perm_A | perm_B), int>);
ほとんどの場合、これは無害です。上記のperm_A | perm_Bをshortにキャストし直すと、不要なバイトは切り捨てられ、perm_Aとperm_Bを直接shortとして結合した場合と同じ値を持つshortが残ります。そして、型の整合性を保ち、サイレントなナローイング変換の厄介なバグを避けたい場合は、ビット演算の結果を元の型にstatic_castする習慣をつけることができます。しかし、ここで重要なのは、整数昇格はオプションではないということです。C++の他のほとんどの領域とは異なり、開発者は選択肢を与えられません。これらの操作のために、型は避けられずに昇格されます。
これがboolに特に危険である第二の理由は、ブーリアン変換です。これは特別なケースで、切り捨てではなく、ゼロをfalseに、非ゼロ値をtrueに明示的に変換します。これらの非ゼロ値の場合、以前に格納されていたビットパターンは破棄され、整数値1を持つtrueに置き換えられます。したがって、次のようなコードを取るとします。
int x{10};
bool b{static_cast<bool>(x)};
そして生成されたアセンブリ(この場合は最適化されていないx86-64 gcc 16.2)を見ると、次のようになります。
mov DWORD PTR [rbp-4], 10 ; 値10を格納
cmp DWORD PTR [rbp-4], 0 ; そして0と比較、xがゼロならZFを設定
setne al ; ZFがクリアされていればALに1を書き込む
mov BYTE PTR [rbp-5], al ; 結果を格納
標準(特に[conv.bool])は、boolへの変換を、それを生成するために使用されたビットパターンに関わらず、boolが持ちうる唯一の値であるtrueとfalseに基づいています。
まとめ
これらすべてをカバーした上で、~trueを評価してから結果をboolにキャストすると何が起こるかについて話しましょう。
trueの値は値1のintに昇格されます。
C++20以降では2の補数動作が要求されるため、intの1の補数は-2として計算されます。
値-2はboolにダウンキャストされ、ブーリアン変換を受け、-2は非ゼロなのでtrueになります。
これで、trueの補数はtrueである、つまりC++ではstatic_cast<bool>(~true) == trueとなります。
これでenumに戻ります。enumは、boolを含む任意の整数型を基底型として使用することが許可されています。では、定義してみましょう。
enum class [[=std::bitmask_type]] boolean : bool{
FALSE,
TRUE,
};
P4313R1に記載されているビット単位補数演算子も見てみましょう。
template<bitmask-like T>
constexpr T operator~ (T lhs) noexcept {
return static_cast<T>(~to_underlying(lhs));
}
ここまでで、その演算子の動作は馴染みのある形になっているはずです。まずenumを基底型(この場合はbool)に変換し、次にその型がintよりランクが低い場合は整数昇格が適用され、ビット演算を実行し、enum型にキャストし直します。予想通りです。
static_assert(~boolean::TRUE == boolean::TRUE);
Godboltはこちら。
これは、言語における少し紛らわしいコーナーケースになる可能性があります。
falseの場合
ここで問題がさらに複雑になる例外的なケースが1つあります。gccで同じ例を実行しようとすると、異なる結果が得られます。
static_assert(~boolean::TRUE == boolean::FALSE);
Godboltはこちら。
gccでは一体何が起こっているのでしょうか?操作をランタイムに移動するために、ランタイム値としてそれに依存する2つの関数を定義してみましょう。
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);
}
そしてアセンブリ(再び最適化されていない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 ; [conv.bool]からの同じsetneパターン
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 ; 最下位ビットをeaxに格納
mov rdx, QWORD PTR [rbp-16]
mov BYTE PTR [rdx], al ; そしてalをout-paramに移動
nop
pop rbp
ret
型が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);
これに対し、ClangとMSVCは、これら4つの補数とキャストの組み合わせの結果すべてをtrueと評価します。これは標準が要求するものです。完全なGodbolt比較はこちら。これはgccにおけるかなり長期間続く適合性バグのようです。
スコープ外のenum、昇格、未定義の動作
しかし、標準の1つの箇所では、整数昇格のルールがさらに進み、これらのビットマスク操作を行う際に、プログラムに未定義の動作が入り込む新しいベクトルを導入しています。このコードを考えてみましょう。
enum nums{
zero,
one,
two,
three,
};
// これは実装定義ですが、gccとClangでは有効です。
static_assert(std::is_same_v<std::underlying_type_t<nums>, unsigned int>);
// numsはunsigned intを基底として使用するため、表現可能な値の全範囲は0以上です。
// では、これをテストしてみましょう。
static_assert(~one >= 0); //失敗
Godboltはこちら。
診断を見ると、gccは次のようなエラーを出します。
<source>:15:20: error: static assertion failed
15 | static_assert(~one >= 0); //FAILS
| ~~~~~^~~~
• the comparison reduces to '(-2 >= 0)'
ここで何が起こったのでしょうか?標準の整数昇格をカバーする箇所、[conv.prom]は、固定された基底型を持たないスコープ外の列挙型が昇格する際に整数型とは異なる動作をするための特別な項目を設けています。表現可能な値の全範囲がintに格納できる場合、その型に昇格され、次にunsigned intを試み、それが失敗した場合は、適切な型が見つかるまで、この符号付き・符号なしのパターンをlongとlong longで繰り返します。しかし、これは通常の整数型とは異なり、表現可能な値の有効な範囲のみを考慮します。そのため、enum ba