Skip to content

Instantly share code, notes, and snippets.

@seanmiddleditch
Last active March 28, 2024 01:43
Show Gist options
  • Save seanmiddleditch/9188b6eff1b64c85fe0111b3885bc184 to your computer and use it in GitHub Desktop.
Save seanmiddleditch/9188b6eff1b64c85fe0111b3885bc184 to your computer and use it in GitHub Desktop.
Meta-Proposal for Enum Member Functions

Meta-Proposal for Enum Member Functions

Problem

Enums today cannot provide any custom behavior via member functions. Behavior for enumeration types must be provided by traits classes or namespace-scope operator overloads and functions.

For some uses of enums, such as opaque ID wrapper or flags types, the inability to introduce hidden friend functions poisons the namespace scope of the enum (resulting in worse diagnostic and build times). Typically, custom operations and extension points should be defined as hidden friends to improve the experience of C++ developers using the type.

Consider:

enum class flags : unsigned {
  none = 0,
  active = 1,
  debug = 2,
  locked = 4,
  deferred = 8
};

// namespace-scope non-hidden operator!
constexpr flags operator|(flags l, flags r) noexcept { return flags{to_underlying(l) | to_underlying(r)}; }

Working around this limitation often requires unfortunate boilerplate that introduces its own set of complexities. An example of one work-around found in the wild is to wrap enumeration types in a struct:

struct flags {
  enum flag_values {
    none = 0,
    active = 1,
    debug = 2,
    locked = 4,
    deferred = 8
  };
  unsigned char value = flag_values::none;
  
  friend constexpr flags operator|(flags l, flags r) noexcept { return flags{l.value | r.value}; }
};

Hoewever, this approach introduces a number of problems with type deduction and implicit conversions. In particular, the deduced type of expressions like the following are surprising:

auto f = flags::none; // decltype(f) == flags::flag_values

A "better" work-around is to define enums and their operations in a private namespace and then alias the enum into the enclosing namespace, something like:

namespace flags_private {
  enum class flags : unsigned {
    none = 0,
    active = 1,
    debug = 2,
    locked = 4,
    deferred = 8
  };
  
  constexpr flags operator|(flags l, flags r) noexcept { return flags{to_underlying(l) | to_underlying(r)}; }
};

using flags = flags_private::flags;

This isn't without some drawbacks, but perhaps the most jarring is that it is an unnatural work-around for a language short-coming with the conjunction of ADL, type inference, name resolution, and operator overloading. Even explaining why this pattern is useful or preferable in many cases requires teaching a number of relatively-advanced topics to engineers who probably just want to make a simple flags enum and move on with their work.

There's also another problem with all of the above work-arounds or even the base problem: they require verbose user-defined operators to be manually define, which is tedious and error-prone for the developer.

Some approaches to this problem use SFINAE/concepts and a generalized set of operators, which then can be opted into with a trait:

template <typename E> struct is_flags_enum : std::false_type { };
template <typename E> concept flags_enum = is_flags_enum<E>::value;

template <flags_enum E>
constexpr E operator|(E l, E r) noexcept { return static_cast<E>(to_underlying(l) | to_underlying(r)); }

enum flags { ... };
template <> struct is_flags_enum<flags> : std::true_type { };

Put simply, this is pretty gnarly, and introduces the compiler-throughput problems of meta-programming and SFINAE/concepts. It also gets more complex for any cases where a user might want to opt-in a different set of operators, e.g. arithmetic operators, a subset of bit-wise operators common to flags types, etc.

As a very minor problem point, with or without templates, the user-defined operators have a negative impact on compiler throughput for enumerations that may be included in many TUs across the project (due to the need to parse, analyze, codegen, and inline the user-defined operators). The core of this proposal does not solve this problem, but as an optional extension we can consider, this proposal unlocks a possible solution to the problem.

Proposal

A scoped neum, after its member items, may include a ; followed by zero or more non-virtual member functions (static, non-static, or friend). Enum bodies may NOT include data members or nested type declarations.

enum class flags : unsigned {
  none = 0,
  active = 1,
  debug = 2,
  locked = 4,
  deferred = 8;
  
  friend constexpr flags operator|(flags l, flags r) noexcept { return flags{to_underlying(l) | to_underlying(r)}; }
};

In this case, we've solved both of the problems outlined above: the operator| is a hidden friend and so it does not pollute the namespace-scope, and all type deduction and other advanced language features see only the real flags enumeration type and no wrapper types.

Operations could also be = delete (not sure what the use case would be, but it should work).

Extension for Defaulted Operators

For many cases with custom operators on enum types, such as the flags type above, the implementation should be trivial for the compiler to generate. These operators all take the form:

enum_type operator@(enum_type l, enum_type r) noexcept { enum_type{to_underlying(l) @ to_underlying(r)}; }
enum_type operator@(enum_type e) noexcept { enum_type{@to_underlying(v)}; }

And the associated assignment forms.

As an extension to this proposal, it could be allowed to define friend or member operator functions within a scoped enum definition using = default, with the equivalent behavior of the operator forms above.

Effectively, this enables opting-in to the ability to use integral operators as on non-class enum definitions, except the result type is the enum type itself rather than the underlying type.

It would be intended that implementations do not generate actual function definitions in this case, instead treating these defaulted operators as they do any other built-in operator (e.g. no CALL instructions or function bodies in generated code, even in debug/unoptimized builds).

e.g.

enum e { v };

decltype(v | v); // -> int
enum class ec { v; friend auto operator|(ec, ec) = default; };

decltype(ec::v | ec::v); // -> ec

Extension for Static Data Members

Adding non-static data members to an enum should of course be disallowed, but allowing static data members may have value.

Examples of enumerations since the early days of C include various meta-values that aren't really meant to be part of the enumerated set, especially when enumerations are meant to be extensible in some fashion by user code:

enum class errors {
  ok = 0,
  system,
  network,
  io,
  
  user_start = 1000,
  user_end = 2000
};

Once we have reflection, we'll have the problem that these enumeration values would be part of the enumerated set of names, even though for many users of reflection we might instead prefer that these informational values be excluded. With static data members, the above could be declared as:

enum class errors {
  ok = 0,
  system,
  network,
  io;
  
  static constexpr errors user_start = 1000;
  static constexpr errors user_end = 1000;
};

Reflecting on the enumeration values would then only expose the "real" items, while the side-band data would only be found via reflection looking for static data members.

Considerations

What About Unscoped Enums?

Unscoped enums inject their member items into the enclosing namespace. Much of the motivation for this feature is specifically to move function declarations out of the enclosing namespace so they can be hidden friends or member functions.

Introducing member functions or static members to unscoped enums would work best only if those members are scoped, unlike the enumeration values themselves. This is an inconsistency that is probably not ideal. Alternatively, unscoped enums could be further constrained to only allow friend function declarations (to enable hidden friends) and not member functions or static member data, but a nuanced differing set of rules between scoped and unscoped enums also does not feel ideal.

There's some potential advantages to keeping things uniform and allowing member functions on unscoped enums. However, being conservative, I believe this feature offers significant value while being restricted only to scoped enums, and it avoids some potentially tricky corner cases with name lookup in member function bodies were they allowed on unscoped enums.

It would be a pure addition to add member function support to unscoped enums in the future, so altogether I think we can exclude unscoped enums safely now and reconsider if/when a compelling use case is offered for their inclusion.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment