TL;DR:使用 enum 作為 bitflags 並不理想,但這就是我們最終會採用的方式。
許多電子文件都在闡述列舉位元旗標型別(不僅限於 C++)。身為跨多種語言的技術社群,我們似乎都認同正確的呈現方式應該類似這樣:
// 我以某種方式選擇在此型別上啟用位元運算
enum struct permissions {
NONE = 0,
READ = 1 << 0,
WRITE = 1 << 1,
EXEC = 1 << 2
};
// 接著,我可以使用「一般位元運算」
auto p = permissions::READ | permissions::WRITE;
if ((p & permissions::READ) == permissions::READ) {
// ...
}
在 C++ 中,選擇啟用位元運算的方式包括:新增可透過 ADL 找到的函式宣告、加入反射註解、使用巨集提供運算子,或是其他方式……
我們在 C++ 中很可能會走到這裡。隨著標準的更新,我們獲得了新技術,讓我們更容易達成此目標,而 C++26 的反射就是最新的例子。P4313 是一份針對此風格的 enum 旗標提案。
我認為這是一種可行的方法(也就是我們會讓它運作),但最終我覺得這是錯誤的方向。我認為使用 bitset 才是更好的解決方案。不幸的是,std::bitset 無法達到此目標,這也是我們可能最終採用 enum bitflags 的原因。
通常提到 enum bitflags 的優點包括:
- 型別安全(至少在使用限定範圍列舉時)。不同型別的值之間沒有隱式轉換。我們不會意外將 permissions 與其他值混淆。
- 使用便利性。使用這些東西看起來就像使用一般整數。(我注意到 P4313 提到「純 C 風格的
int」,儘管 clang-tidy 有 bugprone-signed-bitwise 檢查。)
我認為缺點包括:
- 實作便利性。如我所說,隨著每個 C++ 標準的更新,這一點已有所改善。這也是 C++ 函式庫作者文化所忽視的:誰在乎底層實作有多困難?但對於不屬於自己的列舉型別套用位元運算,仍然會有點麻煩。
- 美觀性。我不希望使用這些東西看起來像使用一般整數,因為使用一般整數充滿危險。這導致……
- 使用便利性。位元運算子因歷史原因在 C 和 C++ 中有「錯誤」的優先順序。是的,請使用編譯器警告。諷刺的是,P4313 中使用的範例程式碼就展現了這個運算子優先順序錯誤(我已在上面修正)!
- (或許是最重要的……)概念混淆。
enumbitflags 簡單易用,但不單純。它們將命名位元(我們真正想要的)與表示位元混為一談。確實,這不是我們的錯;這是 C 語言的遺留問題,多種語言至今仍在加深此問題。但這也是不正確的。我們說「這個型別有成員A和B」,然後假裝A|B也是該型別的成員。但這立刻破壞了型別安全:我們混淆了表示與命名。
那麼替代方案是什麼?不幸的是,std::bitset 無法滿足需求,而且很有可能無法被改造以滿足需求。但更好的 bitset 可以,而且完全可以實作。以下是我希望它看起來的樣子。
enum struct permissions {
READ, WRITE, EXEC,
MAX // 這是慣用的選擇啟用方式
};
// bitset 可以接受列舉,並保留它(型別安全)
using perms_t = bitset<permissions::MAX>;
// 如何使用標記建構子?
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 bitflags 方法,我會使用侵入式選擇啟用機制,例如將 ADL 找到的函式注入第三方命名空間。使用 bitset 方法,則是非侵入式的,但我仍然需要提供「max」值(或以本例未展示的其他 API 形式指定位元數)。
這兩種技術都意味著,當上游程式碼變更時,我可能需要修改程式碼。我認為總體而言,bitset 方法稍微容易一些,特別是我們可能可以使用反射來發現合適的「max」值。(除非第三方函式庫故意將 enum 值隱藏起來,而在這種情況下,我們又能用什麼來命名位元呢?)
但你可能還是覺得這不夠理想,而你說得對,因為到目前為止,你想像的只是 std::bitset 加上這些額外功能。為了使用方便,這個 bitset 還需要一些其他功能。
如果我們使用 bitset 作為表示方式,實際上我們希望能輕鬆指定並存取底層實作(就像 enum 可以處理其底層型別)。因為最終型別只是編譯時期的裝飾,執行時期並不在乎:這些東西確實需要在底層是純整數。
所以類似這樣:
using B1 = bitset<8>; // 8 位元,底層為 std::uint8_t
using B2 = bitset<8, std::uint32_t>; // 8 位元,底層為 std::uint32_t
using B3 = bitset<64>; // 64 位元,底層為 std::uint64_t
using B4 = bitset<64, std::uint8_t>; // 64 位元,底層為 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(); // 基於 permissions 的 bitset
auto serial_perms = perms.to<std::uint16_t>(); // 用於序列化的 std::uint16_t
我們當然可以為其他使用情境在 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.