One of the bigger documentation problems with arrays is that integer indices do not provide any information to the programmer about the meaning of the index.
Consider an array holding 5 test scores:
#include <vector>
int main()
{
std::vector testScores { 78, 94, 66, 77, 14 };
testScores[2] = 76; // who does this represent?
}
Who is the student represented by testScores[2]
? It’s not clear.
Using unscoped enumerators for indexing
In lesson 16.3 -- std::vector and the unsigned length and subscript problem, we spent a lot of time discussing how the index of std::vector<T>::operator[]
(and the other C++ container classes that can be subscripted) has type size_type
, which is generally an alias for std::size_t
. Therefore, our indices either need to be of type std::size_t
, or a type that converts to std::size_t
.
Since unscoped enumerations will implicitly convert to a std::size_t
, this means we can use unscoped enumerations as array indices to help document the meaning of the index:
#include <vector>
namespace Students
{
enum Names
{
kenny, // 0
kyle, // 1
stan, // 2
butters, // 3
cartman, // 4
max_students // 5
};
}
int main()
{
std::vector testScores { 78, 94, 66, 77, 14 };
testScores[Students::stan] = 76; // we are now updating the test score belonging to stan
return 0;
}
In this way, it’s much clearer what each of the array elements represents.
Because enumerators are implicitly constexpr, conversion of an enumerator to an unsigned integral type is not considered a narrowing conversion, thus avoiding signed/unsigned indexing problems.
Using a non-constexpr unscoped enumeration for indexing
The underlying type of an unscoped enumeration is implementation defined (and thus, could be either a signed or unsigned integral type). Because enumerators are implicitly constexpr, as long as we stick to indexing with unscoped enumerators, we won’t run into sign conversion issues.
However, if we define a non-constexpr variable of the enumeration type, and then try to index our std::vector
with that, we may get sign conversion warnings on any platform that defaults unscoped enumerations to a signed type:
#include <vector>
namespace Students
{
enum Names
{
kenny, // 0
kyle, // 1
stan, // 2
butters, // 3
cartman, // 4
max_students // 5
};
}
int main()
{
std::vector testScores { 78, 94, 66, 77, 14 };
Students::Names name { Students::stan }; // non-constexpr
testScores[name] = 76; // may trigger a sign conversion warning if Student::Names defaults to a signed underlying type
return 0;
}
In this particular case, we could make name
constexpr (so that the conversion from a constexpr signed integral type to std::size_t
is non-narrowing). However, that won’t work when our initializer isn’t a constant expression.
An alternate option is to explicitly specify the underlying type of the enumeration to be an unsigned int:
#include <vector>
namespace Students
{
enum Names : unsigned int // explicitly specifies the underlying type is unsigned int
{
kenny, // 0
kyle, // 1
stan, // 2
butters, // 3
cartman, // 4
max_students // 5
};
}
int main()
{
std::vector testScores { 78, 94, 66, 77, 14 };
Students::Names name { Students::stan }; // non-constexpr
testScores[name] = 76; // not a sign conversion since name is unsigned
return 0;
}
In the example above, since name
is now guaranteed to be an unsigned int
, it can be converted to a std::size_t
without sign conversion issues.
Using a count enumerator
Note that we have defined an extra enumerator at the end of the enumerator list named max_students
. If all the prior enumerators are using default values (which is recommended) this enumerator will have a default value matching the count of the preceding enumerators. In the example above, max_students
has value 5
, as there are 5 enumerators defined prior. Informally, we’ll call this a count enumerator, as its value represents the count of previously defined enumerators.
This count enumerator can then be used anywhere we need a count of the prior enumerators. For example:
#include <iostream>
#include <vector>
namespace Students
{
enum Names
{
kenny, // 0
kyle, // 1
stan, // 2
butters, // 3
cartman, // 4
// add future enumerators here
max_students // 5
};
}
int main()
{
std::vector<int> testScores(Students::max_students); // Create a vector with 5 elements
testScores[Students::stan] = 76; // we are now updating the test score belonging to stan
std::cout << "The class has " << Students::max_students << " students\n";
return 0;
}
We use max_students
in two places: first, we create a std::vector
with a length of max_students
, so the vector will have one element per student. We also use max_students
to print the number of students.
This technique is also nice because if another enumerator is added later (just before max_students
), then max_students
will automatically become one larger, and all our arrays using max_students
will update to use the new length without further modification.
#include <vector>
#include <iostream>
namespace Students
{
enum Names
{
kenny, // 0
kyle, // 1
stan, // 2
butters, // 3
cartman, // 4
wendy, // 5 (added)
// add future enumerators here
max_students // now 6
};
}
int main()
{
std::vector<int> testScores(Students::max_students); // will now allocate 6 elements
testScores[Students::stan] = 76; // still works
std::cout << "The class has " << Students::max_students << " students\n";
return 0;
}
Asserting on array length with a count enumerator
More often, we’re creating an array using an initializer list of values, with the intent of indexing that array with enumerators. In such cases, it can be useful to assert that the size of the container equals our count enumerator. If this assert triggers, then either our enumerator list is incorrect somehow, or we have provided the wrong number of initializers. This can easily happen when a new enumerator is added to the enumeration, but a new initialization value is not added to the array.
For example:
#include <cassert>
#include <iostream>
#include <vector>
enum StudentNames
{
kenny, // 0
kyle, // 1
stan, // 2
butters, // 3
cartman, // 4
max_students // 5
};
int main()
{
std::vector testScores { 78, 94, 66, 77, 14 };
// Ensure the number of test scores is the same as the number of students
assert(std::size(testScores) == max_students);
return 0;
}
Tip
If your array is constexpr, then you should static_assert
instead. std::vector
doesn’t support constexpr, but std::array
(and C-style arrays) do.
We discuss this further in lesson 17.3 -- Passing and returning std::array.
Best practice
Use a static_assert
to ensure the length of your constexpr array matches your count enumerator.
Use an assert
to ensure the length of your non-constexpr array matches your count enumerator.
Arrays and enum classes
Because unscoped enumerations pollute the namespace they are defined in with their enumerators, it is preferable to use enum classes in cases where the enum is not already contained in another scope region (e.g. a namespace or class).
However, because enum classes don’t have an implicit conversion to integral types, we run into a problem when we try to use their enumerators as array indices:
#include <iostream>
#include <vector>
enum class StudentNames // now an enum class
{
kenny, // 0
kyle, // 1
stan, // 2
butters, // 3
cartman, // 4
max_students // 5
};
int main()
{
// compile error: no conversion from StudentNames to std::size_t
std::vector<int> testScores(StudentNames::max_students);
// compile error: no conversion from StudentNames to std::size_t
testScores[StudentNames::stan] = 76;
// compile error: no conversion from StudentNames to any type that operator<< can output
std::cout << "The class has " << StudentNames::max_students << " students\n";
return 0;
}
There are a couple of ways to address this. Most obviously, we can static_cast
the enumerator to an integer:
#include <iostream>
#include <vector>
enum class StudentNames
{
kenny, // 0
kyle, // 1
stan, // 2
butters, // 3
cartman, // 4
max_students // 5
};
int main()
{
std::vector<int> testScores(static_cast<int>(StudentNames::max_students));
testScores[static_cast<int>(StudentNames::stan)] = 76;
std::cout << "The class has " << static_cast<int>(StudentNames::max_students) << " students\n";
return 0;
}
However, this is not only a pain to type, it also clutters up our code significantly.
A better option is to use the helper function that we introduced in lesson 13.6 -- Scoped enumerations (enum classes), which allows us to convert the enumerators of enum classes to integral values using unary operator+
.
#include <iostream>
#include <type_traits> // for std::underlying_type_t
#include <vector>
enum class StudentNames
{
kenny, // 0
kyle, // 1
stan, // 2
butters, // 3
cartman, // 4
max_students // 5
};
// Overload the unary + operator to convert StudentNames to the underlying type
constexpr auto operator+(StudentNames a) noexcept
{
return static_cast<std::underlying_type_t<StudentNames>>(a);
}
int main()
{
std::vector<int> testScores(+StudentNames::max_students);
testScores[+StudentNames::stan] = 76;
std::cout << "The class has " << +StudentNames::max_students << " students\n";
return 0;
}
However, if you’re going to be doing a lot of enumerator to integral conversions, it’s probably better to just use a standard enum inside a namespace (or class).
Quiz time
Question #1
Create a program-defined enum (inside a namespace) containing the names of the following animals: chicken, dog, cat, elephant, duck, and snake. Define an array with an element for each of these animals, and use an initializer list to initialize each element to hold the number of legs that animal has. Assert that the array has the correct number of initializers.
Write a main() function that prints the number of legs an elephant has, using the enumerator.