Найти в Дзене
LITVINOV-UPGRADE-LINUX

Перечисления (enum) в C++98–C++20

Перечисления (enumeration, enum) – это именованные наборы целочисленных констант. Другими словами, enum позволяет определить новый тип, значения которого ограничены перечисленным набором именованных констант, каждая из которых соответствует определенному числу. Например, вместо использования «магических чисел» 0, 1, 2 для кодирования, скажем, цветов, можно объявить перечисление: enum Color { RED, GREEN, BLUE }; Здесь Color – новый тип, а RED, GREEN, BLUE – перечислители (enum-элементы). По умолчанию первому перечислителю присваивается значение 0, а каждому следующему – на единицу больше предыдущего, если явные значения не указаны. Таким образом, в примере RED=0, GREEN=1, BLUE=2. Перечислители ведут себя как константы этого типа. Переменные типа Color могут хранить только одно из указанных значений (формально любое значение, которое может быть представлено подлежащим типом, но легально присваивать обычно только определённые перечисленные константы). Исторически перечисления появились
Оглавление

1. Введение в enum

Перечисления (enumeration, enum) – это именованные наборы целочисленных констант. Другими словами, enum позволяет определить новый тип, значения которого ограничены перечисленным набором именованных констант, каждая из которых соответствует определенному числу. Например, вместо использования «магических чисел» 0, 1, 2 для кодирования, скажем, цветов, можно объявить перечисление:

enum Color { RED, GREEN, BLUE };

Здесь Color – новый тип, а RED, GREEN, BLUE – перечислители (enum-элементы). По умолчанию первому перечислителю присваивается значение 0, а каждому следующему – на единицу больше предыдущего, если явные значения не указаны. Таким образом, в примере RED=0, GREEN=1, BLUE=2. Перечислители ведут себя как константы этого типа. Переменные типа Color могут хранить только одно из указанных значений (формально любое значение, которое может быть представлено подлежащим типом, но легально присваивать обычно только определённые перечисленные константы).

Исторически перечисления появились в языке C и унаследованы C++98 без изменений. В ранних стандартах C++ перечисления являлись неструктурированными (unscoped): они не создавали своего собственного пространства имён, и перечислители были видны в окружающей области видимости. Кроме того, такие enum допускали неявные преобразования в целые типы, что порой приводило к ошибкам. В C++11 были добавлены ограниченные перечисления (scoped enums), которые решают многие проблемы старого подхода. Также в новых стандартах появились возможности явно управлять типом данных enum и другими аспектами. В этом руководстве мы рассмотрим все эти эволюционные изменения – от классических unscoped-перечислений C++98 до возможностей C++20.

2. Неструктурированные перечисления (unscoped enum, C++98)

Неструктурированные перечисления – это «обычные» enum из C и C++98. Они объявляются ключевым словом enum без указания class/struct. Пример:

enum Fruit { Apple, Banana, Cherry };

В таком enum перечислители (Apple, Banana, Cherry) автоматически имеют целочисленные значения по порядку (0, 1, 2, если не заданы явно). Важная особенность старых enum: перечислители добавляются в окружающую область видимости. В примере выше после объявления можно писать просто Apple или Banana как синонимы целых чисел 0 и 1. Эти имена не имеют префикса типа, что может приводить к конфликтам. Например, два разных enum в одном скоупе не могут иметь одинаковые имена элементов:

enum Color { RED, GREEN, BLUE };
// enum TrafficLight { RED, YELLOW, GREEN }; // Ошибка: RED и GREEN уже определены[2]

Во избежание конфликтов имен разработчики ранее часто добавляли префиксы к именам перечислителей (например, COLOR_RED, COLOR_GREEN), либо использовали разные пространства имён. Тем не менее, безотносительно стиля, все элементы unscoped-enum «утекают» наружу и могут вызвать коллизии имён.

Другая особенность неструктурированных enum – неявное преобразование в целочисленный тип. Перечисление фактически имеет некий целочисленный подлежащий тип (обычно int, если не требуется больше диапазона). Значение перечислителя можно использовать там, где требуется целое число – это происходит автоматически (promotion). Например:

enum Fruit { Apple, Banana, Cherry };
Fruit f = Banana;
int n = f; // неявно Fruit -> int, n станет 1
std::cout << "Fruit #" << n << std::endl; // вывод: Fruit #1

Переменную типа Fruit можно сравнивать с целым числом или участвовать в арифметических выражениях – enum не мешает этому, так как легко приводится к int. Однако обратное преобразование (int -> enum) не выполняется неявно. Нельзя присвоить переменной Fruit произвольное число без явного приведения:

f = (Fruit)2; // C-стиль приведения, осторожно: 2 не соответствует ни одному Fruit
// f = 2; // Ошибка компиляции: нет неявного int -> Fruit

Таким образом, unscoped enum предоставляет удобство использования имен вместо чисел, но не обеспечивает строгой типизации. Компилятор не предотвратит некорректное использование enum как целого числа в выражениях. Например, следующий код формально компилируется (в С++98), хотя логически неверен:

enum Color { RED, GREEN, BLUE };
Color c = RED;
Color next = (Color)(static_cast<int>(c) + 1); // некорректное значение, не равное ни RED, ни GREEN, ни BLUE

С такой ситуацией связаны риски: next будет содержать значение 1 (то есть как будто GREEN), но на самом деле мы не вызывали никакой операции, гарантирующей получение следующего цвета корректно. В языке отсутствует встроенная защита от подобных ошибок в старых перечислениях[3][4].

Подлежащим типом неструктурированного enum по умолчанию является целочисленный тип, достаточный для хранения всех перечислителей. В C++98 разработчик не мог явно указать этот тип – выбор предоставлен реализации. Как правило, компилятор берет int, если все значения помещаются в диапазон int, либо больший тип (например, unsigned long или long long), если какие-то перечислители слишком велики. Некоторые компиляторы могли выбрать более мелкий тип (char или short), если это достаточно[5]. Например:

// значения помещаются в 1 байт, но компилятор может взять int (4 байта) enum Small { S0 = 0, S1 = 100 }; // одно значение ~1e10 не влезает в 32 бита, тип будет 64-битный (long long)
enum Large { L0 = 0, L1 = 10000000000 }; // возможно 4 и 8 (в байтах) на типичной архитектуре
std::cout << sizeof(Small) << " " << sizeof(Large) << std::endl;

Таким образом, размер объекта enum не строго определён в старом стандарте – он может быть разным на разных платформах или компиляторах, хотя практически обычно равен 4 байтам (sizeof(int)) для большинства небольших перечислений.

3. Ограниченные перечисления: enum class и enum struct (C++11)

В C++11 появились ограниченные перечисления (scoped enums), синтаксически вводимые как enum class (или эквивалентно enum struct). Они решают сразу несколько проблем старых enum. Такие перечисления объявляются с указанием ключевого слова class или struct после enum. Например:

enum class Color { Red, Green, Blue };
enum struct LogLevel { Debug, Info, Warning, Error };

Ключевые слова class и struct в данном контексте ничем не отличаются – это синонимы[6]. Возможность писать enum struct была добавлена для единообразия синтаксиса, но на практическое содержание не влияет[7]. В 99% случаев разработчики используют слово class для объявления ограниченных enum.

Основные особенности enum class:

  • Перечислители не попадают в окружающую область видимости. Они являются вложенными именами внутри типа перечисления. В примере выше доступны имена Color::Red, Color::Green и т.д., но просто Red вне контекста Color не определён. Это устраняет конфликты имен между разными перечислениями. Теперь можно иметь два enum class с одинаковыми именами перечислителей, и это не вызывает ошибок, т.к. имена разграничены собственным типом:

enum class Color1 { Red, Green, Blue };
enum class Color2 { Red, Green, Blue };
Color1 c1 = Color1::Red;
Color2 c2 = Color2::Red; // OK, Color1::Red и Color2::Red – разные сущности]

  • Значения enum class не приводятся неявно к целому типу. Это повышает безопасность типов. Нельзя случайно использовать Color::Red как число или выполнить арифметику напрямую с переменными типа Color. Попытка присвоить int переменной Color или наоборот вызовет ошибку компиляции. Например:
Color color = Color::Red;
// int x = color; // Ошибка: нет неявного преобразования Color -> int
// color = 1; // Ошибка: 1 (int) не приводится автоматически к Color
// Color next = color + 1; // Ошибка: выражение color+1 не имеет смысла без явного кастинга

Если действительно нужно получить соответствующее целое значение, нужно выполнить явное приведение (например, static_cast<int>(Color::Red) получит 0). Обратное преобразование – из числа в enum class – тоже возможно только явно: auto c = static_cast<Color>(1); (но разработчик должен сам следить, чтобы значение 1 соответствовало одному из перечислителей, иначе получим валидный объект Color со значением вне диапазона объявленных, что может привести к логическим ошибкам).

  • По умолчанию подлежащий тип (см. следующую секцию) для enum class – int, как и для обычных enum (если явно не указан другой). Однако, в отличие от старых enum, в C++11 появилась возможность явно задать подлежащий целочисленный тип.

Пример использования ограниченного перечисления:

enum class FileMode { Read, Write, Append };

FileMode mode = FileMode::Read;
if (mode == FileMode::Read) {
std::cout << "Mode is Read\n";
}
// mode = 2; // Ошибка: нельзя присвоить int
int n = static_cast<int>(mode); // n = 0, явное преобразование

В примере FileMode::Read – это 0 (если не указаны другие значения), но получить это число можно только явно. Перечислители Read, Write, Append не конфликтуют с другими именами и требуют квалификатора FileMode::.

Enum struct: как упоминалось, enum struct идентичен enum class по поведению. Его можно использовать, например, для семантического разделения разных групп перечислений:

enum struct Unit { Meter, Kilogram, Second };
enum struct State { Meter, Name, Coord };

Здесь Unit::Meter и State::Meter – разные константы, что невозможно было бы с неструктурированными enum без ухищрений.

Ограниченные enum – это предпочтительный способ объявлять новые перечисления в современном C++, поскольку они предотвращают множество ошибок и упрощают организацию кода. Далее мы рассмотрим дополнительные возможности, связанные с enum, которые появились начиная с C++11, такие как задание типа и использование в битовых масках.

4. Подлежащий тип перечисления (underlying type) (C++11)

Подлежащим типом enum называется целочисленный тип, в котором хранятся значения перечисления в памяти. В C++98 этот тип нельзя было задать явно – выбор оставался за компилятором. C++11 добавил возможность задавать underlying type явно как для scoped, так и для unscoped перечислений.

Синтаксис: после имени enum через двоеточие указывается желаемый целочисленный тип. Примеры:

enum class Permissions : unsigned short { Read = 1, Write = 2, Execute = 4 };
enum ErrorCode : std::uint8_t { Success = 0, LogicError = 1, IoError = 2 };

В первом случае Permissions будет храниться в unsigned short (обычно 2 байта). Во втором ErrorCode будет храниться в 1 байте (тип uint8_t). Теперь sizeof(ErrorCode) гарантированно равен 1. Без указания типа, компилятор мог бы выбрать int (4 байта) даже если значений мало[12].

Зачем управлять подлежащим типом? Во-первых, для оптимизации памяти – например, если нужно хранить большое количество элементов типа ErrorCode (в массиве или структуре), имеет смысл ужать их до минимума, раз у нас заведомо небольшой диапазон. Во-вторых, явное указание типа делает поведение enum более предсказуемым при взаимодействии с другими языками и ABI. Например, если в С API ожидается 32-битный int, можно задать enum : int для уверенности.

Кроме того, знание подлежащего типа открывает дополнительные возможности: можно объявлять перечисление неполностью (forward declaration). В C++11 разрешено объявить enum class без списка значений, если задан underlying type. Такая opaque enum declaration позволяет ссылаться на тип до определения. Пример:

enum class TokenType : uint32_t; // объявление (forward-declare), размер уже известен (4 байта)

struct Token {
TokenType type; // можно использовать не полностью определённый enum
/* ... */
};

enum class TokenType : uint32_t { Identifier, Number, End }; // определение

Без фиксированного underlying type такая предварительная декларация была бы невозможна (компилятору неизвестен размер TokenType). Для неструктурированных enum аналогично: enum X : long; объявляет незавершённое перечисление X типа long, определение которого будет позже. В практике forward-declaration enum встречается нечасто, но это полезно знать для ситуаций с взаимозависимыми объявлениями.

Следует помнить, что диапазон возможных значений enum определяется подлежащим типом. Если явно указанный тип слишком узок для всех перечислителей, программа не скомпилируется.

Например: попытка объявить enum E : unsigned char { A = 0, B = 300 }; приведёт к ошибке, потому что значение 300 не поместится в unsigned char (0..255).

5. Безопасность типов: enum class против обычных enum

Как видно из предыдущих разделов, enum class существенно повышает type safety (типобезопасность) по сравнению с традиционными enum. Подведём итоги этого сравнения:

  • Область видимости имен. Обычные enum «засоряют» внешнее пространство имён своими перечислителями. Enum class имеет собственный скоуп, что предотвращает конфликты. Например, если у вас есть enum class Status { Ok, Error }; и enum class NetworkStatus { Ok, Disconnected };, то использовать Ok напрямую нельзя – нужно Status::Ok или NetworkStatus::Ok, и конфликтов нет. В случае старых enum пришлось бы придумывать уникальные имена (STATUS_OK, NETSTATUS_OK и т.д.).
  • Неявные преобразования. Значение неструктурированного enum автоматически преобразуется к целому типу (обычно int) при необходимости[3]. Это означает, что вы можете случайно выполнить операции, непредназначенные для enum. Например, сложение двух enum: Color::RED + Color::GREEN сначала преобразует оба в int (0 + 1) и даст результат 1 (то есть непредусмотренное значение, по сути равное GREEN). В случае enum class такой код просто не компилируется – над Color нельзя выполнить operator+ без явного кастинга. Любое использование значения enum class как числа требует явного приведения типа. Обратное тоже верно: присвоение или сравнение enum class с числом не компилируется. Таким образом, enum class защищает от большинства случайных ошибок, заставляя программиста явно подтверждать свои намерения (через static_cast и т.п.).
  • Сравнение разных типов. Разные неструктурированные enum могут неявно преобразовываться в int, поэтому теоретически их можно сравнивать или смешивать в выражениях, и компилятор не возразит. Например, enum A { X=1 }; enum B { Y=1 }; bool eq = (X == Y); – компилируется, сравнение происходит как 1==1 (что true). Это, очевидно, логически неправильно – сравниваются несвязанные перечисления. Enum class не позволят такое сделать: A::X == B::Y – ошибка, эти типы несоизмеримы без кастов. Таким образом, enum class сохраняют строгую раздельность типов.
  • Безопасность значений. Даже с enum class можно явно привести любое целое значение к данному enum, получив валидный объект вне диапазона определённых имен. Например, auto bad = static_cast<Color>(5); где Color имеет только 0,1,2 – приведёт к значению 5 типа Color. Использование такого значения в логике программы – источник потенциальных ошибок. Компилятор этого не отследит, поэтому явные касты нужно применять осторожно. Рекомендуется дополнительно проверять результат или ограничивать такие приведения от неверных значений (например, с помощью assert в отладке). В C++17 появился тип std::optional и в целом более выразительные средства, поэтому вместо приведения int->enum часто лучше спроектировать API так, чтобы невозможные значения не возникали либо обрабатывались отдельно (см. также раздел о рефлексии).

В общем, лучшей практикой в современном C++ является использование enum class вместо необязательного применения старых enum. Старый синтаксис может пригодиться лишь в случаях, когда нужна полная совместимость с С или удобство побитовых операций (о последнем – далее).

6. Использование enum как битовых флагов

Перечисления часто применяются для представления набора битовых флагов, где каждому элементу соответствует отдельный бит в числе. Например, права доступа файлов можно представить флагами Read = 1, Write = 2, Execute = 4 и комбинировать их (1|2 = 3 означает право чтения и записи). Классический способ – использовать обычный (unscoped) enum для задания таких флагов с значениями, являющимися степенями двойки:

enum FilePermissions { Read = 1, Write = 2, Execute = 4 };
int perms = Read | Write; // комбинируем флаги (здесь perms типа int)
if (perms & Write) { /* проверяем наличие флага Write */ }

В примере выше переменная perms взята как int, а не FilePermissions, потому что при Read | Write каждый операнд неявно превращается в int (значения 1 и 2) и операция | выполняется как над int. Результат 3 затем хранится в perms. Можно было бы привести результат обратно к типу FilePermissions, но он не соответствует ни одному именованному элементу (3 – это комбинация флагов). Поэтому зачастую разработчики просто используют int или тип побольше (например, std::uint32_t) для хранения комбинаций флагов. Enum при этом служит лишь для объявления констант-битов.

С появлением enum class напрямую комбинировать их через побитовые операторы нельзя, потому что нет неявного преобразования в целое. Однако можно перегрузить соответствующие операторы для конкретного типа enum class, чтобы реализовать удобную работу с флагами. Рассмотрим современный подход:

#include <cstdint>
#include <iostream>

enum class Permissions : uint8_t { // определяем флаги в 8-битном поле
None = 0,
Read = 1 << 0, // 0x01
Write = 1 << 1, // 0x02
Execute = 1 << 2 // 0x04
};

// Перегрузка побитовых операторов для типа Permissions
inline Permissions operator|(Permissions a, Permissions b) {
return static_cast<Permissions>(
static_cast<uint8_t>(a) | static_cast<uint8_t>(b));
}
inline Permissions operator&(Permissions a, Permissions b) {
return static_cast<Permissions>(
static_cast<uint8_t>(a) & static_cast<uint8_t>(b));
}
inline Permissions operator~(Permissions a) {
return static_cast<Permissions>(
~static_cast<uint8_t>(a));
}

int main() {
Permissions p = Permissions::Read | Permissions::Write; // комбинирование через перегруженный |
if ((p & Permissions::Write) != Permissions::None) { // проверка флага через &
std::cout << "Write permission set\n";
}
Permissions q = ~Permissions::Read; // инвертирование (~) установит все биты кроме бита Read
if ((q & Permissions::Read) == Permissions::None) {
std::cout << "Read permission not set in q\n";
}
}

В этом примере мы определили Permissions как enum class с типом uint8_t – этого достаточно для трёх флагов. Далее перегрузили операторы |, & и ~ для удобства. Теперь можно комбинировать значения Permissions напрямую. Например, Permissions::Read | Permissions::Write возвращает значение типа Permissions равное 0x03 (битовая комбинация). Мы также определили Permissions::None = 0, чтобы обозначать "ни одного флага". Это полезно для сравнения результатов операций – как видно, проверка (p & Permissions::Write) != Permissions::None говорит о наличии флага.

Обратите внимание: перегрузка операторов выполнена через преобразование к uint8_t и обратно к Permissions. Мы могли бы использовать std::underlying_type_t<Permissions> вместо явного uint8_t для большей гибкости, но так как мы точно знаем тип, это не обязательно.

Стандартная библиотека не предлагает универсального способа «разрешить» побитовые операции для enum class. В некоторых библиотеках (Qt, DirectX) принято макросами или шаблонами автоматически перегружать операторы для перечислений, помеченных как флаги. В нашем случае мы сделали это вручную.

Стоит упомянуть, что начиная с C++17 сам стандарт использует enum class для битовых флагов:

пример:

– std::filesystem::perms (права файловой системы) объявлен как enum class perms : unsigned, и для него перегружены operator&, operator|, operator~ в <filesystem>.

– std::byte (C++17), который реализован как enum class byte : unsigned char {} и поддерживает побитовые операции. Это подтверждает, что enum class отлично подходит для битовых флагов, если слегка дополнить его операторами.

7. Преобразование enum в числа и обратно

Часто возникает задача получить числовое значение из элемента перечисления или наоборот получить enum по его числу. Рассмотрим оба направления.

Enum -> число

Для неструктурированных enum это тривиально – такое преобразование происходит неявно. Но полагаться на неявное преобразование не рекомендуется (даже в C++98 можно явно приводить для ясности). В современном коде, когда мы имеем enum class, необходимо использовать явное приведение. Обычно применяют static_cast:

enum class ResultCode : int { Success = 0, Error = 1 };
ResultCode r = ResultCode::Error;
int code = static_cast<int>(r);
std::cout << "Code = " << code << std::endl; // вывод: Code = 1

Здесь переменная r имеет значение ResultCode::Error, а после приведения мы получаем соответствующее целое 1. В общем случае приведение enum -> int безопасно.

Иногда вместо конкретного целочисленного типа хочется программно получить тип, используемый под enum. Для этого в <type_traits> есть шаблон std::underlying_type. Он позволяет узнать underlying type в compile-time. Например:

#include <type_traits>
using Under = std::underlying_type_t<ResultCode>; // Under будет равен типу int static_assert(std::is_same_v<Under, int>);

Это особенно полезно, если подлежащий тип не явно известен (или может отличаться на разных платформах). С C++11 можно писать std::underlying_type<Enum>::type, а начиная с C++14 добавлен удобный алиас _t как показано выше.

Начиная с C++23 планируется ещё более простой способ: функция-шаблон std::to_underlying(enumValue). Она возвращает числовое значение enum, избавляя от явного выписывания типа и кастов. Использование будет таким:

// Требуется стандарт C++23
int code2 = std::to_underlying(r); // эквивалент static_cast<int>(r)

Хотя C++23 выходит за рамки нашего обзора, мы упоминаем std::to_underlying, поскольку он уже может встречаться в новых материалах. До C++20 включительно разработчики используют static_cast или std::underlying_type как описано выше.

Число -> enum

Обратное преобразование – получить enum по заданному целому – возможно только явно. Для этого также применяется static_cast:

int n = 1;
ResultCode rc = static_cast<ResultCode>(n);

Однако здесь есть тонкость: если n не совпадает ни с одним из определённых значений перечисления, преобразование всё равно пройдет! Переменная rc получит значение 1, хотя в ResultCode определены только 0 (Success) и 1 (Error) – это как раз допустимый случай. А вот если бы n = 2, то rc стал бы иметь значение 2, которое не соответствует ни одному именованному элементу ResultCode. Формально такое значение не является невозможным для объекта enum (он хранит в себе число 2, что не нарушает размерности типа), но с точки зрения логики это обычно ошибка. Компилятор не предоставляет автоматического механизма проверки допустимости, поэтому ответственность лежит на программисте.

Можно вручную проверять попадание значения в диапазон перечислителя. Например, знать минимальный и максимальный код или использовать идиому с дополняющим элементом (см. раздел о количестве элементов). Например, если определён специальный элемент ResultCode::Last со значением 1 больше последнего реального кода, то можно проверить if (n < static_cast<int>(ResultCode::Last)) перед преобразованием. В противном случае – обработать как недопустимое значение.

Отметим, что C++17 немного упростил преобразование из числа в enum для случаев, когда вы инициализируете прямо при объявлении. Появилась возможность прямой list-инициализации: ResultCode r{1}; – это разрешено, если 1 укладывается в диапазон underlying type и не приводит к узкому преобразованию. Например:

ResultCode r{1}; // Ok в C++17, r будет ResultCode::Error (так как 1 совпадает)
ResultCode r2{42}; // Ok, 42 влезает в int, но r2 теперь содержит неопределённый код 42!

В C++14 пришлось бы писать static_cast<ResultCode>(1). Нововведение экономит синтаксис, но, как видно, не предотвращает ошибки (r2 получил 42 без жалоб). Поэтому даже с этой возможностью следует убедиться, что значение корректно.

В целом, для преобразования чисел в enum безопаснее использовать не прямые касты, а более выразительные конструкции. Например, можно написать функцию-фабрику, возвращающую std::optional<Enum> – которая вернёт std::nullopt, если число не соответствует ни одному из определённых значений. Либо хотя бы assert вставлять на время отладки. Это относится и к старым enum: там преобразование int->enum также требует явного кастинга и не проверяется.

8. Преобразование enum в строки и обратно

Стандарт C++ не предоставляет встроенного механизма для получения имени перечислителя в виде строки или для выбора элемента enum по строковому названию. Тем не менее, такие задачи часто возникают – например, для вывода значений в лог, для парсинга конфигурационных файлов, пользовательского ввода и т.д. Рассмотрим, как можно конвертировать enum в строку и наоборот.

Enum -> строка

Самый прямой способ – написать функцию, которая сопоставляет каждому значению enum читаемое название. Чаще всего это делается через оператор switch:

#include <string>
enum class Color { Red, Green, Blue };

std::string toString(Color c) {
switch (c) {
case Color::Red: return "Red";
case Color::Green: return "Green";
case Color::Blue: return "Blue";
}
return "Unknown";
}

int main() {
Color a = Color::Green;
std::cout << "Color is " << toString(a) << std::endl; // Вывод: Color is Green
}

Здесь каждая ветка case возвращает строковой литерал. Мы предусмотрели возвращение "Unknown" на случай, если в функцию попадёт значение вне диапазона определённых (что, как мы обсудили, теоретически может случиться при неправильном cast).

Альтернативный подход – использовать массив строк, индексированный значением enum. Этот метод удобен, если значения enum представляют плотный диапазон от 0 до N-1 без пропусков. Например:

static const char* ColorNames[] = { "Red", "Green", "Blue" };
std::string toStringArray(Color c) {
size_t index = static_cast<size_t>(c);
if (index < sizeof(ColorNames)/sizeof(ColorNames[0]))
return ColorNames[index];
return "Unknown";
}

В данном случае мы рассчитываем, что Color::Red=0, Green=1, Blue=2. Такой подход особенно удобен, если применён трюк с последним элементом Count (см. следующий раздел) – тогда размер массива можно задать через значение Count. Однако он требует, чтобы значения шли подряд с нуля, иначе придётся либо вручную вычислять индекс (например, если enum не нулепозиционный), либо использовать map.

Для неструктурированных enum (старых) ситуация аналогична – нет автоматической функции. Но у них хотя бы перечислители доступны как символы препроцессору, и иногда используют макросы X для генерации строк. Например, можно прибегнуть к трюку: объявить псевдо-функцию:

#define COLOR_LIST(X) \
X(Red) \
X(Green) \
X(Blue)

enum Color {
#define MAKE_ENUM(name) name,
COLOR_LIST(MAKE_ENUM)
#undef MAKE_ENUM
};

const char* ColorNames[] = {
#define MAKE_STR(name) #name,
COLOR_LIST(MAKE_STR)
#undef MAKE_STR
};

Этот шаблонный код сначала сгенерирует enum, затем массив строк тех же идентификаторов. Такой приём называется X-macro. Он избавляет от необходимости дублировать список вручную дважды – список единственный (COLOR_LIST) используется для обоих целей. Минус – сложность синтаксиса и потеря привычной чистоты enum. Тем не менее, иногда в больших проектах можно встретить подобный подход для генерации однотипных функций для enum.

Строка -> enum

Обратная задача – получить enum по его имени – тоже решается явно. Например, можно написать функцию парсинга:

#include <stdexcept>
Color colorFromString(const std::string& str) {
if (str == "Red") return Color::Red;
if (str == "Green") return Color::Green;
if (str == "Blue") return Color::Blue;
throw std::invalid_argument("Unknown color: " + str);
}

Здесь мы просто сравниваем входную строку с известными именами. Эта функция вернёт соответствующий Color или выбросит исключение, если строка неверная.

При большом количестве элементов удобнее использовать контейнер, например std::unordered_map<std::string, Color>:

static const std::unordered_map<std::string, Color> colorMap = {
{"Red", Color::Red},
{"Green", Color::Green},
{"Blue", Color::Blue}
};
Color colorFromStringMap(const std::string& str) {
auto it = colorMap.find(str);
if (it != colorMap.end()) return it->second;
throw std::invalid_argument("Unknown color: " + str);
}

С точки зрения сложности O(N) vs O(1) это может быть выгодно, если разбор производится часто, хотя для 3-10 элементов разница незначительна. В любом случае, стандартного механизма по имени взять значение нет – нужно описывать соответствие вручную.

Замечание: В C++20 добавлен класс std::format для форматирования, но он не знает ничего об именах enum – при попытке отформатировать Color::Red он отформатирует его числовое значение. То же касается operator<< для iostream – если не перегрузить его для вашего enum, поток просто увидит его как int. Поэтому для человекочитаемого вывода необходимо определять либо функцию, либо перегружать operator<< сами:

std::ostream& operator<<(std::ostream& os, Color c) {
return os << toString(c);
}

После этого std::cout << c выведет, например, "Red".

9. Получение количества элементов enum

Нередко полезно знать, сколько элементов определено в перечислении. Например, чтобы пройтись циклом по всем значениям или размер массива строк задать. Поскольку прямой рефлексии в C++ нет, нельзя спросить у типа enum о количестве элементов. Однако существует идиома, которую используют во многих проектах: добавить фиктивный последний элемент, который равен числу реальных элементов. Часто его называют Count или Last:

enum class Color {
Red,
Green,
Blue,
Count // всегда последний
};

В этом enum Color::Count автоматически получит значение 3 (если Red=0, Green=1, Blue=2). Тем самым static_cast<int>(Color::Count) равно количеству определённых цветов[18]. Теперь можно, например, создать массив имён нужного размера:

static const char* ColorNames[static_cast<int>(Color::Count)] = { "Red", "Green", "Blue" };

или итерироваться:

for (int i = 0; i < static_cast<int>(Color::Count); ++i) {
auto color = static_cast<Color>(i);
// ... использовать color ...
}

Важно, чтобы новые элементы всегда добавлялись перед Count. Тогда код, использующий Count, не нуждается в изменении при расширении enum – он автоматически подхватит новое количество.

Эта техника работает и для C-стилей enum, и для enum class. Часто константу называют с префиксом, например, kCount (как const), либо Color_Count. Но в случае enum class можно просто Count, так как оно не конфликтует вне типа.

Обратим внимание: значение Count не предназначено для использования как обычное состояние. Его смысл – "количество элементов" и обычно в программу не должно попадать как реальное значение параметра. Т.е. Color::Count – скорее служебная константа. Иногда вместо Count делают Last = последний реальный элемент, и тогда количество = Last+1. Оба подхода эквивалентны по сути.

В стандарте C++ пока нет встроенного средства получить число элементов. Возможное будущее расширение – рефлексия – могло бы решить эту проблему (см. следующий раздел), но по состоянию на C++20 приходится использовать идиомы вроде описанной. Многие статические анализаторы и linters распознают и одобряют паттерн с Count

10. Размер enum и управление памятью

Под размером enum подразумевается sizeof(T) для данного типа перечисления. Как отмечалось ранее, размер равен размеру подлежащего целочисленного типа. Без фиксированного underlying type компилятор может выбрать разный размер в зависимости от значений[5]. Обычно минимальный размер – 4 байта (int) на большинстве платформ для небольших перечислений, но если значения очень большие – может стать 8 байт. Кроме того, в спецификациях ABI разных систем могут быть правила, влияющие на выбор (например, выравнивание).

С введением явного задания underlying type (C++11) разработчик получил контроль над размером. Если важна экономия памяти, можно смело использовать небольшие типы для enum. Пример:

enum class Suit : uint8_t { Clubs, Diamonds, Hearts, Spades };
static_assert(sizeof(Suit) == 1, "Suit should be 1 byte");

Здесь мы уверены, что Suit занимает 1 байт. Если бы мы не указали : uint8_t, возможно, компилятор всё равно выбрал бы int (4 байта), т.к. все 4 значения легко помещаются в int. Указание uint8_t гарантирует минимальный размер.

Иногда прибегают к хитрости: в старом коде можно увидеть в enum фиктивный элемент для указания размера. Например, приведённый ранее код:

enum MY_FAVOURITE_FRUITS {
E_APPLE = 0x01, E_WATERMELON = 0x02, /*...*/ E_MANGO = 0x80,
E_MY_FAVOURITE_FRUITS_FORCE8 = 0xFF // попытка "заставить" компилятор взять 8-битный тип
};

Здесь разработчик явно установил последний элемент = 0xFF (255), надеясь, что компилятор выберет под enum 1 байт. Однако это не гарантировано – компилятор волен взять и int, главное чтобы он вместил 0xFF. В C++98 нельзя было напрямую повлиять, но с C++11 правильный способ – просто указать : unsigned char при объявлении enum. Тогда размер гарантированно будет 1 байт.

Стоит также понимать, что размер enum влияет на выравнивание и компоновку структур. Enum – это по сути интегральный тип, поэтому его alignment равен alignment соответствующего целого. Например, enum class на 1 байт обычно выравнивается как uint8_t (то есть может плотно упаковываться), а enum на 4 байта – как int (обычно 4-байтовое выравнивание).

С точки зрения памяти, перечисление не отличается от обычного целого: оно хранится как значение своего подлежащего типа. Поэтому управление памятью enum сводится к выбору подходящего размера типа. В редких случаях может иметь смысл использовать атрибуты выравнивания или pack для структур, содержащих enum, но это скорее общие вопросы оптимизации.

Ещё один аспект – совместимость с Си. В языке C подлежащий тип тоже определяется реализацией, но при передаче enum через vararg или несоответствие деклараций может быть неожиданность. Лучше явно приводить enum к ожидаемому типу при таких операциях. При использовании extern "C" интерфейсов, если нужно строго 32-битный enum, можно в C++ объявить его : int для уверенности.

В рамках C++17 появился тип std::byte – как было отмечено, это фактически enum class byte : unsigned char {}. Он предназначен для удобного представления сырых байтов. Его размер 1 и он не имеет перечисленных значений, но сам факт использования enum class демонстрирует доверие стандарта к тому, что enum может использоваться как просто тип-хранилище нужного размера (здесь – 1 байт) без каких-либо накладных расходов.

Подытоживая: контролируйте размер enum, когда это важно, используя возможность указания underlying type. Проверяйте sizeof в критичных структурах (через static_assert), чтобы убедиться в ожидаемых размерах и выравнивании.

11. Выбор enum по строке (рефлексия-подобные подходы)

Как мы обсуждали в разделе 8, превращение строки в значение enum требует ручного кода. Хотелось бы иметь механизм автоматического сопоставления, как есть в некоторых языках (например, Enum.valueOf("Green") в Java). Такая возможность относится к области рефлексии – способности программы узнавать свою структуру (имена типов, поля, методы) во время выполнения. Классический C++ рефлексии не содержит: ни классы, ни перечисления не могут отдать список своих элементов или что-либо подобное на рантайме.

Тем не менее, существуют приближённые способы решить задачу выбора enum по строке:

  • Ручное отображение. Самый надёжный и явный метод – использовать if/else или switch, как показано ранее, или отображение через std::map. Этот подход требователен к поддержке: при добавлении нового значения разработчик должен не забыть обновить функцию. Иначе входная строка никогда не будет распознана как новый значение. Для критичных случаев можно добавить в функцию default-ветку в switch и выводить ошибку, чтобы сразу обнаружить несоответствие (например, assert или исключение, если строка не найдена). Тогда забытый элемент даст о себе знать в процессе тестирования.
  • Макросы / метапрограммирование. Чтобы снизить дублирование кода, применяют X-макросы, как в примере с Color в прошлом разделе, или шаблонные трюки. Идея: хранить список имен и значений в одном месте, откуда сгенерировать и enum, и функции toString/fromString. Пример с COLOR_LIST макросом иллюстрирует этот подход. Другой вариант – использовать constexpr массив структур {Name, Value} и при запуске выполнять поиск по нему. Например:
struct ColorInfo { const char* name; Color value; };
constexpr ColorInfo colorInfos[] = {
{"Red", Color::Red}, {"Green", Color::Green}, {"Blue", Color::Blue}
};
constexpr size_t colorCount = sizeof(colorInfos)/sizeof(colorInfos[0]);

Color colorFromStringConst(const std::string& s) {
for (size_t i = 0; i < colorCount; ++i) {
if (s == colorInfos[i].name)
return colorInfos[i].value;
}
throw std::invalid_argument("Unknown Color");
}

Благодаря constexpr массив colorInfos может быть развернут компилятором, а цикл может быть оптимизирован. По сути, такой код – аналог написания нескольких if, но в более компактной форме.

  • Библиотеки static reflection. Сообщество разработчиков создало утилиты, которые, используя возможности современных стандартов (C++17 и выше), реализуют почти рефлексию для enum. Один из популярных примеров – библиотека magic_enum. Она способна в compile-time сгенерировать набор строковых имен для enum и предоставить функции enum_name(value) и enum_cast<Name>(). Внутри используются трюки с внушительным шаблонным кодом, но для пользователя всё выглядит магически просто:
#include <magic_enum.hpp>
Color c = Color::Green;
std::string name = magic_enum::enum_name(c); // name = "Green"
auto optColor = magic_enum::enum_cast<Color>("Red"); // optColor = Color::Red внутри std::optional

У magic_enum есть ограничения (например, enum должен быть без вручную заданных нестандартных значений, диапазон перебирается от минимального до максимального с шагом 1; работает только для enum, где не определены повторяющиеся значения и т.п.). Но в целом эта библиотека предоставляет желаемую функциональность без ручного труда, правда, не входя в стандарт.

Пока в стандарте C++ нет встроенной рефлексии, каждый из вышеперечисленных методов – это компромисс между удобством и сложностью поддержки. В C++26 ожидается появление статической рефлексии, которая, возможно, позволит на уровне языка получать список элементов enum и их названия. Это упростит написание таких функций и сделает сторонние библиотеки не нужными. Но до тех пор, программисты вынуждены либо писать конвертеры вручную, либо использовать внешние инструменты (макросы, генераторы кода, библиотеки).

В заключение: для выбора enum по строке сейчас лучше явно поддерживать соответствие имен и значений. Хотя это кажется рутинным, такой код прозрачен и надёжен, а при хорошей организации (например, вынос таблицы соответствий отдельно) – и не слишком громоздок.

12. Советы по стилю и лучшие практики

Назначение перечислений. Используйте enum, когда требуется ограничить набор возможных значений некоторой сущности и дать им человекочитаемые имена. Перечисления хорошо подходят для описания состояний, опций, категорий и т.д. Например, состояния конечного автомата, типы сообщений, режимы доступа.

Выбор типа перечисления (enum class vs enum). В современном C++ рекомендуется предпочитать enum class (ограниченные перечисления) для новых перечислений. Они обеспечивают лучшую проверку типов и изоляцию имён. Обычные (неструктурированные) enum можно использовать, если действительно нужны их особенности: например, для совместимости с Си (при взаимодействии с С API), либо для применения идиом, где требуется неявное преобразование в int (редко). Одним из таких случаев может быть использование enum значений в #if препроцессора или где enum Foo { A=1, B=2 }; удобнее как набор констант. Но даже в этих случаях часто есть альтернатива (константы constexpr или enum class + каст).

Именование. Общепринятых жёстких правил нет, но вот несколько распространённых стилей: - Для неструктурированных enum, чьи элементы попадают в глобальную область видимости, часто выбирают либо UPPER_SNAKE_CASE (все буквы заглавные с подчёркиваниями) для подчёркивания статуса константы, либо добавляют префикс, связанный с именем enum. Например:

enum Color { COLOR_RED, COLOR_GREEN, COLOR_BLUE };
enum Direction { DirUp, DirDown, DirLeft, DirRight };

Префиксы (COLOR_, Dir) помогают избежать конфликтов. Но они делают имена длиннее и дублируют информацию. - Для enum class префиксы обычно не нужны, так как имена изолированы. Поэтому часто используют PascalCase или CamelCase без префикса: enum class Color { Red, Green, Blue };. Некоторые стили требуют отличать enum от других идентификаторов – тогда применяют, например, суффикс _e для типа (например, Color_e) или суффикс для значений (редко). Однако в большинстве случаев достаточно просто соблюдать ясность. - Иногда в C++-коде встречается префикс k (от «константа») у имен перечислителей: enum class Error { kSuccess, kFail, kUnknown };. Это скорее дань традиции обозначать константы (например, Google Style Guide рекомендует kConstantName для constexpr). В enum class это избыточно, но не запрещено. В примерах ранее мы видели kRed, kGreen, kBlue, kCount[24] – такой стиль тоже возможен.

Подлежащий тип. Явно задавайте underlying type, если: - Нужно контролировать размер (экономия памяти, выравнивание и т.д.). - Планируется forward-declare (см. раздел 4). - Значения не помещаются в int – тогда это обязательно. - Просто для ясности (например, enum class Flags : uint8_t сразу даёт понять, что это битовые флаги, и их максимум 8 бит).

Если ни одна из причин не актуальна, можно не задавать тип – по умолчанию всё равно будет int, что обычно приемлемо.

Инициализация значений. Если логика не требует конкретных численных кодов, разрешите компилятору нумеровать автоматически. Так меньше шансов ошибиться. То есть не пишите Red=0, Green=1, Blue=2 – это всё сделается само. Явно указывайте значения только когда: - Нужно нестандартное нумерование (например, биты, или конкретные коды ошибок, заданные спецификацией). - Нужно изменить стартовое значение (например, enum Index { One=1, Two, Three };). - Нужно задать разреженные значения.

Комбинации и побитовые операции. Если enum используется для флагов, рассмотрите добавление None = 0 и перегрузку операторов |, &, ^, ~ для enum class. Либо используйте для хранения флагов стандартные типы (std::bitset, std::uintXX_t) – это тоже неплохой вариант. Но если хочется именно enum, то описанный шаблон сделает использование значительно удобнее.

Switch и enum. При использовании switch с enum всегда добавляйте default (или, лучше, case для всех значений, включая, возможно, Count/Unknown). Компилятор не будет ругаться, если вы не обработали какой-то элемент enum – он просто пойдёт в default. Поэтому, чтобы не пропустить новые значения, можно в default делать assert(false) или хотя бы комментарий // unreachable. В C++17 появился атрибут [[nodiscard]] и [[maybe_unused]], но для enum-case специфического ничего нет. В C++20 появился std::is_constant_evaluated и концепты, но опять же, исчерпываемость enum не контролируется на уровне языка.

Сериализация. Если планируется сохранять значения enum (в файл, в сеть) или передавать между модулями, убедитесь, что подлежащий тип и конкретные значения зафиксированы. Изменение порядка или значений enum может сломать совместимость. В таких случаях, как правило, явно задают численные коды каждому элементу (особенно если enum расширяется со временем, старые значения должны сохраниться). Например, протокол может требовать, что ErrorCode::Overflow = 5 и это значение не изменится между версиями.

Обновление enum. Если вы используете Count-элемент, не забудьте обновлять его при добавлении новых элементов. Иначе он перестанет корректно отражать размер. Хотя, как было отмечено, обычно стратегия – добавлять новые элементы перед Count, тогда сам Count всегда актуален. Аналогично, если у вас есть ручной Last или подобное.

enum и классы/пространства имён. Хорошей практикой является вкладывать enum в класс или namespace, если они логически относятся к нему. Например, если есть класс AudioPlayer, и у него есть набор состояний, удобно объявить enum class AudioPlayer::State { Stopped, Playing, Paused }; внутри класса или в namespace AudioPlayerStates. Это группирует связанные понятия и не размывает их в общем пространстве.

Не используйте using namespace для enum class. С C++20 появилась конструкция using enum (рассмотрена в следующем разделе), которая локально подставляет перечислители в область видимости. Иногда программисты, устав писать Type::Value, делают using Type::Value; или даже using namespace EnumNamespace;. Это может вернуть проблему конфликтов имен. Лучше либо использовать using enum если доступно, либо терпеливо квалифицировать имена – это улучшает читаемость кода.

Осторожно с флагами всех бит. Если вы определяете флаг типа "All" или используете ~0 для установки всех бит (как в примере q = ~Permissions::Read), помните, что в enum class доступно только то количество бит, которое в underlying type. Например, ~Permissions::Read в примере даст 0xFE (биты 1 и 2 установлены, 0 сброшен) в пределах uint8_t. Это не "бесконечные" биты, а строго 8 бит. Иногда определяют константу All = Read|Write|Execute явно, чтобы не зависеть от ~.

Следуя этим рекомендациям, вы сможете эффективно использовать перечисления, избегая распространённых ловушек и сохраняя код ясным.

13. Новое в C++20 и изменения в поведении

Стандарт C++20 не принёс кардинальных изменений в механизм перечислений, но добавил несколько полезных возможностей и синтаксических улучшений.

using enum – упрощённый доступ к перечислителям. Главное нововведение C++20 в контексте enum – это конструкция using enum. Она позволяет импортировать все элементы указанного enum в текущую область видимости, не теряя при этом преимуществ scoped enum. Проще говоря, внутри определённого блока кода можно писать перечислители без префикса. Например:

enum class ComputeStatus { Ok, Error, DiskFull, Timeout };

int main() {
using enum ComputeStatus; // после этой строки перечислители ComputeStatus видны напрямую

ComputeStatus status = Ok;
switch (status) {
case Ok: std::cout << "OK\n"; break;
case Error: std::cout << "Error\n"; break;
case DiskFull: std::cout << "Disk full\n"; break;
case Timeout: std::cout << "Timeout\n"; break;
}
}

Без using enum ComputeStatus нам пришлось бы каждый case писать как ComputeStatus::Ok и т.д. Используя новую конструкцию, мы существенно сократили шаблонный код в switch. Это особенно полезно, когда в одном блоке используется один конкретный enum множество раз – код становится менее загромождённым именами типа.

Также using enum может применяться при объявлении членов класса или namespace, чтобы «вытянуть» перечислители в этот скоуп. Например:

struct Server {
enum class State { Starting, Running, Stopped };
using enum State; // делает Server::Starting эквивалентом Server::State::Starting внутри Server
};
Server::State s = Server::Starting; // благодаря using enum, Starting доступен как член Server

Важно, что using enum работает только для одного перечисления за раз. Если импортированные имена конфликтуют с чем-то, будет ошибка компиляции. Поэтому нельзя в одном блоке сделать using enum для двух enum с совпадающими значениями – получим конфликт.

using enum не нарушает типовую безопасность – в примере выше, хотя мы пишем status = Ok;, тип status известен как ComputeStatus, и никакое другое Ok из другого enum туда не попадёт. Это лишь синтаксический сахар.

Совместимость с enum class. Конструкция using enum разрабатывалась именно для работы с scoped enum, чтобы компенсировать их многословность. Для обычных enum она тоже применима, но особого смысла там нет (неструктурированные enum и так видно без квалификатора). Кстати, using enum позволяет даже несколько укорить старый enum, если он вложен в класс – обычно для неструктурированного вложенного enum надо писать ClassName::Enumerator, а с using enum внутри класса можно избежать этого.

Другие изменения C++20, влияющие на enum: - Концепты и ограничения шаблонов. В C++20 вы можете писать шаблон, принимающий только enum. Например, template<std::enum E> void foo(E e); – концепт std::enum проверяет, что параметр – перечислимый тип. Также есть концепт std::integral (который включает enum, т.к. они относятся к целочисленным типам по концептам). Это может упростить перегрузки и сделать код шаблонов, работающих с enum, выразительнее. - Рефлексия (отсутствует). Несмотря на ожидания, в финальный стандарт C++20 возможности рефлексии не вошли. Были эксперименты с __reflect в некоторых компиляторах, но ничего официального. Поэтому, как мы обсуждали, для получения имён или количества элементов приходится использовать прежние приёмы. - constexpr и consteval. Enum сами по себе являются литеральными типами, поэтому их можно использовать в constexpr контекстах как и раньше. C++20 расширил возможности constexpr (вплоть до constexpr-выполнения динамического кода), но это не специфично для enum. Тем не менее, можно, к примеру, сделать constexpr-функцию, возвращающую массив всех значений enum (с помощью трюка с fold-expression по ...). Это скорее относится к метапрограммированию, но C++20 позволяет такие штуки провернуть (в сочетании с C++11 constexpr и variadic templates).

  • Дополнение по [[deprecated]] и enum. С C++14 можно помечать перечислители атрибутом [[deprecated]]. В C++20 это не новшество, но напомним, что если какое-то значение enum устарело, можно так пометить, и компилятор выдаст предупреждение при использовании. Пример: enum class Protocol { Old [[deprecated]], New };. Это помогает миграции, но не влияет на сам механизм.

Изменения в поведении существующих возможностей: C++20 в основном сохранил поведение enum от C++11/C++14/C++17. Разве что сам using enum может рассматриваться как изменение в области видимости (внутри блока), но на сам тип не влияет.

Стоит отметить, что в C++17 произошло небольшое изменение, которое мы ранее описали: разрешили list-инициализацию enum из числа. В C++20 это осталось, ничего не убирали. Ещё, начиная с C++20, использование неScoped enum без указания underlying type в шаблонах SFINAE-контекстах могло вести к UB раньше, но были уточнения стандарту (это довольно внутренние детали, касающиеся std::underlying_type и enable_if). В целом, C++20 не вводил проблемных изменений для старого кода с enum.

Взгляд в будущее: Уже в C++23 добавлены утилиты для enum: упомянутая std::to_underlying, а также std::is_scoped_enum (тип-тrait для проверки, является ли enum scoped)[32]. Возможно, статическая рефлексия в C++26 даст новые стандартизованные способы перебирать перечисления и получать их имена. Таким образом, язык постепенно развивается в сторону облегчения работы с перечислениями, но шаг за шагом.

На C++20 же суммируем: самое заметное улучшение – using enum, позволяющее писать код чуть короче и яснее, особенно при частом использовании enum class в пределах одной функции или класса. Все остальные конструкции остались прежними, сохраняя полную совместимость с кодом, написанным на C++11/C++14/C++17. Enum продолжают играть важную роль как удобный инструмент для создания именованных констант и ограниченных типов в языке C++, и понимание их возможностей во всех стандартах поможет эффективно использовать их в ваших программах.

Ссылки

Грокаем C++
GitHub - Neargye/magic_enum: Static reflection for enums (to string, from string, iteration) for modern C++, work with any enum type without any macro or boilerplate code