Константы — это здорово. Типы — это замечательно. Константы определенного типа — еще лучше. А 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<char>'} 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 появился заголовок <type_traits>. Он включает утилиты для проверки свойств типов. Неудивительно, что is_enum проверяет, является ли тип перечислением или нет. Он возвращает true как для scoped, так и для unscoped версий.

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

#include <iostream>
#include <type_traits>

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

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

std::underlying_type

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

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

#include <iostream>
#include <type_traits>

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<UnscopedColor>::type, int >;
  constexpr bool isScopedColorInt = std::is_same_v< std::underlying_type_t<ScopedColor>, int >;
  constexpr bool isCharBasedColorInt = std::is_same_v< std::underlying_type_t<CharBasedColor>, int >;
  constexpr bool isCharBasedColorChar = std::is_same_v< std::underlying_type_t<CharBasedColor>, 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 <type_traits>

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 <type_traits>

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

int main() {
  using enum ScopedColor;
}

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

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

#include <iostream>
#include <type_traits>
 
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<UnscopedColor>::value << '\n';
    std::cout << std::is_scoped_enum_v<ScopedColor> << '\n';
    std::cout << std::is_scoped_enum_v<S> << '\n';
    std::cout << std::is_scoped_enum_v<int> << '\n';
}
/*
false
true
false
false
*/

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

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

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

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

#include <iostream>
#include <type_traits>
#include <utility>
 
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<std::underlying_type_t<ScopedColor>>(sc);
    [[maybe_unused]] std::underlying_type_t<ScopedColor> underlyingDeduced = std::to_underlying(sc);
}

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

Заключение

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

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

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

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

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

Комментарии (28)


  1. boulder
    19.12.2023 14:41

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


    1. nronnie
      19.12.2023 14:41

      В C# давно уже сделали возможность при вместе со значением указывать имя параметра. В общем-то это задумывали для случая наличия нескольких параметров со значением по умолчанию, но и в ситуациях без них это часто удобно для улучшения читаемости:

      List<int> foo = new(capacity: 42);
      


      1. vitiok78
        19.12.2023 14:41

        Даже в PHP уже сделали именованные параметры


    1. Gummilion
      19.12.2023 14:41

      Вообще какое-то частичное решение, ну хорошо, булевые значения заменили на что-то осмысленное, а как насчет других типов? Вот есть, к примеру, функция площади треугольника с 3 параметрами - как понять чисто из вызова, это три стороны, две стороны и угол или что-то еще? Что же, делать для каждого параметра обертку над float?


      1. nronnie
        19.12.2023 14:41

        делать для каждого параметра обертку над float

        Иногда тоже может иметь смысл. Например, чтобы случайно (допустим, из-за обычной опечатке в коде не начать складывать или сравнивать периметр с площадью или фамилию с номером телефона. Главное, думаю, без фанатизма к этому подходить.


  1. HardWrMan
    19.12.2023 14:41

    Смысл перечисления в его контроле, чтобы компилятор не позволял ошибиться программисту. Например, если я укажу переменную типа перечисление на Pascal/Delphi, то компилятор мне не даст в неё загрузить ничего, кроме описанного в типе перечисления. В С я могу загружать как объявленное перечисление так и просто число. Потому что в С перечисление это просто контроль уникальности константы как числа в пределах одного перечисления, а не защита от ошибки в коде. А ведь перечисления очень удобно использовать в стэйт-машинах прямо в switch/case.


    1. vZakhar
      19.12.2023 14:41

      Когда компилятор не защищает, есть линтер) В этом отношении я на стороне автора статьи из 2022 - enum вместо констант


      1. HardWrMan
        19.12.2023 14:41

        Запускаешь компилирование - код собирается но не работает. Запускаешь линтер - он тебя тыкает носом на несоответствие. Ты исправляешь, запускаешь ещё раз компилирование - код собирается и работает. Это так мило, всю жизнь мечтал запускать программы.


        1. nronnie
          19.12.2023 14:41

          Проверку линтером можно ведь встроить в процесс сборки, чтобы "нехороший" код вообще не собирался. Да и зачем каждый раз руками "запускать". Для этого есть автоматизированные (unit и интеграционные) тесты, которые тоже в процесс сборки и CI встраиваются.


          1. HardWrMan
            19.12.2023 14:41

            Я понимаю, что всё это поддаётся автоматизации. Но в чём проблема контроль конкретно перечисления встроить в сам компилятор, как это сделано в других языках?


            1. nronnie
              19.12.2023 14:41

              Это было бы, конечно, идеально. Но, я, вот, на C# пишу, и там тоже "защита от enum" самая минимальная - нет implicit cast. Но с explicit cast ты все равно можешь в enum засунуть любое самое бредовое значение - поэтому постоянно приходится проверять что же тебе под личиной этого enum на самом деле прилетело. Но у меня к enum-ам больше другие претензии. Если я вижу в коде enum с полутора десятками значений, то я сразу напрягаюсь, потому что уже знаю, что где-то в другом месте (причем еще и не в единственном) в коде будет какой-нибудь жуткий switch с ветками на все эти полтора десятка значений и на несколько экранов.


              1. HardWrMan
                19.12.2023 14:41

                Ну, всё хорошо в меру. Но перегибы случаются, ага.


            1. KanuTaH
              19.12.2023 14:41

              Ну так enum class как раз и придуман ради того, чтобы в переменную этого типа нельзя было "загружать просто число". Но это уже C++ а не C.


        1. 0x6b73ca
          19.12.2023 14:41

          Так линтер ещё в момент написания кода красненьким подчеркивает что здесь что то не то


  1. Gummilion
    19.12.2023 14:41

    Непонятно, почему решили добавить ключевое слово class, если оно вовсе не класс и ничего, кроме перечисления значений в него по-прежнему нельзя добавить?


    1. zlat_zlat
      19.12.2023 14:41

      Думаю, смысл был в ограничении видимости (приватности) членов этого класса.


      1. Gummilion
        19.12.2023 14:41

        Нет, штука конечно нужная, но почему класс-то? Если так не хотелось вводить новые ключевые слова - ну назвали бы enum namespace (тем более что using для них есть).


        1. KanuTaH
          19.12.2023 14:41

          Из Страуструповского C++11 FAQ:

          The new enums are "enum class" because they combine aspects of traditional enumerations (names values) with aspects of classes (scoped members and absence of conversions).


        1. nronnie
          19.12.2023 14:41

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


          1. Gummilion
            19.12.2023 14:41

            Сделали бы тогда уж возможность добавлять в enum методы - к примеру, для Color могли бы быть string name() и uint32_t toRgba(), а то какой-то странный класс, в котором не может быть ничего, кроме констант


            1. nronnie
              19.12.2023 14:41

              Кстати, да - один из резонов по которым может иметь смысл вообще использовать вместо какого-либо enum как раз класс с фиксированным набором инстансов. Это, например, часто используется в одном из приемов рефакторинга - "Замена условного оператора полиморфизмом".


    1. XViivi
      19.12.2023 14:41

      Предполагаю, это потому что возможно изначально это задумывали как сокращение для чего-то такого:

      struct EnumName
        {
            enum { a, b, c };
        };

      А потом оно обросло... гипотеза, если что, но правдоподобная.


  1. NeoCode
    19.12.2023 14:41

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

    enum class T {
        T1 = 0x1,
        T2 = 0x2
    };
    
    int main()
    {
        T   i = T::T1; // OK
        int j = T::T2; // ERROR
        int k = T::T1 | T::T2; // ERROR
        return 0;
    }

    Также не понимаю запрет неявного преобразования между int и bool (сейчас во всех современных языках это ввели). С ошибками из-за таких преобразований вообще ни разу не сталкивался.


    1. KanuTaH
      19.12.2023 14:41

      В частности, это делает невозможным использование элементов перечисления как битовых масок

      Напрямую - нет, но написать оператор никто не мешает:

      T operator|(T lhs, T rhs)
      {
        using UT = std::underlying_type_t<T>;
      
        return static_cast<T>(static_cast<UT>(lhs) | static_cast<UT>(rhs));
      }
      
      int main()
      {
        T i = T::T1; // OK
        T k = T::T1 | T::T2; // OK
        return 0;
      }

      Плюс в том, что есть гарантия, что вы случайно не заOR'ите яблоки с апельсинами.

      Также не понимаю запрет неявного преобразования между int и bool

      Вы о каком конкретно запрете?

      bool x = 100500; // OK
      int y = x + true; // OK
      bool z = y - y; // OK
      
      if (x > y) { // OK
        [...]
      }
      


      1. NeoCode
        19.12.2023 14:41

        Запрет не в C++, а в более новых языках (в частности я столкнулся с этим в Go - пришлось писать банальную функцию конвертирования bool в int... из той же серии что и предложенный вами оператор, который по сути ничего не делает, но нужен для того чтобы код компилировался). В плюсах, когда вводили bool, это еще никому в голову не пришло:)

        А чтобы не заOR'ите яблоки с апельсинами, можно запретить операции между разными enum'ами, но разрешить в некоторых случаях между enum и int.


        1. KanuTaH
          19.12.2023 14:41

          А чтобы не заOR'ите яблоки с апельсинами, можно запретить операции между разными enum'ами, но разрешить в некоторых случаях между enum и int.

          Хм, хм, enum class как бы либо приводится к int неявно, либо нет. В первом случае будет выполняться integral promotion, который выполняется в любом случае, ибо арифметические операции в C++ не работают с аргументами, меньшими чем int, а во втором случае будет как сейчас. Сейчас правила "автоматических" арифметических конверсий в C++ и так довольно сложноваты, вы хотите сделать их еще сложнее? Это раз, а во-вторых, это никак не защитит от яблок и апельсинов, просто не в одной и той же строке, а в двух разных. А вариант с явным определением оператора защитит.


  1. arTk_ev
    19.12.2023 14:41

    Как же с++ устарел и уродлив.


    1. KanuTaH
      19.12.2023 14:41

      Судя по вашим комментариям в других статьях, вы изобрели очередной язычок, который собираетесь рекламировать как серебряную пулю от всех проблем на свете? :) Не вы первый, не вы последний.