C++ 枚举标志 vs 位集

22

使用位集(bitsets)相比使用枚举标志(enum flags)有哪些优缺点?

namespace Flag {
    enum State {
        Read   = 1 << 0,
        Write  = 1 << 1,
        Binary = 1 << 2,
    };
}

namespace Plain {
    enum State {
        Read,
        Write,
        Binary,
        Count
    };
}

int main()
{
    {
        unsigned int state = Flag::Read | Flag::Binary;
        std::cout << state << std::endl;

        state |= Flag::Write;
        state &= ~(Flag::Read | Flag::Binary);
        std::cout << state << std::endl;
    } {
        std::bitset<Plain::Count> state;
        state.set(Plain::Read);
        state.set(Plain::Binary);
        std::cout << state.to_ulong() << std::endl;

        state.flip();
        std::cout << state.to_ulong() << std::endl;
    }

    return 0;
}

就我目前所见,位集(bitsets)具有更方便的设置(set)/清除(clear)/翻转(flip)函数来处理,但枚举标志(enum-flags)的使用是一种更广泛的方法。

位集有哪些可能的缺点以及在我的日常代码中应该何时使用什么?


由于旗标是预先计算的,它们在测试中具有明显的优势。 - StoryTeller - Unslander Monica
2
我认为这完全取决于情况。它取决于用例、个人偏好、项目要求、使用的代码风格指南等等。如果是针对自己的项目,那么就按照自己最好的方式去做。不过我的建议是,在性能之前,首先考虑可读性、可维护性和正确性等因素。“足够好”通常就足够了。 - Some programmer dude
1
bitset 可以与 constexpr 一起使用吗?在这里可能会得到相同的时间。但是一般来说,由于它的平台无关性,bitset 较慢。 - Swift - Friday Pie
2
位集比裸位操作慢得多(在我的机器上慢了约24倍)。但我有另一个结果,其中位集几乎与汇编代码一样快。 - Gluttton
首先,这两个例子并不等价!你必须在翻转后显式设置读取和二进制标志才能真正获得等价性。因此,实际上,位集变量产生了更长的代码(多了四行)...当然,并不总是更短的代码更易于阅读。对我来说,由于我相当习惯于裸露的位操作,它与位集变量一样容易阅读,因此我更喜欢前者,但这是一个非常个人的问题... - Aconcagua
显示剩余6条评论
4个回答

13

无论是std::bitset还是c风格的enum都有管理标志的重要缺点。首先,让我们考虑下面的示例代码:

namespace Flag {
    enum State {
        Read   = 1 << 0,
        Write  = 1 << 1,
        Binary = 1 << 2,
    };
}

namespace Plain {
    enum State {
        Read,
        Write,
        Binary,
        Count
    };
}

void f(int);
void g(int);
void g(Flag::State);
void h(std::bitset<sizeof(Flag::State)>);

namespace system1 {
    Flag::State getFlags();
}
namespace system2 {
    Plain::State getFlags();
}

int main()
{
    f(Flag::Read);  // Flag::Read is implicitly converted to `int`, losing type safety
    f(Plain::Read); // Plain::Read is also implicitly converted to `int`

    auto state = Flag::Read | Flag::Write; // type is not `Flag::State` as one could expect, it is `int` instead
    g(state); // This function calls the `int` overload rather than the `Flag::State` overload

    auto system1State = system1::getFlags();
    auto system2State = system2::getFlags();
    if (system1State == system2State) {} // Compiles properly, but semantics are broken, `Flag::State`

    std::bitset<sizeof(Flag::State)> flagSet; // Notice that the type of bitset only indicates the amount of bits, there's no type safety here either
    std::bitset<sizeof(Plain::State)> plainSet;
    // f(flagSet); bitset doesn't implicitly convert to `int`, so this wouldn't compile which is slightly better than c-style `enum`

    flagSet.set(Flag::Read);    // No type safety, which means that bitset
    flagSet.reset(Plain::Read); // is willing to accept values from any enumeration

    h(flagSet);  // Both kinds of sets can be
    h(plainSet); // passed to the same function
}

即使你认为在简单的示例中很容易发现这些问题,但它们最终会潜伏在每个在 C 风格的枚举和 std::bitset 之上构建标志的代码库中。
那么,为了更好的类型安全性,你可以做什么呢?首先,C++11 的作用域枚举是一种改进的类型安全性方式。但它会大大阻碍便利性。解决方案的一部分是使用模板生成的按位运算符来处理作用域枚举。以下是一篇非常好的博客文章,它解释了如何使用并提供了工作代码https://www.justsoftwaresolutions.co.uk/cplusplus/using-enum-classes-as-bitfields.html 现在让我们看看这会是什么样子:
enum class FlagState {
    Read   = 1 << 0,
    Write  = 1 << 1,
    Binary = 1 << 2,
};
template<>
struct enable_bitmask_operators<FlagState>{
    static const bool enable=true;
};

enum class PlainState {
    Read,
    Write,
    Binary,
    Count
};

void f(int);
void g(int);
void g(FlagState);
FlagState h();

namespace system1 {
    FlagState getFlags();
}
namespace system2 {
    PlainState getFlags();
}

int main()
{
    f(FlagState::Read);  // Compile error, FlagState is not an `int`
    f(PlainState::Read); // Compile error, PlainState is not an `int`

    auto state = FlagState::Read | FlagState::Write; // type is `FlagState` as one could expect
    g(state); // This function calls the `FlagState` overload

    auto system1State = system1::getFlags();
    auto system2State = system2::getFlags();
    if (system1State == system2State) {} // Compile error, there is no `operator==(FlagState, PlainState)`

    auto someFlag = h();
    if (someFlag == FlagState::Read) {} // This compiles fine, but this is another type of recurring bug
}

这个例子的最后一行显示了一个仍然无法在编译时捕获的问题。在某些情况下,比较相等可能是真正需要的。但大多数时候,真正意思是 if ((someFlag & FlagState::Read) == FlagState::Read)
为了解决这个问题,我们必须区分枚举类型和位掩码类型。以下是一篇文章,详细介绍了我之前提到的部分解决方案的改进:https://dalzhim.github.io/2017/08/11/Improving-the-enum-class-bitmask/ 免责声明:我是这篇后来的文章的作者。
当使用上一篇文章中生成的模板位运算符时,您将获得我们在上一段代码中演示的所有好处,同时还可以捕获mask == enumerator的错误。

3

一些观察结果:

  • std::bitset< N >支持任意数量的位(例如,多于64位),而枚举类型的底层整数类型被限制为64位;
  • std::bitset< N >可以隐式地(取决于std实现)使用最小尺寸的底层整数类型来适应所需的位数,而枚举类型的底层整数类型需要明确声明(否则,将使用int作为默认底层整数类型);
  • std::bitset< N >表示一个通用的N位序列,而作用域枚举提供可用于方法重载的类型安全性;
  • 如果std::bitset< N >被用作位掩码,那么一个典型实现取决于用于索引(!=掩码)目的的额外枚举类型;

请注意,后两个观察结果可以结合起来定义一个方便的强std::bitset类型:

typename< Enum E, std::size_t N >
class BitSet : public std::bitset< N >
{
    ...

    [[nodiscard]]
    constexpr bool operator[](E pos) const;

    ...
};

如果代码支持一些反射功能来获取显式枚举值的数量,那么位数可以直接从枚举类型中推导出来。

  • 作用域枚举类型没有按位运算符重载(这可以使用SFINAE或概念为所有作用域和非作用域枚举类型定义一次,并在使用之前包含,但需要更少的模板编程知识),而未作用域枚举类型会衰减成基础整数类型;
  • 枚举类型的按位运算符重载比std::bitset< N >需要更少的模板代码(例如:auto flags = Depth | Stencil;);
  • 枚举类型支持有符号和无符号的基础整数类型,而std::bitset< N >内部使用无符号的整数类型(移位运算符)。

对于我自己的代码,我大多使用std::bitset (和 eastl::bitvector)作为私有位/布尔容器来设置/获取单个位/布尔值。对于掩码操作,我更喜欢具有明确定义基础类型和按位运算符重载的作用域枚举类型。


2
你是否开启了优化?24倍的速度因子是非常不可能的。
对我而言,位集合(bitset)更加优秀,因为它可以为你管理空间:
- 可以无限扩展。如果你有很多标志(flag),在使用int/long long版本时可能会用完空间。 - 如果你只使用几个标志,可能会占用更少的空间(可以适配unsigned char/unsigned short - 虽然我不确定实现是否应用了这种优化)。

1

这里有一个不同的选择:https://codereview.stackexchange.com/questions/183246/type-safe-flag-sets-bit-fields-that-make-sense - Cris Luengo

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接