TL;DR: using enum for bitflags is poor, but it’s what we’re likely to end up with.
Many electrons have been spent expounding enumeration bitflag types (and not just in C++). As a tech community across multiple languages, we seem to be all in on the idea that the right way for this to look is something like:
// 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 operators"
auto p = permissions::READ | permissions::WRITE;
if ((p & permissions::READ) == permissions::READ) {
// ...
}
Where in C++, opting in variously looks like adding a function declaration found by ADL, or adding a reflection annotation, or using a macro to provide operators, or…
We might well end up here in C++. With successive standards, we get new techniques which enable us to get there a little more easily, and C++26 reflection is the latest example of that. P4313 is a proposal for enum flags in this style.
I think it’s a workable approach (i.e. we will make it work), but ultimately it seems to me that it’s the wrong direction. I think using a bitset is a much better solution. Unfortunately, std::bitset can’t get us there, which is why we may end up with the enum bitflags.
The pros of enum bitflags are usually given as:
- Type safety (at least when using scoped enumerations). There is no implicit conversion between values of different types. We can’t accidentally confuse permissions with some other value.
- Ergonomics of use. Using these things looks like using plain old integers. (I note that P4313 says “plain C-style
int“, notwithstanding clang-tidy’s bugprone-signed-bitwise check.)
I think the downsides are:
- Ergonomics of implementation. As I said, problems are lessened with each C++ standard. And this is something the culture of C++ library authorship discounts: who cares how difficult it is to implement under the hood? But it’s still going to be kind of annoying to apply bit operations to enumeration types you don’t own.
- Aesthetics. I don’t want using these things to look like using plain old integers, because using plain old integers is fraught with peril. Which leads to…
- Ergonomics of use. Bitwise operators famously have the “wrong” precedence in C and C++, for historical reasons. Yes, please use your compiler warnings. Ironically the expositional code used in P4313 exhibits this well-known operator precedence error (which I corrected above)!
- (Perhaps most significantly…) Conflation of ideas.
enumbitflags are easy, but not simple. They complect naming bits (what we actually want) with representing them. True, it’s not our fault; this is the legacy of C that multiple languages double down on at this point. But it’s also not right. We say “the type has membersAandB” then we pretend thatA|Bis also a member of the type. But that’s immediately subverting type safety: we have confused representation with naming.
So what’s the alternative? Well unfortunately, std::bitset doesn’t cut it, and in all probability can’t be made to cut it. But a better bitset could, and is totally implementable. Here’s what I’d like it to look like.
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]) {
// ...
}
As far as I can see, this solves almost (I’m being conservative in saying almost) all the problems with the enumeration approach.
- Type safety. I can’t assign a
perms_there from some otherbitsetor from an integral type. And this is true even for unscopedenums I find in 3rd party libraries. - Ergonomics of use.
operator[]isn’t going to have the precedence problems of the bitwise operators. Enumeration values don’t have to be pre-shifted, avoiding a source of potential error. - Correct separation of concerns. The enumeration type names the bits. The
bitsetrepresents them. We aren’t pretending thatREAD|WRITEis a member ofpermissions.
It still ticks the other boxes too. Implementation is at least as easy as, if not easier than, the enum approach.
I think the one remaining problem is around the ergonomics of taking a 3rd party codebase whose enum(s) I don’t own, and treating the enums as bits. Fair criticism. I think we are always going to find that there are ways this can be annoying. With the enum bitflags approach I would use an intrusive opt-in mechanism something like injecting ADL-found functions into a 3rd party namespace. With a bitset approach, it’s not intrusive, but I still need to provide a “max” value (or otherwise specify the number of bits in some other API shape variation not shown here).
Both of these techniques mean that I might have to change code when upstream code changes. I think on balance the bitset approach is a bit easier, especially considering that we can probably use reflection to discover a suitable “max” value. (Unless a 3rd party library deliberately hides the enum values away, and in that case, what would we use to name the bits anyway?)
But you’re still thinking this isn’t as nice as it could be, and you’re right, because so far you’re imagining std::bitset with just these extras. For nice usage, this bitset needs a few more things.
If we’re using a bitset for representation, well actually we want an easy way to specify and get access to the underlying thing (like enum can deal with its underlying type). Because ultimately types are all just compile-time fluff that the runtime doesn’t care about: these things do need to be plain integral stuff under the hood.
So something like this:
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>;
Because we want to be able to make these size/speed tradeoffs appropriately across platforms from tiny microcontrollers to massive servers.
And because this thing is just integral stuff that we want to write to some serialization format, we want an easy way to do that.
auto perms = get_permissions(); // a bitset over permissions
auto serial_perms = perms.to<std::uint16_t>(); // a std::uint16_t for serialization
We can of course add several other nice things to a bitset type, for other uses cases. (e.g. Why is it so hard, in a generic way, to construct a std::bitset with all the bits set? How do I iterate over set bits? How do I find the lowest unset bit?) But all that’s a different story.
Well, that’s about it. I think this bitset approach is much better than the enum flags approach. It’s better even after successive C++ versions iron out more and more of the annoying niceties of enum flags implementation. But we’re going to end up with enum flags, aren’t we? And I guess we’ll just make it work.