C++ Extending Enums

C++ Extending Enums

ยท

13 min read

Quite frequently we want to be able to extend an enum in a higher-level library or system that was originally defined in a lower-level library. This is not possible with C-enums or C++ enum classes, so to get this functionality we'll have to use classes and try to model extending enums with them. We could use macros ... but macros are dangerous and should in general be avoided.

Motivation

Consider the following enums used to indicate different types of logging messages we might receive:

enum class LogFilter : int
{
    Test = 0, Error, Warning, Base, Profiler, 
    FirstEnum = Test, LastEnum = Profiler // First/Last will be needed later!
};

enum class LogSystem : int
{
    Memory = 1000, Threads, Tasks, Events, //...
    FirstEnum = Memory, LastEnum = Events
};

We can use these enums to indicate that a given log message is one of these types, then later select the messages we're interested in based on this value. LogFilter needs to be defined at the base of our engine so that Assert()'s, Warning()'s, and other low-level code can apply the appropriate tags to their messages.

However, higher-level libraries will want to define their own filters (above in LogSystem), such as for memory, threading, events, or other systems. Users may also want to define filters at an ever higher level that are specific to their application. These higher-level enums have no business being defined at the base of our game engine; LogFilter should not know about them.

Given these separate enums, what should our Log_Message() function signature be? It needs to be defined at the lowest levels of the engine for LogFilter to use it, but here we don't know that LogSystem even exists. To do our logging, what we want to have is a type ExtendedLogFilter that will accept LogFilter (the base enum type), and anything extending it (e.g. LogSystem), but not anything else:

void Log_Message(ExtendedLogFilter aFilter, const char* aMessage);

ExtendedEnum Policies

First we'll define a concept for what types of enums are extendable: those with the FirstEnum and LastEnum members as seen in LogFilter above. We'll use these enums to help ensure that the range of values of extension enums (e.g. LogSystem) don't overlap with base ones (e.g. LogFilter).

template <typename DataType>
concept ExtendableEnumConcept = std::is_enum_v<DataType> && requires()
{
    DataType::FirstEnum;
    DataType::LastEnum;
};

As demonstrated with ExtendedLogFilter above, the ExtendedEnum class that we'll be creating needs to know the base enum type (e.g. LogFilter) that we are extending. In addition, we need to specify whether the extensions are linear or branching, and whether we want this enum to be used as a template parameter or not. We thus define ExtendedEnumPolicy:

enum class ExtendedEnumPolicy
{
    Regular = 0, Meta, Branched, BranchedMeta
};

constexpr bool Is_MetaEnumPolicy(ExtendedEnumPolicy aPolicy)
{
    return ((aPolicy == ExtendedEnumPolicy::Meta) || 
        (aPolicy == ExtendedEnumPolicy::BranchedMeta));
}

constexpr bool Is_BranchedEnumPolicy(ExtendedEnumPolicy aPolicy)
{
    return ((aPolicy == ExtendedEnumPolicy::Branched) || 
        (aPolicy == ExtendedEnumPolicy::BranchedMeta));
}

Linear extensions are the most common case: e.g. some UserDefinedLogFilter extends LogSystem, which extends LogFilter. Here only one enum is allowed to extend a given base enum. Therefore, each state is uniquely specified by the enum value, which we can guarantee is non-overlapping.

Branching extensions allow several different enums to extend a single base enum. This is useful when we want to extend the same base enum in several different contexts, where each context is guaranteed to not overlap with other contexts. This context separation is critical because we will no longer be able to guarantee that values between different enum branches won't overlap. An example usage of this is AnyCall for AnyObject, and is specified by using one of the Branched* enum policies above.

The only member of our ExtendedEnum class is the underlying integer value, and we would like it to remain private to prevent anyone from accidentally setting invalid values. However, for our extended enum to be used as a non-type template parameter, we need it to be public. Thus the *Meta policies above will make the value a public member, else it will be private. This comes in handy for our definition of ObjectPolicy for AnyObject.

Thus we declare the following classes and type definitions for usage:

//PRIMARY CLASS DECLARATION
template <ExtendableEnumConcept BaseEnum, ExtendedEnumPolicy Policy = 
    ExtendedEnumPolicy::Regular>
class ExtendedEnum;

//CONVENIECE DECLARATIONS FOR DIFFERENT POLICIES
template <ExtendableEnumConcept BaseEnum>
using MetaExtendedEnum = ExtendedEnum<BaseEnum, ExtendedEnumPolicy::Meta>;

template <ExtendableEnumConcept BaseEnum>
using BranchedEnum = ExtendedEnum<BaseEnum, ExtendedEnumPolicy::Branched>;

template <ExtendableEnumConcept BaseEnum>
using BranchedMetaEnum = ExtendedEnum<BaseEnum, 
    ExtendedEnumPolicy::BranchedMeta>;

//E.g. for logging:
using ExtendedLogFilter = ExtendedEnum<LogFilter>;

Extending Enums

How do we tell the compiler that one enum extends another? We want to specify that (e.g.) UserDefinedLogFilter extends LogSystem, without also separately specifying that it extends LogFilter: extensions are chainable and thus should be detectable that way. E.g. if we're trying to store a UserDefinedLogFilter in ExtendedLogFilter, we need to detect its extension of LogFilter through LogSystem.

The best (and perhaps the only non-grotesque) way to do this is through template specialization:

//Specialize for your Linear BaseEnum to extend it
template <ExtendableEnumConcept BaseEnum>
struct EnumExtension
{
    using ExtensionType = void; //Default to no extension
};

template <ExtendableEnumConcept BaseEnum>
using EnumExtensionT = typename EnumExtension<BaseEnum>::ExtensionType;

//Usage example specializing EnumExtension:
template <>
struct EnumExtension<LogFilter> { using ExtensionType = LogSystem; };

Above we've specialized EnumExtension for the LogFilter enum to indicate that LogSystem extends it. The default ExtensionType is void, indicating that there is no extension. The compiler won't generate any code for EnumExtension<LogFilter> until first use, so we can just define the EnumExtension specialization for LogFilter immediately after the LogSystem definition.

Below shows how to walk the extension chain to determine whether one enum is an extension of another. We first check whether the enum is even allowed to extend the other one (same underlying type, ranges cannot overlap). We then recurse through the extension chain until we either find the desired extension type or reach the end of the chain (void):

//To_Underlying() can be replaced with std::to_underlying in C++23
template <ExtendableEnumConcept Base, ExtendableEnumConcept Extension>
consteval bool Can_ExtendEnum()
{
    //Must have same underlying type, and ranges cannot overlap
    return std::is_same_v<std::underlying_type_t<Base>, 
       std::underlying_type_t<Extension>> && 
       (To_Underlying(Base::LastEnum) < To_Underlying(Extension::FirstEnum));
}

template <ExtendableEnumConcept Base, ExtendableEnumConcept Extension>
consteval bool Get_IsEnumExtension()
{
    if(!Can_ExtendEnum<Base, Extension>())
        return false; //Not even possible (e.g. range overlap)

    using NextExtension = EnumExtensionT<Base>;
    if(std::is_same_v<NextExtensionEnum, Extension>)
        return true; //Found it!

    if constexpr (std::is_same_v<NextExtension, void>)
        return false; //Reached the end of the chain: not an extension
    else //Go to the next link in the extension chain
        return Get_IsEnumExtension<NextExtension, Extension>();
}

There is one problem with the technique above though: it doesn't work for branching extensions! That's because we specialized EnumExtension on the type of the BaseEnum: thus there can be only one extension type per base! For branching extensions, we instead specialize on the type of the ExtensionEnum:

//Specialize for your Branched ExtensionEnum to branch it
template <ExtendableEnumConcept ExtensionEnum>
struct EnumBranch
{
    using BaseType = void;
};

template <ExtendableEnumConcept ExtensionEnum>
using EnumBranchT = typename EnumBranch<ExtensionEnum>::BaseType;

template <ExtendableEnumConcept Base, ExtendableEnumConcept Extension>
consteval bool Get_IsEnumBranch()
{
    //Similar recursion as Get_IsEnumExtension(), except using EnumBranch
    //...
}

//Usage example specializing EnumBranch:
template <>
struct EnumBranch<CallablePolicy> { using BaseType = ObjectPolicy; };

Since EnumBranch works for both cases, why didn't we just use that for linear extensions as well? Because when we use linear extensions we want to still be able to get a string for our enum! ExtendedEnum only knows the BaseEnum type and the stored integer value: to find the original enumerator matching the value it will have to march through the extension chain starting from BaseEnum. Getting an enum string is impossible for branched enums because the enum values in the different branches may overlap: we cannot guarantee uniqueness. The details on getting an enum string are discussed in the Stringification section further below.

ExtendedEnum Class

If one of the *Meta ExtendedEnumPolicy's is chosen, the enum value must be a public member to use the enum as template parameter, otherwise we want it to be private to prevent non-authorized modification. However, we cannot directly select the access policy (public/private) of a member variable based on a template parameter. Instead we'll have two different base classes: one with the member public, the other private, and we'll choose which one to inherit from based on the ExtendedEnumPolicy:

//Base class used for runtime enums
template <ExtendableEnumConcept BaseEnum>
class ExtendedEnumBase
{
protected:
    using DataType = std::underlying_type_t<BaseEnum>;
    DataType mValue = std::numeric_limits<DataType>::max();
public:
    constexpr ExtendedEnumBase() = default;
    constexpr ExtendedEnumBase(DataType aValue) : mValue(aValue) {};
};

//Base class used for metaprogramming enums: All members must be public
template <ExtendableEnumConcept BaseEnum>
class MetaExtendedEnumBase
{
protected:
    using DataType = std::underlying_type_t<BaseEnum>;
public:
    const DataType mValue = std::numeric_limits<DataType>::max();
};

Note that mValue in MetaExtendedEnumBase is const: since this type is only intended to be used as a template parameter, it shouldn't change after construction.

With the base classes out of the way, the first part of the ExtendedEnum class definition is:

template <ExtendableEnumConcept BaseEnum, ExtendedEnumPolicy Policy>
class ExtendedEnum : public std::conditional_t<Is_MetaEnumPolicy(Policy), 
    MetaExtendedEnumBase<BaseEnum>, ExtendedEnumBase<BaseEnum>>
{
public:
    static constexpr bool sIsBranched = Is_BranchedEnumPolicy(Policy);
    static constexpr bool sIsMeta = Is_MetaEnumPolicy(Policy);

private:
    using BaseType = std::conditional_t<sIsMeta, 
        MetaExtendedEnumBase<BaseEnum>, ExtendedEnumBase<BaseEnum>>;

    static_assert(To_Underlying(BaseEnum::FirstEnum) <= 
        To_Underlying(BaseEnum::LastEnum), "Invalid enum!\n");

public:
    using DataType = typename BaseType::DataType;

    constexpr DataType Get() const {return this->mValue;}
    constexpr operator DataType() const {return Get();}

    //...
};

There's nothing above that's too exciting that we haven't talked about already. The static_assert ensures that the BaseEnum is defined correctly, and the Get() method returns the enum value stored in the base class. The remainder of the ExtendedEnum methods:

template <ExtendableEnumConcept BaseEnum, ExtendedEnumPolicy Policy>
class ExtendedEnum : public std::conditional_t<Is_MetaEnumPolicy(Policy), 
    MetaExtendedEnumBase<BaseEnum>, ExtendedEnumBase<BaseEnum>>
{
public:
    //...
    //IS_STORABLE:
    template <ExtendableEnumConcept EnumType>
    static constexpr bool Is_Storable()
        requires(std::is_same_v<EnumType, BaseEnum>);
    template <ExtendableEnumConcept EnumType>
    static constexpr bool Is_Storable()
        requires(!std::is_same_v<EnumType, BaseEnum>);

    //CONSTRUCTORS
    constexpr ExtendedEnum() = default;

    template <ExtendableEnumConcept EnumType>
    constexpr ExtendedEnum(EnumType aEnum)
        requires (Is_Storable<EnumType>()) : BaseType{To_Underlying(aEnum)}{}

    template <ExtendableEnumConcept EnumType>
    constexpr ExtendedEnum(ExtendedEnum<EnumType, Policy> aEnum)
        requires (Is_Storable<EnumType>()) : BaseType{aEnum.Get()}

    //ASSIGNMENT
    template <ExtendableEnumConcept EnumType>
    constexpr ExtendedEnum& operator=(EnumType aEnum)
        requires (Is_Storable<EnumType>() && !sIsMeta)
    {
        this->mValue = To_Underlying(aEnum); return *this;
    }

    template <ExtendableEnumConcept EnumType>
    constexpr ExtendedEnum& operator=(ExtendedEnum<EnumType, Policy> aEnum) 
        requires (Is_Storable<EnumType>() && !sIsMeta);
       {
        this->mValue = aEnum.Get(); return *this;
    }
};

The methods themselves simply set or return mValue and thus aren't terribly interesting. What IS interesting are the concept requirements on the methods. The assignment methods are disabled for sIsMeta policies, as the stored enum value is const and cannot be changed. The Is_Storable() requirements ensure that only the allowed enums are stored, and are defined as:

template <ExtendableEnumConcept BaseEnum, ExtendedEnumPolicy Policy>
template <ExtendableEnumConcept EnumType>
constexpr bool ExtendedEnum<BaseEnum, Policy>::Is_Storable()
    requires(std::is_same_v<EnumType, BaseEnum>)
{
    return true;
}

template <ExtendableEnumConcept BaseEnum, ExtendedEnumPolicy Policy>
template <ExtendableEnumConcept EnumType>
constexpr bool ExtendedEnum<BaseEnum, Policy>::Is_Storable()
    requires(!std::is_same_v<EnumType, BaseEnum>)
{
    return (!sIsBranched && Get_IsEnumExtension<BaseEnum, EnumType>()) || 
        (sIsBranched && Get_IsEnumBranch<BaseEnum, EnumType>());
}

So, we are allowed to store the input EnumType enum if it is the same type as BaseEnum. If we have a branching policy, we are allowed to store it if Get_IsEnumBranch(), and if it's a linear extension we can store it if Get_IsEnumExtension(), both of which were defined earlier.

But why are there two separate methods? If we put it all into one method, and if we use this class (e.g. put a LogFilter in ExtendedLogFilter) before we even define any extensions (e.g. LogSystem), then it won't work. Why? Because the compiler will generate the code for (e.g.) Get_IsEnumExtension<LogFilter, LogFilter>(), and will thus use EnumExtension<LogFilter> before we had a chance to specialize it! Then when we do specialize it later (for the LogSystem extension) we get a compiler error indicating that this type was already defined! This problem is avoided by having a second method, and disabling it entirely when EnumType is BaseEnum (e.g. LogFilter). Thus this code is not generated until it is actually needed, and by then we'll have our template specialization defined.

Stringification

While optional, it is often convenient to be able to convert an enum into a string for logging. To do this, we'll start with the same technique we used for getting type names: adapting this Stackoverflow solution for enums:

//Adapted from: https://stackoverflow.com/questions/81870/is-it-possible-to-print-a-variables-type-in-standard-c/64490578#64490578
template <auto EnumValue>
inline consteval FixedView<char> Get_EnumFunctionName() 
    requires(std::is_enum_v<decltype(EnumValue)>)
{
#if defined(__GNUC__) || defined(__clang__)
    return FixedView(__PRETTY_FUNCTION__);
#elif defined(_MSC_VER)
    return FixedView(__FUNCSIG__);
#else
    return FixedView(__func__);
#endif
}

This again returns compiler-dependent strings:

//MSVC: FixedView<char> __cdecl Get_EnumFunctionName<main::Dummy::Test>(void)
//gcc: constexpr FixedView<char> Get_EnumFunctionName() [with auto EnumValue = main::Dummy::Test]
enum class Dummy {Test = 0};
std::cout << Get_EnumFunctionName<Dummy::Test>().mBegin << "\n";

To extract the name of the enum from this string, we do:

template <auto EnumValue>
consteval FixedView<char> Get_EnumName()
    requires (std::is_enum_v<decltype(EnumValue)>
{
    //Similar to Get_TypeName(), but we need to also search for the last ':'
    //...
}

template <>
consteval FixedView<char> Get_EnumName<DummyEnum::DummyValue>()
{
    return FixedView("DummyValue");
}

Extracting the name of the enum is similar to what we did before for the type name, but with the extra step of having to search for the last ':' in the string to extract the enum name (left as an exercise for the reader ๐Ÿ˜Š).

However, Get_EnumName() above gives us the name of an enum that we've selected at compile time, not at runtime: there is no way to call this function with a runtime enum value. What we can do though is we can generate an array containing every possible enum name at compile time, and use the enum value as an index to do a lookup at runtime!

Thus for any extendable enum (one with FirstEnum and LastEnum) we can call:

template <ExtendableEnumConcept EnumType>
constexpr FixedView<char> Get_String(EnumType aEnum)
{
    constexpr auto cEnumNameArray = Get_EnumNameArray<EnumType>();
    auto cIndex = To_Underlying(aEnum) - To_Underlying(EnumType::FirstEnum);
    return cEnumNameArray[cIndex];
}

The index into this array is just the enum value offset by EnumType::FirstEnum. Note that its extremely important to only use this method for enums with contiguous values! Otherwise you'll generate some extremely large arrays and take a very long time compiling. If you need to get the string of a non-contiguous enum, override this function for your enum type and manually type the strings instead.

For linearly-extended ExtendedEnum's we can call the following recursive function, which traverses the chain of extensions, checking to see if the value is in the range of the current enum. If it is, it then defers to the Get_String() method for that enum type:

template <ExtendableEnumConcept BaseEnum, ExtendedEnumPolicy Policy>
constexpr FixedView<char> Get_String(ExtendedEnum<BaseEnum, Policy> aEnum)
    requires(!Is_BranchedEnumPolicy(Policy))
{
    return Get_EnumString_Impl<BaseEnum>(aEnum);
}

template <ExtendableEnumConcept EnumType, ExtendableEnumConcept BaseEnum, 
    ExtendedEnumPolicy Policy>
constexpr FixedView<char> Get_String_Impl(ExtendedEnum<BaseEnum, Policy> 
    aEnum) requires(!Is_BranchedEnumPolicy(Policy))
{
    auto cEnumValue = aEnum.Get();
    bool cInRange = (cEnumValue >= To_Underlying(EnumType::FirstEnum)) && 
        (cEnumValue <= To_Underlying(EnumType::LastEnum));

    //Is it in the range of this enum?
    if(cInRange)
    {
        //It must be a EnumType: Cast to it and call Get_String() for it
        auto cEnum = static_cast<EnumType>(cEnumValue);
        return Get_String(cEnum);
    }

    //Out of range, check the next extension
    using ExtensionType = EnumExtensionT<EnumType>;
    if constexpr (!std::is_void_v<ExtensionType>)
        return Get_String_Impl<ExtensionType>(aEnum);
    else //Reached the end of the extension chain: Not a valid value!
    {
        Assert(false);
        return {"", 0};
    }
}

This now just leaves how to generate and return the array of enum names at compile time. First we define AggregateArray, an extremely slimmed-down class (intended only for metaprogramming) which we'll use to store our array of enum names. Unlike std::array, this class asserts instead of throwing exceptions (which make it difficult for compilers to optimize code):

template <typename DataType, int Size>
struct AggregateArray
{
    DataType mArray[Size];
    constexpr const DataType& operator[](int aIndex) const
    {
        Assert((aIndex >= 0) && (aIndex < Size));
        return mArray[aIndex];
    }
};

Now to fill the array, we need to use a std::integer_sequence of Indices... which we cast to EnumType to call Get_EnumName() with:

template <ExtendableEnumConcept EnumType, auto... Indices>
consteval auto Get_EnumNameArray_Impl( 
    std::integer_sequence< std::underlying_type_t<EnumType>, Indices... >)
{
return AggregateArray { Get_EnumName<static_cast<EnumType>(Indices)>()... };
}

How do we create this input integer sequence? std::make_integer_sequence creates a consecutive sequence that starts at zero, but enum values will generally start at some offset. So to do this we define our own MakeIntegerSequence, which uses std::make_integer_sequence to generate a sequence of the desired length starting from zero. It then uses Shift_Sequence() to add an offset to each value, getting us a parameter pack of values matching our enum values:

//Adapted from: https://stackoverflow.com/questions/35625079/offset-for-variadic-template-integer-sequence
template <auto Offset, auto... Integers>
auto Shift_Sequence(std::integer_sequence<decltype(Offset), Integers...>)
{
    return std::integer_sequence<decltype(Offset), (Offset + Integers)...>{};
}

template<auto Offset, typename IntegerSequenceType>
using OffsetIntegerSequenceT = 
    decltype(Shift_Sequence<Offset>(IntegerSequenceType{}));

//std::make_integer_sequence generates from zero to Length - 1
//This generates from Start to Start + Length - 1
template<auto Start, auto Length>
using MakeIntegerSequence = OffsetIntegerSequenceT<Start, 
    std::make_integer_sequence<decltype(Start), Length>>;

Finally, to use this integer sequence, Get_EnumNameArray() below calls Get_EnumNameArray_Impl() with an object of our helper type MakeEnumSequence<EnumType>:

template <ExtendableEnumConcept EnumType>
consteval auto Get_EnumSize()
{
    return 1 + To_Underlying(EnumType::LastEnum) - 
        To_Underlying(EnumType::FirstEnum);
}

template <ExtendableEnumConcept EnumType>
using MakeEnumSequence = MakeIntegerSequence<
    To_Underlying(EnumType::FirstEnum), Get_EnumSize<EnumType>()>;

template <ExtendableEnumConcept EnumType>
consteval auto Get_EnumNameArray()
{
    return Get_EnumNameArray_Impl<EnumType>(MakeEnumSequence<EnumType>{});
}

That's it! We can now get strings for our enum values (Make_StringView() just converts a FixedView to std::string_view for use with std::cout):

//"Test"
std::cout << Make_StringView(Get_EnumString(LogFilter::Test)) << "\n"; 

//"Memory"
ExtendedLogFilter cFilter(LogSystem::Memory);
std::cout << Make_StringView(Get_EnumString(cFilter)) << "\n";

Conclusion

Extendable enums provide a clean way for higher-level code to effectively add extra enum values that lower-level code can use. Given our two LogFilter and LogSystem enums (with special FirstEnum and LastEnum values!), the only code needed to link them was:

template <>
struct EnumExtension<LogFilter> { using ExtensionType = LogSystem; };

Once you have the library setup, extending an enum is easy to do, they're safe to use, and they prevent lower-level code from being dependent on higher-level concerns.

License & Disclaimers

Support This Blog!

Did you find this article valuable?

Support Paul Mattione by becoming a sponsor. Any amount is appreciated!

ย