String Conversion for Enumerations
Convert enumerators to human-readable strings for output and debugging.
What Is Enumeration to String Conversion?
Enumeration to string conversion is the pair of lookup functions you write yourself to turn an enumerator into readable text and readable text back into an enumerator. C++ gives you neither direction for free, so both are hand-written, and both are short.
The reason is worth understanding before writing any code. An enumerator name is a label the compiler uses while it is translating your source. The object that survives translation holds nothing but a number of the enumeration's underlying type. By the time the program runs, the spelling of express is gone.
#include <iostream>
enum ShippingSpeed
{
economy, // 0
standard, // 1
express, // 2
overnight, // 3
};
int main()
{
ShippingSpeed chosen{ express };
std::cout << "Dispatch method: " << chosen << '\n';
return 0;
}
Output:
Dispatch method: 2
As the previous lesson covered, there is no operator<< that takes a ShippingSpeed, so the value converts to its integral value and the int overload prints that. A 2 is correct and useless: nobody reading a dispatch note wants to look up what speed 2 was.
Some languages ship a reflection facility that can hand you the name of an enumerator at run time. C++20 does not, which is exactly why this lesson exists. A static reflection facility has been voted into a future revision of the standard, but nothing you compile today can rely on it, so the mapping between values and text is yours to write and yours to keep in step.
The Outbound Direction: Value to Text
The standard approach is a function that takes an enumerator and returns its text. A switch statement is the usual body, because a switch can be used on an enumerated value directly and a case label per enumerator makes a missing one easy to spot.
#include <iostream>
#include <string_view>
enum ShippingSpeed
{
economy,
standard,
express,
overnight,
};
constexpr std::string_view speedLabel(ShippingSpeed speed)
{
switch (speed)
{
case economy: return "Economy";
case standard: return "Standard";
case express: return "Express";
case overnight: return "Overnight";
default: return "Unlisted";
}
}
static_assert(speedLabel(express) == "Express");
int main()
{
constexpr ShippingSpeed chosen{ express };
std::cout << "Dispatch method: " << speedLabel(chosen) << '\n';
return 0;
}
Output:
Dispatch method: Express
Three decisions in that function are worth pulling apart.
The return type is std::string_view. Each case returns a C-style string literal, and a std::string_view is a non-owning view of characters that already exist. Returning std::string instead would copy those characters into a freshly allocated buffer on every single call, only to throw it away after printing. The view copies a pointer and a length and allocates nothing.
The function is constexpr. Labels are needed in constant expressions surprisingly often, and marking the function constexpr lets it run at compile time when its argument is a constant. That is what the static_assert above proves: the comparison happens during compilation, so a mistyped label is a build failure rather than a bad line in a report. Constexpr functions have a chapter of their own further on, and until then the keyword can be read as permission to evaluate the call during compilation.
There is a default case. More on that below, because it is the part that most often gets left out.
Returning a
std::string_view is only safe when the characters it views outlive the call. Here they do: string literals exist for the entire run of the program, so the view handed back to the caller points at storage that can never go away. Viewing a local std::string instead would leave the caller holding a dangling view.
Return
std::string_view from a lookup function whose every branch returns a string literal. Reserve std::string for cases where the text is assembled at run time.
The Default Case Is Load Bearing
It is tempting to skip the default case on the grounds that the four enumerators are the only four values that exist. They are not, and the most common way to discover this is not an exotic cast. It is a colleague adding a fifth enumerator six months later.
#include <iostream>
#include <string_view>
enum ShippingSpeed
{
economy,
standard,
express,
overnight,
sameDay, // added later, and the switch below was never updated
};
constexpr std::string_view speedLabel(ShippingSpeed speed)
{
switch (speed)
{
case economy: return "Economy";
case standard: return "Standard";
case express: return "Express";
case overnight: return "Overnight";
default: return "Unlisted";
}
}
int main()
{
std::cout << speedLabel(overnight) << '\n';
std::cout << speedLabel(sameDay) << '\n';
return 0;
}
Output:
Overnight
Unlisted
The stale switch produces a wrong-but-harmless label instead of a function that falls off its end without returning anything. Returning an obvious placeholder such as "Unlisted", or asserting so the problem surfaces during testing, are both reasonable choices. Silently returning an empty string is not, because an empty label looks like missing data rather than a bug.
There is a second option, and it makes a real trade-off. Delete the default case, cover every enumerator explicitly, and put the fallback return after the switch. GCC then reports the gap at compile time. The function below is written that way on purpose to show the diagnostic:
constexpr std::string_view speedLabel(ShippingSpeed speed)
{
switch (speed)
{
case economy: return "Economy";
case standard: return "Standard";
case express: return "Express";
case overnight: return "Overnight";
}
return "Unlisted";
}
Compiling that with -Wall produces:
warning: enumeration value 'sameDay' not handled in switch [-Wswitch]
default inside the switch |
no default, fallback after it |
|
|---|---|---|
| A newly added enumerator | compiles silently, prints the placeholder | raises -Wswitch at compile time |
| A value no enumerator names | returns the placeholder | returns the placeholder |
| Cost of the safety | a wrong label can reach production | every new enumerator forces an edit |
Prefer the version without a
default label when the enumeration is yours to maintain, so the compiler nags you about every new enumerator. Keep a fallback return after the switch regardless, since an enumeration object can hold a value no case covers.
The Inbound Direction: Text to Value
Reading a value back in is the harder half, because the text arriving from a user is not guaranteed to correspond to anything. Two designs are common, and they trade off differently.
| Numbered menu | Typed word | |
|---|---|---|
| What the user supplies | an integer | a word |
| How the match is made | range check, then a cast | a comparison against each name |
| Failure mode | out-of-range number | unrecognized word |
| Cost of adding an enumerator | renumber the prompt | add one comparison |
| Best for | short, stable lists | anything the user should read back |
Reading a Number and Casting It
std::cin >> chosen does not compile for a program-defined type: the standard library has no operator>> that knows what a ShippingSpeed is. The workaround is to read an int and convert it.
#include <iostream>
#include <string_view>
enum ShippingSpeed
{
economy,
standard,
express,
overnight,
};
constexpr std::string_view speedLabel(ShippingSpeed speed)
{
switch (speed)
{
case economy: return "Economy";
case standard: return "Standard";
case express: return "Express";
case overnight: return "Overnight";
default: return "Unlisted";
}
}
int main()
{
std::cout << "Pick a speed (0 economy, 1 standard, 2 express, 3 overnight): ";
int picked{};
std::cin >> picked;
if (picked < economy || picked > overnight)
{
std::cout << "\nThat number is not on the price list\n";
}
else
{
ShippingSpeed chosen{ static_cast<ShippingSpeed>(picked) };
std::cout << "\nDispatching by " << speedLabel(chosen) << '\n';
}
return 0;
}
Given the input:
3
Output:
Pick a speed (0 economy, 1 standard, 2 express, 3 overnight):
Dispatching by Overnight
The static_cast is not optional. An integer never converts implicitly to an enumeration type, so the matched number has to be cast back explicitly before it can be stored in a ShippingSpeed.
Validate before you cast, never after. As the previous lesson covered, casting a value outside the enumeration's range is undefined behavior, and for an enumeration with no explicit base that range can be as narrow as the enumerators themselves. Here the four enumerators occupy 0 through 3, so
static_cast<ShippingSpeed>(9) is already undefined even though the cast compiles without complaint.
Reading a Word and Looking It Up
Matching on a word is friendlier, and it leaves the enumerator numbers free to change. It needs two things the outbound direction did not.
First, a switch will not work here. A switch requires an integral or enumerated value, and the input is text, so the matching becomes a chain of comparisons.
Second, a lookup that can fail needs somewhere to put "no match". Adding a notFound enumerator to the enumeration itself pollutes the type with a value that is not a shipping speed, so std::optional is the better fit: it carries either a valid enumerator or nothing at all.
#include <iostream>
#include <optional>
#include <string_view>
enum ShippingSpeed
{
economy,
standard,
express,
overnight,
};
constexpr std::string_view speedLabel(ShippingSpeed speed)
{
switch (speed)
{
case economy: return "Economy";
case standard: return "Standard";
case express: return "Express";
case overnight: return "Overnight";
default: return "Unlisted";
}
}
constexpr std::optional<ShippingSpeed> speedFromWord(std::string_view word)
{
if (word == "economy") return economy;
if (word == "standard") return standard;
if (word == "express") return express;
if (word == "overnight") return overnight;
return {};
}
void quote(std::string_view typed)
{
std::optional<ShippingSpeed> chosen{ speedFromWord(typed) };
if (chosen)
std::cout << typed << " ships as " << speedLabel(*chosen) << '\n';
else
std::cout << typed << " is not on the price list\n";
}
int main()
{
quote("express");
quote("airmail");
return 0;
}
Output:
express ships as Express
airmail is not on the price list
Each return inside speedFromWord hands back an enumerator, which is why no cast appears anywhere in it. Had the function matched positions in a list and returned an index instead, that index would have needed a static_cast<ShippingSpeed> before it could be stored. Returning {} builds an empty std::optional, and the if (chosen) test in quote distinguishes a real answer from a miss without any sentinel value.
The two functions together form a round trip: speedFromWord turns text into a value, speedLabel turns it back into text. Keeping them next to each other in the same file is the cheapest way to notice when one has drifted out of step with the other.
A chain of comparisons is fine for a handful of enumerators. When the list grows, a later lesson pairs an enumeration with a
std::array of names and indexes straight into it, which removes the per-name code entirely.
Accepting Any Letter Case
"Express" does not match "express", so a user who capitalizes the first letter gets told their speed is not on the price list. Fold the incoming text to lower case first, and the comparison chain does not have to change at all.
#include <cstddef>
#include <iostream>
#include <optional>
#include <string>
#include <string_view>
enum ShippingSpeed
{
economy,
standard,
express,
overnight,
};
constexpr std::string_view speedLabel(ShippingSpeed speed)
{
switch (speed)
{
case economy: return "Economy";
case standard: return "Standard";
case express: return "Express";
case overnight: return "Overnight";
default: return "Unlisted";
}
}
constexpr std::optional<ShippingSpeed> speedFromWord(std::string_view word)
{
if (word == "economy") return economy;
if (word == "standard") return standard;
if (word == "express") return express;
if (word == "overnight") return overnight;
return {};
}
std::string toAsciiLowerCase(std::string_view word)
{
std::string folded{};
for (std::size_t pos{ 0 }; pos < word.length(); ++pos)
{
char letter{ word[pos] };
if (letter >= 'A' && letter <= 'Z')
letter = static_cast<char>(letter - 'A' + 'a');
folded += letter;
}
return folded;
}
void quote(std::string_view typed)
{
std::optional<ShippingSpeed> chosen{ speedFromWord(toAsciiLowerCase(typed)) };
if (chosen)
std::cout << typed << " ships as " << speedLabel(*chosen) << '\n';
else
std::cout << typed << " is not on the price list\n";
}
int main()
{
quote("Express");
quote("OVERNIGHT");
quote("airmail");
return 0;
}
Output:
Express ships as Express
OVERNIGHT ships as Overnight
airmail is not on the price list
toAsciiLowerCase returns a std::string rather than a std::string_view because it builds new characters, and a view of characters that are about to be destroyed would dangle. The name says ASCII deliberately: shifting 'A' through 'Z' handles English text and nothing else. Accented and non-Latin letters pass through untouched, which is fine for matching keywords you chose yourself and not fine for arbitrary user text.
Summary
Enumerator names do not survive compilation. The object holds a number of the underlying type, C++20 has no reflection to recover the name, and so every conversion in either direction is code you write.
Value to text is a switch. One case label per enumerator, each returning a string literal, with a default or a fallback return after the switch so no path leaves the function without a value. Prefer letting the compiler's -Wswitch warning find stale switches for enumerations you maintain.
Return std::string_view, not std::string. Every branch returns a literal that lives for the whole program, so a view is safe, and it avoids a copy and an allocation on every call. Marking the function constexpr lets labels be checked and used at compile time.
Text to value comes in two shapes. A numbered menu reads an int, range checks it, then uses static_cast to convert it, since integers never convert to enumeration types implicitly and an out-of-range cast is undefined behavior. A typed word is matched by a chain of comparisons, because a switch cannot be used on text.
Return std::optional from a lookup that can fail. It represents "matched this enumerator" and "matched nothing" in the return type itself, with no invalid enumerator added to the enumeration and no sentinel value for a caller to forget to check.
Both directions still read awkwardly at the call site: speedLabel(chosen) where you wanted chosen. The next lesson removes that by teaching the stream operators about your type directly.
How did you find this lesson?
Your rating helps us improve the content.
Create an account to track your progress and access interactive exercises. Already have one? Sign in.
String Conversion for Enumerations - Quiz
Test your understanding of the lesson.
Practice Exercises
Log Level Formatter
Create a logging system that converts log level enumerations to strings for display. Implement a function that takes a log level enum and returns its string representation.
Lesson Discussion
Share your thoughts and questions
No comments yet. Be the first to share your thoughts!