↓ Skip to main content
  1. posts/

EnumArray - A Practical Mapping Trick Without Paying Hash-Map Tax

··Index: 6·

EnumArray: a map from enum to data without the hash map
#

Whenever I work with configuration systems, metadata tables or low-level processing pipelines, I keep running into the same pattern. I have an enum, I want to map each enumerator to some piece of data, and the naive solution is as universal as it is convenient.

std::unordered_map<Unit, const char*> unitNames {
    { Unit::Grams, "g" },
    { Unit::Meters, "m" },
    { Unit::Liters, "l" },
    { Unit::Items, "pcs" },
};

The map works and reads fine, but for a table of four items that’s wasteful. Metadata enums tend to be tiny, and a hash map still makes you pay for hashing, indirect lookups, memory overhead and poor locality, the compiler gets no size to work with, and in the worst case you get to deal with a surprising number of hidden allocations, all for something that’s basically a compile-time list of a few well-known items.

At the same time an enum is just an integer, and integers make perfect array indices. That leads to a simple idea: keep the dictionary interface and back it with std::array. The only missing piece is the size of the array, and the easiest way to get it (not elegant, but hard to get wrong) is an extra enumerator.

enum class Unit {
    Grams,
    Meters,
    Liters,
    Items,

    Count
};

If you’ve had to maintain older C codebases, you’ve seen this pattern before. The Count trick is boring, but works and needs zero metaprogramming magic, and the container on top of it is short.

template<typename Enum, typename T>
class EnumArray {
public:
    EnumArray(std::initializer_list<std::pair<Enum, T>>&& values) {
        for (auto&& [key, val] : values)
            data[std::to_underlying(key)] = val;
    }

    T& operator[](Enum key) {
        return data[std::to_underlying(key)];
    }

    const T& operator[](Enum key) const {
        return data[std::to_underlying(key)];
    }

private:
    static constexpr size_t N = std::to_underlying(Enum::Count);
    std::array<T, N> data{};
};

Code using EnumArray looks just like code using a map.

EnumArray<Unit, const char*> unitNames {
    { Unit::Grams, "g" },
    { Unit::Meters, "m" },
    { Unit::Liters, "l" },
    { Unit::Items, "pcs" },
};

std::cout << unitNames[Unit::Items] << "\n";  // pcs

What you get
#

The data sits in one compact, cache-friendly block, with no pointer chasing, no buckets and no load factors, and every lookup costs O(1) without collisions or a custom hash function to write.

Because the compiler knows the size up front, there are no hidden allocations, no calls to reserve() and no resizing, and you can make almost everything constexpr. For small fixed dictionaries (units, message types, UI states, processing steps) it’s simply the cheapest way to do it.

Where I use it
#

In an image processing pipeline each filter gets its parameters this way, and nothing touches the heap during processing.

enum class FilterId {
    Gaussian,
    Median,
    Sobel,
    Sharpen,
    Count
};

EnumArray<FilterId, FilterConfig> filters {
    { FilterId::Gaussian, {3, 1.0f} },
    { FilterId::Median,   {5} },
    { FilterId::Sobel,    {1} },
    { FilterId::Sharpen,  {2} }
};

Logging channels can carry their own log levels.

enum class LogChannel {
    Network,
    Storage,
    Rendering,
    Physics,
    Count
};

EnumArray<LogChannel, Level> logLevels {
    { LogChannel::Network,  Level::Info },
    { LogChannel::Storage,  Level::Warning },
    { LogChannel::Rendering,Level::Debug },
    { LogChannel::Physics,  Level::Error },
};

And a simulation can keep per-material constants, here the density.

enum class Material {
    Steel,
    Concrete,
    Glass,
    Wood,
    Count
};

EnumArray<Material, float> density {
    { Material::Steel,     7850.f },
    { Material::Concrete,  2400.f },
    { Material::Glass,     2500.f },
    { Material::Wood,       600.f },
};

Limits
#

The technique is simple, but it isn’t universal, and a few restrictions matter. First of all, the enum has to be contiguous and start at zero, so something like this breaks it.

enum class Type {
    A = 10,
    B = 11,
    C = 512,
    Count
};

Your array would need 512 entries, most of them unused, and in a real system that’s unacceptable.

T also has to be default-constructible, because the underlying array is always allocated at full size. Give it a type like this one and EnumArray<Enum, NonDefault> won’t compile.

struct NonDefault {
    NonDefault(int);
};

Partial initialization leaves default-constructed gaps.

EnumArray<Unit, const char*> names {
    { Unit::Grams, "g" },
    { Unit::Liters, "l" }
};

names[Unit::Meters] == nullptr   // default value

Whether that’s acceptable depends on your application. Duplicate initializers are another trap, since the second one silently overwrites the first.

EnumArray<Unit, int> values {
    { Unit::Grams, 1 },
    { Unit::Grams, 20 },   // overwrites silently
};

Hash maps behave the same way, but with an array it’s easier to make that mistake. Worst of all, an out-of-range enum value is instant undefined behavior.

Unit x = static_cast<Unit>(1000);
names[x];

That indexes outside of the array, and nothing protects you unless you add explicit checks.

Variants
#

Engine codebases often extend the idea. To get rid of the default construction requirement, store std::optional<T>.

std::array<std::optional<T>, N> data;

You can also resolve everything at compile time.

constexpr EnumArray<Unit, std::string_view> names = {
    { Unit::Grams,  "g" },
    { Unit::Meters, "m" },
    { Unit::Liters, "l" },
    { Unit::Items,  "pcs" },
};

And in high-integrity code, where correctness beats speed, it’s easy to add a bounds-checked accessor.

T& at(Enum key) {
    const auto idx = std::to_underlying(key);
    if (idx >= N) throw std::out_of_range("EnumArray");
    return data[idx];
}

EnumArray or a map
#

I tend to reach for EnumArray when the enum is small and fixed, lookups happen all the time, memory locality matters, I’d like constexpr initialization, nothing gets inserted at runtime and default construction is acceptable.

I’d rather switch to unordered_map or map when the enum values aren’t contiguous, entries come and go at runtime, the dictionary is sparse, memory overhead doesn’t matter, or another subsystem owns the enum and may change it in unpredictable ways.

I use this pattern often and rarely regret it. For an enum that’s a closed set of states known at compile time, you get dictionary-like syntax with array-level performance and no extra costs.

Related

Skrypt

·