TL;DR: enumをビットフラグに使うのは不適切だが、それが最終的に行き着くところだ。
列挙型のビットフラグタイプについては(C++だけでなく)多くの議論が交わされてきた。私たちの技術コミュニティは、複数の言語にわたって、この問題の正しい表現方法は次のようなものだと考えているようだ:
// I somehow opt in for bitwise operations on this type
enum struct permissions {
NONE = 0,
READ = 1 << 0,
WRITE = 1 << 1,
EXEC = 1 << 2
};
// then, I can use "regular bitwise operations"
auto p = permissions::READ | permissions::WRITE;
if ((p & permissions::READ) == permissions::READ) {
// ...
}
C++では、ADLで発見される関数宣言の追加、リフレクションアノテーションの追加、マクロによる演算子の提供など、さまざまな方法でオプトインが行われる。
私たちはC++でもこの方向に進むかもしれない。標準の更新ごとに新しい手法が導入され、少しずつこの形に近づきやすくなっており、C++26のリフレクションがその最新例だ。P4313は、このスタイルのenumフラグを提案するものだ。
これは実用的なアプローチだと思う(つまり、私たちはそれを実現するだろう)が、最終的には誤った方向だと感じる。私はbitsetを使う方がはるかに優れた解決策だと考える。残念ながらstd::bitsetはそこまで到達できず、それが私たちがenumビットフラグに行き着くかもしれない理由だ。
enumビットフラグの利点として通常挙げられるのは:
- 型安全性(少なくともスコープ付き列挙型を使った場合)。異なる型の値の間で暗黙の変換は発生しない。permissionsを他の値と誤って混同することはできない。
- 使用時の使いやすさ。これらの使用法は、通常の整数を使う場合と同様に見える。(P4313では「通常のCスタイルの
int」と述べているが、clang-tidyのbugprone-signed-bitwiseチェックはこれに該当する。)
欠点は次のとおりだ:
- 実装時の使いやすさ。先述の通り、各C++標準で軽減されている。これはC++ライブラリ作成文化が軽視する点でもある。内部実装がどれだけ難しいかは問題ではない、という考え方だ。しかし、自分が所有していない列挙型にビット演算を適用するのは、やはり面倒な作業になる。
- 美観。私はこれらの使用法が通常の整数の使用法のように見えることを望まない。なぜなら通常の整数の使用法には危険が伴うからだ。これが次の点につながる。
- 使用時の使いやすさ。ビット演算子は歴史的理由により、CおよびC++で「誤った」優先順位を持つことで有名だ。コンパイラ警告を使うべきだ。皮肉なことに、P4313で使用されている例示コードにもこの演算子優先順位のバグが現れている(私は上で修正した)!
- (おそらく最も重要な点として)概念の混同。
enumビットフラグは簡単だが単純ではない。ビットに名前を付けること(実際に望むこと)と、それを表現することを混同している。確かに私たちの責任ではない。これはCの遺産であり、複数の言語が現在もこれを強化している。しかし正しいわけでもない。私たちは「型にメンバAとBがある」と言いながら、A|Bもその型のメンバであるかのように振る舞う。しかしこれは即座に型安全性を損なう。表現と命名を混同しているのだ。
では代替案は何だろうか。残念ながらstd::bitsetは十分ではないし、ほぼ確実に十分にできるようにすることはできない。しかし、より良いbitsetなら可能であり、完全に実装可能だ。私が望む形は次のようになる。
enum struct permissions {
READ, WRITE, EXEC,
MAX // here's the conventional opt-in
};
// bitset can take an enum, and preserve it (type safety)
using perms_t = bitset<permissions::MAX>;
// how about a tag constructor?
auto p = perms_t{place_bits, permissions::READ, permissions::WRITE};
if (p[permissions::READ]) {
// ...
}
これで列挙型アプローチの問題のほとんど(私は控えめに「ほとんど」と言っている)が解決されると考えている。
- 型安全性。ここで他の
bitsetや整数型からperms_tを代入することはできない。これはサードパーティライブラリで見つかる非スコープenumでも同様だ。 - 使用時の使いやすさ。
operator[]はビット演算子の優先順位の問題を抱えない。列挙値は事前にシフトする必要がなく、潜在的なエラーの原因を避けられる。 - 関心事の正しい分離。列挙型はビットの名前を付ける。
bitsetはそれらを表現する。私たちはREAD|WRITEがpermissionsのメンバであるかのように振る舞わない。
他の点も満たしている。実装はenumアプローチと同等か、それ以上に容易だ。
残る唯一の問題は、所有していないサードパーティのコードベースのenumをビットとして扱うことの使いやすさだ。これは正当な批判だと思う。私たちは常に、何らかの形で面倒な点が出てくると考えている。enumビットフラグアプローチでは、サードパーティの名前空間にADLで発見される関数を注入するような侵入的なオプトインメカニズムを使うだろう。bitsetアプローチでは侵入的ではないが、「max」値(またはここに示していない他のAPI形状のバリエーションでビットの数を指定する)を提供する必要がある。
どちらの手法でも、上流のコードが変更されたときにコードを変更する必要が生じる可能性がある。バランスを考えればbitsetアプローチの方がやや簡単だと思う。特に、リフレクションを使って適切な「max」値を検出できる可能性があることを考慮すると。(サードパーティライブラリが意図的にenum値を隠蔽している場合は別だが、その場合はそもそもビットの名前を何に使えばよいのか?)
しかし、これだけではまだ十分ではないと考えているだろうし、その通りだ。なぜなら、これまでのところはこれらの拡張を加えただけのstd::bitsetを想像しているからだ。使いやすくするためには、このbitsetにはさらにいくつかの機能が必要だ。
表現にbitsetを使うなら、実際には基になるものを簡単に指定・取得する方法が必要だ(enumが基になる型を扱えるように)。なぜなら、結局のところ型はすべてコンパイル時の飾り物であり、ランタイムは気にしないからだ。これらのものは実際には内部で通常の整数である必要がある。
したがって、次のような形になる:
using B1 = bitset<8>; // 8 bits, a std::uint8_t underneath
using B2 = bitset<8, std::uint32_t>; // 8 bits, a std::uint32_t
using B3 = bitset<64>; // 64 bits, a std::uint64_t
using B4 = bitset<64, std::uint8_t>; // 64 bits, a std::array<std::uint8_t, 8>
enum struct permissions { READ, WRITE, EXEC, MAX };
using B = bitset<permissions::MAX, std::uint32_t>;
なぜなら、マイクロコントローラから大規模サーバーまで、プラットフォームに応じてサイズと速度のトレードオフを適切に行えるようにしたいからだ。
また、このものは実際にはシリアライズ形式に書き込みたい通常の整数なので、それを簡単に行う方法が必要だ。
auto perms = get_permissions(); // a bitset over permissions
auto serial_perms = perms.to<std::uint16_t>(); // a std::uint16_t for serialization
もちろん、他の用途のためにbitset型にさらにいくつかの便利な機能を追加することもできる。(例:一般的な方法で、すべてのビットがセットされたstd::bitsetを構築するのはなぜこんなに難しいのか?セットされたビットを反復処理するにはどうすればよいか?最も低い未セットのビットを見つけるにはどうすればよいか?)しかし、それは別の話だ。
以上だ。私はこのbitsetアプローチがenumフラグアプローチよりもはるかに優れていると思う。C++のバージョンが更新されるごとにenumフラグの実装の面倒な点が解消されても、依然として優れている。しかし、私たちは結局enumフラグに行き着くのだろう。そして、それをうまく活用するのだろう。
0 Comments
Log in to join the conversation.No comments yet. Be the first to share your thoughts.