What Is an Enumerator-Indexed std::array?

An unscoped enumeration hands out consecutive integers starting at zero. A std::array numbers its elements with consecutive integers starting at zero. Put those two facts side by side and you get a single, very useful invariant:

the integral value of an enumerator is the position of that enumerator's data in a parallel std::array

Everything in this lesson is one consequence or another of that sentence. The enumeration supplies the vocabulary, the array supplies the data, and the value of an enumerator is the bridge between them.

#include <array>
#include <iostream>

enum Terrain
{
    meadow,      // 0
    marsh,       // 1
    ridge,       // 2
    glacier,     // 3
    max_terrains // 4
};

int main()
{
    constexpr std::array stepCost { 5, 12, 9, 20 };

    std::cout << "crossing a ridge costs " << stepCost[ridge] << '\n';

    return 0;
}

Output:

crossing a ridge costs 9

stepCost[ridge] reads as English and compiles to stepCost[2]. The cost of that readability is an invariant somebody has to maintain, and the rest of this lesson is about making the compiler maintain it for you.

One Invariant, Four Jobs

Once the value-equals-position invariant holds, the same enumeration answers four different questions, each with its own table:

What you want The table you build How you reach the answer
a number attached to each enumerator std::array of that number type index it with the enumerator
a printable name for each enumerator std::array of std::string_view index it with the enumerator
the enumerator a user typed the same name table scan it; the position you stop at is the value
every enumerator in turn std::array of the enumerators themselves walk it with a range-based for loop

The four sections that follow work through those rows in order. The last two sections cover what holds the whole scheme together, and what to do when the invariant does not hold at all.

Making the Compiler Check the Table Length

Class template argument deduction counts your initializers and sizes the array to match. That is convenient right up to the moment you type one initializer too few, because then the array is silently shorter than the enumeration it is supposed to serve.

This program is broken, and the comment marks where:

#include <array>
#include <iostream>

enum Terrain
{
    meadow,      // 0
    marsh,       // 1
    ridge,       // 2
    glacier,     // 3
    max_terrains // 4
};

int main()
{
    constexpr std::array stepCost { 5, 12, 9 }; // only three costs were typed

    std::cout << "glacier costs " << stepCost[glacier] << '\n';

    return 0;
}

stepCost deduces to std::array<int, 3>, and glacier is 3, so the read runs off the end of the array. That is undefined behavior: whatever the program prints, it did not come from stepCost. Here GCC happens to notice at compile time and says so, but it is not obliged to:

s.cpp: In function 'int main()':
s.cpp:17:59: warning: array subscript 3 is outside array bounds of 'const std::array<int, 3> [1]' [-Warray-bounds=]
   17 |     std::cout << "glacier costs " << stepCost[glacier] << '\n';
      |                                                           ^~~~
s.cpp:15:26: note: at offset 12 into object 'stepCost' of size 12
   15 |     constexpr std::array stepCost { 5, 12, 9 }; // only three costs were typed
      |                          ^~~~~~~~

A warning that depends on the optimizer seeing through your code is not a safety net. Write the check yourself. The max_terrains enumerator already holds the count you expect, and the array's length is a compile-time constant, so a static_assert can compare them before the program is ever built. Adding one turns the silent bug into a build failure:

#include <array>
#include <iostream>

enum Terrain
{
    meadow,      // 0
    marsh,       // 1
    ridge,       // 2
    glacier,     // 3
    max_terrains // 4
};

int main()
{
    constexpr std::array stepCost { 5, 12, 9 };

    static_assert(std::size(stepCost) == max_terrains);

    std::cout << "glacier costs " << stepCost[glacier] << '\n';

    return 0;
}

This one does not compile, which is the point:

s.cpp: In function 'int main()':
s.cpp:17:39: error: static assertion failed
   17 |     static_assert(std::size(stepCost) == max_terrains);
      |                   ~~~~~~~~~~~~~~~~~~~~^~~~~~~~~~~~~~~
  • the comparison reduces to '(3 == 4)'

Supply the missing cost and the program builds and runs:

#include <array>
#include <iostream>

enum Terrain
{
    meadow,      // 0
    marsh,       // 1
    ridge,       // 2
    glacier,     // 3
    max_terrains // 4
};

int main()
{
    constexpr std::array stepCost { 5, 12, 9, 20 };

    static_assert(std::size(stepCost) == max_terrains);

    std::cout << "glacier costs " << stepCost[glacier] << '\n';

    return 0;
}

Output:

glacier costs 20

The real payoff comes later, when somebody adds a fifth terrain to the enumeration and forgets the table. The build stops on their machine instead of the program misbehaving on a user's.

Best Practice
Every time you write a constexpr std::array whose length is dictated by an enumeration, write a static_assert next to it comparing std::size(theArray) against the count enumerator.

A Table of Names

Names are just another fact attached to each enumerator, so the same indexing trick prints them. Store the names in a constexpr std::array of std::string_view and the lookup becomes a subscript.

The element type matters here. Written plainly, constexpr std::array terrainLabel { "meadow", "marsh" } deduces an array of const char*, and a const char* is a bare address: comparing two of them compares addresses rather than text, and neither one knows its own length. The sv suffix, enabled by using namespace std::string_view_literals;, makes each literal a std::string_view so deduction produces the type you actually want.

#include <array>
#include <iostream>
#include <string_view>

namespace Terrain
{
    enum Kind
    {
        meadow,
        marsh,
        ridge,
        glacier,
        max_terrains
    };

    using namespace std::string_view_literals; // enables the sv suffix below

    constexpr std::array terrainLabel { "meadow"sv, "marsh"sv, "ridge"sv, "glacier"sv };
    static_assert(std::size(terrainLabel) == max_terrains);
}

constexpr std::string_view labelOf(Terrain::Kind tile)
{
    return Terrain::terrainLabel[static_cast<std::size_t>(tile)];
}

std::ostream& operator<<(std::ostream& stream, Terrain::Kind tile)
{
    return stream << labelOf(tile);
}

int main()
{
    Terrain::Kind current{ Terrain::ridge };

    std::cout << "the party is standing on a " << current << '\n';
    std::cout << "the tile north of it is a " << Terrain::marsh << '\n';

    return 0;
}

Output:

the party is standing on a ridge
the tile north of it is a marsh

Three details are worth pausing on.

The enumeration and its table live together in a namespace named Terrain, with the enumeration itself named Kind. That gives you Terrain::ridge at the call site without making the enumeration scoped, so the enumerators still convert to integers and can still index an array.

labelOf casts to std::size_t before subscripting. Unscoped enumerators convert to an integral type on their own, so the cast is not strictly required, but writing it makes the signed-to-unsigned conversion explicit rather than leaving it to the compiler's warning settings.

operator<< takes the stream by reference and returns that same reference. Returning the stream is what makes chaining work: std::cout << "on a " << current << '\n' is three calls in a row, and each one needs the previous one to hand the stream back.

Why a table beats a switch
A switch that returns a literal per enumerator does the same job, but it repeats the enumerator list a second time, and nothing forces the two lists to agree. The table plus static_assert keeps one list of names and fails the build when it drifts out of step with the enumeration.

Reading an Enumerator Back From Text

The name table works in reverse too. Given some text, scan the table; if the text matches the entry at position slot, then slot is the value of the enumerator you want, and a static_cast turns it back into the enumeration type.

That scan is exactly the body of an operator>> overload. Extraction operators take their target by non-const reference so they can write to it, and they report failure by putting the stream into a fail state rather than by returning a value:

#include <array>
#include <iostream>
#include <string>
#include <string_view>

namespace Terrain
{
    enum Kind
    {
        meadow,
        marsh,
        ridge,
        glacier,
        max_terrains
    };

    using namespace std::string_view_literals;

    constexpr std::array terrainLabel { "meadow"sv, "marsh"sv, "ridge"sv, "glacier"sv };
    static_assert(std::size(terrainLabel) == max_terrains);
}

constexpr std::string_view labelOf(Terrain::Kind tile)
{
    return Terrain::terrainLabel[static_cast<std::size_t>(tile)];
}

std::ostream& operator<<(std::ostream& stream, Terrain::Kind tile)
{
    return stream << labelOf(tile);
}

std::istream& operator>>(std::istream& stream, Terrain::Kind& tile)
{
    std::string typed{};
    std::getline(stream >> std::ws, typed);

    for (std::size_t slot{ 0 }; slot < Terrain::terrainLabel.size(); ++slot)
    {
        if (typed == Terrain::terrainLabel[slot])
        {
            tile = static_cast<Terrain::Kind>(slot); // the position is the enumerator value
            return stream;
        }
    }

    stream.setstate(std::ios_base::failbit); // nothing matched, so the read failed
    return stream;
}

int main()
{
    Terrain::Kind current{ Terrain::ridge };
    std::cout << "the party is standing on a " << current << '\n';

    std::cout << "walk onto which terrain? ";
    std::cin >> current;

    if (!std::cin)
        std::cout << "there is no such terrain on this map" << '\n';
    else
        std::cout << "the party is now standing on a " << current << '\n';

    return 0;
}

Input:

marsh

Output:

the party is standing on a ridge
walk onto which terrain? the party is now standing on a marsh

Give it text that is not in the table and the loop finishes without a match, failbit goes up, and if (!std::cin) catches it:

Input:

swamp

Output:

the party is standing on a ridge
walk onto which terrain? there is no such terrain on this map

Notice what did not happen: nobody wrote a list of if (typed == "marsh") return marsh; lines. Both directions of the conversion are driven by the one array, so adding a terrain means adding one label and one cost, and the static_assert catches you if you add neither.

Warning
When extraction fails, tile keeps whatever value it already had. The operator above deliberately leaves it alone, so the caller must test the stream before trusting the variable. If you would rather have a failed read blank the target, assign tile = {} before returning.

Visiting Every Enumerator

An enumeration is not a range, so you cannot point a range-based for loop at the type itself. This will not compile:

#include <iostream>

enum Terrain
{
    meadow,
    marsh,
    ridge,
    glacier
};

int main()
{
    for (Terrain tile : Terrain) // an enumeration type is not a range
        std::cout << static_cast<int>(tile) << '\n';

    return 0;
}
s.cpp: In function 'int main()':
s.cpp:13:32: error: expected primary-expression before ')' token
   13 |     for (Terrain tile : Terrain) // an enumeration type is not a range
      |                                ^

There are two ways around it. You can count an int from zero up to the count enumerator and cast on every iteration, or you can build one more table, this time holding the enumerators themselves, and walk that. Both appear below:

#include <array>
#include <iostream>
#include <string_view>

namespace Terrain
{
    enum Kind
    {
        meadow,      // 0
        marsh,       // 1
        ridge,       // 2
        glacier,     // 3
        max_terrains // 4
    };

    using namespace std::string_view_literals;

    constexpr std::array terrainLabel { "meadow"sv, "marsh"sv, "ridge"sv, "glacier"sv };
    static_assert(std::size(terrainLabel) == max_terrains);

    constexpr std::array everyKind { meadow, marsh, ridge, glacier }; // the enumerators themselves
    static_assert(std::size(everyKind) == max_terrains);
}

constexpr std::string_view labelOf(Terrain::Kind tile)
{
    return Terrain::terrainLabel[static_cast<std::size_t>(tile)];
}

std::ostream& operator<<(std::ostream& stream, Terrain::Kind tile)
{
    return stream << labelOf(tile);
}

int main()
{
    std::cout << "counting through the values:" << '\n';
    for (int slot{ 0 }; slot < Terrain::max_terrains; ++slot)
        std::cout << "  " << static_cast<Terrain::Kind>(slot) << '\n';

    std::cout << "walking the table:" << '\n';
    for (Terrain::Kind tile : Terrain::everyKind)
        std::cout << "  " << tile << '\n';

    return 0;
}

Output:

counting through the values:
  meadow
  marsh
  ridge
  glacier
walking the table:
  meadow
  marsh
  ridge
  glacier

Same result, different reading experience. The counting loop needs a cast in the body, and that cast is a place where a wrong bound quietly produces a value that is not any enumerator. The range-based loop needs no cast at all: because the element type of everyKind is Terrain::Kind, the loop variable is already that type.

The technique assumes each enumerator has a distinct value. Two enumerators sharing a value are the same value as far as the array is concerned, so one of them can never be told apart from the other.

Keeping Two Tables in Step

Nothing stops you from hanging several tables off the same enumeration. The costs and the names are independent facts about the same four terrains, and both are indexed the same way. Assert that they agree with each other as well as with the count:

#include <array>
#include <iostream>
#include <string_view>

namespace Terrain
{
    enum Kind
    {
        meadow,
        marsh,
        ridge,
        glacier,
        max_terrains
    };

    using namespace std::string_view_literals;

    constexpr std::array terrainLabel { "meadow"sv, "marsh"sv, "ridge"sv, "glacier"sv };
    constexpr std::array stepCost { 5, 12, 9, 20 };

    static_assert(std::size(terrainLabel) == max_terrains);
    static_assert(std::size(stepCost) == std::size(terrainLabel)); // the two tables must stay in step
}

constexpr std::string_view labelOf(Terrain::Kind tile)
{
    return Terrain::terrainLabel[static_cast<std::size_t>(tile)];
}

std::ostream& operator<<(std::ostream& stream, Terrain::Kind tile)
{
    return stream << labelOf(tile);
}

int main()
{
    constexpr std::array route { Terrain::meadow, Terrain::marsh, Terrain::meadow, Terrain::ridge };

    int spent{ 0 };
    for (Terrain::Kind tile : route)
    {
        int cost{ Terrain::stepCost[static_cast<std::size_t>(tile)] };
        spent += cost;
        std::cout << tile << " costs " << cost << ", running total " << spent << '\n';
    }

    return 0;
}

Output:

meadow costs 5, running total 5
marsh costs 12, running total 17
meadow costs 5, running total 22
ridge costs 9, running total 31

route is worth a second look. It is an array of Terrain::Kind, not an array of int, so the loop variable is an enumerator and both the printing and the cost lookup accept it directly. Enumerators are cheap to store and pass around; the tables carry the weight.

When the Invariant Does Not Hold

All of the above rests on enumerators being 0, 1, 2, and so on. Assign explicit values, leave a gap, or start at 1, and the value stops being a position. Indexing then reads the wrong element or runs off the end.

You have two choices. The first is to keep the enumerators sequential and store the odd values in yet another table, which preserves indexing everywhere. The second is to give up indexing and search instead: put the enumerators in an array, put the matching data at the same positions, and find the position by scanning.

#include <array>
#include <iostream>
#include <string_view>

namespace Terrain
{
    enum Kind
    {
        meadow  = 10,
        marsh   = 20,
        ridge   = 30,
        glacier = 40
    };

    using namespace std::string_view_literals;

    constexpr std::array everyKind { meadow, marsh, ridge, glacier };
    constexpr std::array terrainLabel { "meadow"sv, "marsh"sv, "ridge"sv, "glacier"sv };

    static_assert(std::size(everyKind) == std::size(terrainLabel));
}

constexpr std::string_view labelOf(Terrain::Kind tile)
{
    for (std::size_t slot{ 0 }; slot < Terrain::everyKind.size(); ++slot)
    {
        if (Terrain::everyKind[slot] == tile)
            return Terrain::terrainLabel[slot];
    }

    return "off the map";
}

std::ostream& operator<<(std::ostream& stream, Terrain::Kind tile)
{
    return stream << labelOf(tile);
}

int main()
{
    for (Terrain::Kind tile : Terrain::everyKind)
        std::cout << tile << " has the value " << static_cast<int>(tile) << '\n';

    return 0;
}

Output:

meadow has the value 10
marsh has the value 20
ridge has the value 30
glacier has the value 40

The search version costs a loop per lookup instead of a subscript, and it needs no count enumerator, but it still requires the values to be distinct. It also gains something the indexed version lacks: an answer for a value that is not an enumerator at all, rather than undefined behavior.

Danger
Indexing a table with an enumerator whose value is outside 0 to size - 1 is undefined behavior, not a runtime error. There is no bounds check to catch it, so the static_assert on the table length and the discipline of sequential enumerators are the only things standing between you and a silent corruption.

Looking Forward

Everything here relies on unscoped enumerators converting to integers on their own. Scoped enumerations (enum class) refuse that conversion, so an enum class used as an index needs an explicit static_cast at every subscript, or a small helper that performs the cast once. The trade you are making is extra typing against the type safety of enumerators that cannot be mixed up with plain integers or with a different enumeration.

The tables in this lesson each hold one fact apiece. When an enumerator carries several related facts, the tidier arrangement is a single std::array of a struct type rather than several parallel arrays, since one array of records cannot drift out of step with itself the way two arrays can. The static_assert on the record count is still worth writing.

Key Terminology

Count enumerator: a final enumerator such as max_terrains whose value equals the number of real enumerators before it, added so the count is available as a compile-time constant.

Parallel arrays: two or more arrays whose element at a given position describe the same thing, so a single index reaches all of them.

Class template argument deduction (CTAD): the rule that lets std::array stepCost { 5, 12, 9, 20 } deduce both the element type and the length from the initializers.

sv suffix: the literal suffix from std::string_view_literals that makes a string literal a std::string_view, so an array of them deduces to std::array<std::string_view, N> rather than an array of const char*.

Fail state: the condition an input stream enters when a read cannot be satisfied, set here with setstate(std::ios_base::failbit) and detected by testing the stream in a boolean context.

Summary

The invariant: an unscoped enumerator's integral value is the position of its data in a parallel array. Every technique in this lesson is a use of that one fact.

Guard the length: pair each table with static_assert(std::size(table) == max_whatever). CTAD will happily size an array to however many initializers you typed, and a short table turns an ordinary subscript into undefined behavior.

Names are just data: a constexpr std::array of std::string_view, written with the sv suffix, converts an enumerator to text with a subscript. Wrap it in an operator<< overload that takes and returns std::ostream& so streams chain.

Text back to enumerator: scan the same name table; the position of the match is the enumerator's value, so static_cast on the loop index finishes the job. In an operator>> overload, signal no match with setstate(std::ios_base::failbit).

Iteration: a range-based for loop cannot traverse an enumeration type. Build a constexpr std::array of the enumerators and traverse that instead; the loop variable is deduced as the enumeration type, so no casting is needed in the body.

Uniqueness is required: enumerators sharing a value cannot be told apart by either the indexing or the searching approach.

When values are not sequential: replace the subscript with a search over an array of enumerators, paired position for position with the data tables.