TL;DR:使用 enum 作为位标志并不理想,但这正是我们最终会采用的方式。
关于枚举位标志类型(并不仅限于 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 位标志的原因。
enum 位标志的优点通常被总结为:
- 类型安全(至少在使用作用域枚举时)。不同类型的值之间不会发生隐式转换。我们不会意外地将权限与其它值混淆。
- 使用的人体工程学。这些东西的使用方式看起来就像使用普通的整数。(我注意到 P4313 提到 “普通的 C 风格
int”,尽管有 clang-tidy 的 bugprone-signed-bitwise 检查。)
我认为缺点如下:
- 实现的 ergonomics。正如我所说,随着每个 C++ 标准的发布,这一问题都在减轻。这也是 C++ 库作者文化所忽视的一点:谁在乎底层实现有多困难?但对于不属于自己的枚举类型,应用位运算仍然会有些烦人。
- 美观性。我不想让这些东西的使用方式看起来像使用普通的整数,因为使用普通整数充满风险。这就引出了……
- 使用的人体工程学。众所周知,由于历史原因,C 和 C++ 中的按位运算符具有“错误”的优先级。是的,请使用编译器警告。讽刺的是,P4313 中使用的示例代码 就展示了这种运算符优先级错误(我在上面已修正)!
- (或许最重要的是……)概念的混淆。
enum位标志是容易但不简单的。它们将命名位(我们真正想要的)与表示位混为一谈。诚然,这不是我们的错;这是 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 视为位。公平的批评。我认为我们总会发现有一些方法会让人感到烦人。对于 enum 位标志方法,我会使用侵入式的启用机制,比如将通过 ADL 找到的函数注入第三方命名空间。对于 bitset 方法,它是非侵入式的,但我仍然需要提供一个“最大”值(或者以其他未在此展示的 API 形式指定位数)。
这两种技术都意味着,当上游代码发生变化时,我可能需要修改代码。我认为总体而言,bitset 方法稍微容易一些,尤其是考虑到我们可能可以使用反射来发现一个合适的“最大”值。(除非第三方库故意将 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.