[Перевод] Эволюция enum

2ff607862904015676bb7b5b2941c8fb.png

Константы — это здорово. Типы — это замечательно. Константы определенного типа — еще лучше. А enum классы — это просто фантастика.

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

Unscoped перечисления

Перечисления являются частью оригинального языка C++, фактически, они были заимствованы из C. Перечисления — это отдельные типы с ограниченным диапазоном значений. Диапазон значений ограничивается некоторыми явно прописанными константами. Ниже приведен пример классического перечисления:

enum Color { red, green, blue };

Посмотрев на этот небольшой пример, можно обратить внимание на две вещи:

  • Само перечисление (enum) — это существительное в единственном числе, даже несмотря на то, что обычно оно перечисляет несколько значений. Мы используем такое соглашение, потому что понимаем, что оно всегда будет использоваться с одним значением. Если вы берете параметр функции Color, то будет взят один цвет. Когда вы сравниваете со значением, вы будете сравнивать с одним значением. Например, логичнее сравнивать с Color::red, чем с Colors::red.

  • Значения перечислителей прописываются в нижнем регистре, а не капсом (ALL_CAPS)! Хотя есть большая вероятность, что вы привыкли писать их именно капсом. Я тоже так делал. Почему же я в итоге изменил своей традиции? Потому что для написания этой статьи я проверил core guidelines, и в Enum.5 четко сказано, что мы не должны использовать капс, чтобы избежать конфликтов с макросами. Кстати, в Enum.1 также сказано, что мы должны использовать перечисления, а не макросы.

Начиная с C++11, количество возможностей для объявления enum увеличилось. В C++11 появилась возможность указывать базовый тип (underlying type) перечисления. Если он не определен, то базовый тип определяется реализацией, но в любом случае это будет целочисленный тип.

Как его определить? С точки зрения синтаксиса это будет выглядеть немного похожим на наследование! Хотя здесь не нужно определять уровни доступа:

enum Color : int { red, green, blue };

Благодаря этому вы можете быть уверены в том, какой тип будет базовым. Тем не менее, это должен быть целочисленный тип! Например, это не может быть строка. Если вы попробуете указать ее в качестве базового типа, то получите явное сообщение об ошибке:

main.cpp:4:19: error: underlying type 'std::string' {aka 'std::__cxx11::basic_string'} of 'Color' must be an integral type
    4 | enum Color : std::string { red, green, blue };
      |                   ^~~~~~

Обратите внимание, что core guidelines не рекомендуют использовать эту практику! Указывать базовый тип следует только в том случае, если это необходимо.

Когда это может быть необходимо? Можно выделить две распространенные ситуации:

  • Если вы знаете, что количество вариантов будет очень ограничено, и хотите сэкономить немного памяти:

enum Direction : char { north, south, east, west,
   northeast, northwest, southeast, southwest }; 
  • Или, если вы делаете предварительное объявление (forward declare) enum, вы также должны объявить и тип:

enum Direction : char;
void navigate(Direction d);

enum Direction : char { north, south, east, west,
     northeast, northwest, southeast, southwest };

Вы также можете указать точное значение одного или всех перечисляемых значений, если они являются constexpr.

enum Color : int { red = 0, green = 1, blue = 2 };

И снова core guidelines рекомендует не делать этого, если на это нет особой необходимости! Как только вы начнете это делать, можно легко ошибиться и все испортить. В любом случае, мы можем рассчитывать на то, что компилятор присвоит последующие значения последующим значениям перечислителя.

  • Хорошим юзкейсом для указания значения перечислителя является определение только начального значения. Например, если вы определяете месяцы и не хотите начинать с нуля.

enum Month { jan = 1, feb, mar, apr, may, jun,
                 jul, august, sep, oct, nov, dec };
  • Другая причина может заключаться в том, что вы хотите определить значения в виде некоторого значимого символа

enum altitude: char {
  high = 'h',
  low = 'l'
}; 
  • Еще одной причиной может быть эмуляция некоторых битовых полей. То есть вам не нужны последующие значения, вместо этого вы всегда хотите получать следующую степень двойки

enum access_type { read = 1, write = 2, exec = 4 };

Scoped перечисления

В предыдущем разделе мы видели объявления типа enum EnumName{};. В C++11 появился новый тип перечислений, называемый scoped (с ограничением на область видимости) enum. Они объявляются либо с помощью ключевых слов class, либо struct (между ними нет никакой разницы).

Их синтаксис следующий:

enum class Color { red, green, blue };

Для scoped перечислений базовый тип по умолчанию определен в стандарте, и это int. Это также означает, что если вы хотите предварительно объявить перечисление, вам не нужно указывать базовый тип. Если он должен быть int, то этого будет достаточно:

enum class Color;

Но какие еще существуют различия, помимо синтаксических различий между способами их объявления?

Unscoped перечисления могут быть неявно преобразованы к своему базовому типу. Неявные преобразования — это часто не то, что вам нужно, и у scoped перечислений нет такой «особенности». Именно из-за нежелательности неявных преобразований core guidelines настоятельно рекомендует использовать scoped, а не unscoped перечисления.

void Print_color(int color);

enum Web_color { red = 0xFF0000, green = 0x00FF00, blue = 0x0000FF };
enum Product_info { red = 0, purple = 1, blue = 2 };

Web_color webby = Web_color::blue;

// Clearly at least one of these calls is buggy.
Print_color(webby);
Print_color(Product_info::blue);

Unscoped перечисления экспортируют свои перечислители в объемлющую область видимости, что может привести к конфликту имен. С другой стороны, при использовании scoped перечислений вы всегда должны указывать имя перечисления вместе с перечислителями.

enum UnscopedColor { red, green, blue };
enum class ScopedColor { red, green, blue };

int main() {
    [[maybe_unused]] UnscopedColor uc = red;
    // [[maybe_unused]] ScopedColor sc = red; // Doesn't compile
    [[maybe_unused]] ScopedColor sc = ScopedColor::red;
}

Что еще?

Теперь, когда мы разобрались, как работают scoped/unscoped перечисления и чем они отличаются друг от друга, давайте посмотрим, какие еще функции, связанные с перечислениями, предлагает язык или стандартная библиотека.

std: is_enum

В C++11 появился заголовок . Он включает утилиты для проверки свойств типов. Неудивительно, что is_enum проверяет, является ли тип перечислением или нет. Он возвращает true как для scoped, так и для unscoped версий.

Начиная с C++17, также доступен более удобный is_enum_v.

#include 
#include 

enum UnscopedColor { red, green, blue };
enum class ScopedColor { red, green, blue };
struct S{};

int main() {
    std::cout << std::boolalpha
              << std::is_enum::value << '\n'
              << std::is_enum::value << '\n'
              << std::is_enum_v << '\n';
}

std: underlying_type

Также в C++11 был добавлен std::underlying_type. Он помогает нам получить базовый тип перечисления. До C++20, если проверяемое перечисление не полностью определено или не является перечислением, поведение не определено. Начиная с C++, программа становится некорректной для неполных типов перечислений.

В C++14 появился хелпер std::underlying_type_t.

#include 
#include 

enum UnscopedColor { red, green, blue };
enum class ScopedColor { red, green, blue };
enum class CharBasedColor : char { red = 'r', green = 'g', blue = 'b' };

int main() {
 
  constexpr bool isUnscopedColorInt = std::is_same_v< std::underlying_type::type, int >;
  constexpr bool isScopedColorInt = std::is_same_v< std::underlying_type_t, int >;
  constexpr bool isCharBasedColorInt = std::is_same_v< std::underlying_type_t, int >;
  constexpr bool isCharBasedColorChar = std::is_same_v< std::underlying_type_t, char >;

  std::cout
    << "underlying type for 'UnscopedColor' is " << (isUnscopedColorInt ? "int" : "non-int") << '\n'
    << "underlying type for 'ScopedColor' is " << (isScopedColorInt ? "int" : "non-int") << '\n'
    << "underlying type for 'CharBasedColor' is " << (isCharBasedColorInt ? "int" : "non-int") << '\n'
    << "underlying type for 'CharBasedColor' is " << (isCharBasedColorChar ? "char" : "non-char") << '\n'
    ;
}

Объявление перечислений с использованием using в C++20

Начиная с C++20, с перечислениями можно использовать using. Он вводит имена перечислителей в заданную область видимости.

Эта фича достаточно умна, чтобы выдавать ошибку компиляции в случае, если при следующем using будет добавлено имя перечислителя, которое уже было введено в другом перечислении.

#include 

enum class ScopedColor { red, green, blue };
enum class CharBasedColor : char { red = 'r', green = 'g', blue = 'b' };

int main() {
  using enum ScopedColor; // OK!
  using enum CharBasedColor; // error: 'CharBasedColor CharBasedColor::red' conflicts with a previous declaration
}

Стоит отметить, что компилятор не распознает, если в unscoped перечислении уже есть имя перечислителя, которое уже было введено в данное пространство имен. В следующем примере из UnscopedColor уже доступны red, green и blue, однако using ScopedColor с теми же именами перечислителей спокойно принимается принимается.

#include 

enum UnscopedColor { red, green, blue };
enum class ScopedColor { red, green, blue };

int main() {
  using enum ScopedColor;
}

C++23 принесет std: is_scoped_enum

В C++23 в заголовке появится еще одна функция, связанная с перечислениями, — std::is_scoped_enum и ее вспомогательная функция std::is_scoped_enum_v. Как следует из названия и подтверждается приведенным ниже фрагментом, она проверяет, является ли ее аргумент scoped перечислением или нет.

#include 
#include 
 
enum UnscopedColor { red, green, blue };
enum class ScopedColor { red, green, blue };
struct S{};

int main() 
{
    std::cout << std::boolalpha;
    std::cout << std::is_scoped_enum::value << '\n';
    std::cout << std::is_scoped_enum_v << '\n';
    std::cout << std::is_scoped_enum_v << '\n';
    std::cout << std::is_scoped_enum_v << '\n';
}
/*
false
true
false
false
*/

Если вы хотите попробовать фичи C++23, используйте флаг компилятора -std=c++2b.

В C++23 также появится std: to_underlying

В C++23 появится еще одна библиотечная функция для перечислений. Заголовок разживется функцией std::to_underlying. Она преобразует перечисление в его базовый тип. Как уже говорилось, это библиотечная функция, то есть она может быть реализована в более ранних версиях.

Ее можно заменить на static_cast, если у вас есть доступ только к более ранним версиям: static_cast>(e);.

#include 
#include 
#include 
 
enum class ScopedColor { red, green, blue };

int main() 
{
    ScopedColor sc = ScopedColor::red;
    [[maybe_unused]] int underlying = std::to_underlying(sc);
    [[maybe_unused]] int underlyingEmulated = static_cast>(sc);
    [[maybe_unused]] std::underlying_type_t underlyingDeduced = std::to_underlying(sc);
}

Напоминаю, что если вы хотите опробовать фичи C++23, используйте флаг компилятора -std=c++2b.

Заключение

В этой статье мы обсудили все фичи языка и стандартной библиотеки, связанные с перечислениями. Мы увидели, чем отличаются scoped и unscoped перечисления и почему все-таки лучше использовать scoped вариант. И это не единственная рекомендация из Core Guidelines, которую мы обсудили.

Мы также посмотрели, как за эти годы обогатилась стандартная библиотека, упростив работу с перечислениями. А еще мы немного заглянули в будущее и узнали, что принесет нам C++23.

В заключение приглашаем всех начинающих разработчиков на C++ на открытое занятие, посвященное полиморфизму в C++. Чему научимся:

— в каких случаях полезно использовать полиморфизм;
— как работать с виртуальными функциями в C++;
— какая цена использования виртуальных функций.

Урок пройдет вечером 19 декабря, регистрируйтесь по ссылке.

© Habrahabr.ru