Константы — это здорово. Типы — это замечательно. Константы определенного типа — еще лучше. А 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)
HardWrMan
19.12.2023 14:41Смысл перечисления в его контроле, чтобы компилятор не позволял ошибиться программисту. Например, если я укажу переменную типа перечисление на Pascal/Delphi, то компилятор мне не даст в неё загрузить ничего, кроме описанного в типе перечисления. В С я могу загружать как объявленное перечисление так и просто число. Потому что в С перечисление это просто контроль уникальности константы как числа в пределах одного перечисления, а не защита от ошибки в коде. А ведь перечисления очень удобно использовать в стэйт-машинах прямо в switch/case.
vZakhar
19.12.2023 14:41Когда компилятор не защищает, есть линтер) В этом отношении я на стороне автора статьи из 2022 - enum вместо констант
HardWrMan
19.12.2023 14:41Запускаешь компилирование - код собирается но не работает. Запускаешь линтер - он тебя тыкает носом на несоответствие. Ты исправляешь, запускаешь ещё раз компилирование - код собирается и работает. Это так мило, всю жизнь мечтал запускать программы.
nronnie
19.12.2023 14:41Проверку линтером можно ведь встроить в процесс сборки, чтобы "нехороший" код вообще не собирался. Да и зачем каждый раз руками "запускать". Для этого есть автоматизированные (unit и интеграционные) тесты, которые тоже в процесс сборки и CI встраиваются.
HardWrMan
19.12.2023 14:41Я понимаю, что всё это поддаётся автоматизации. Но в чём проблема контроль конкретно перечисления встроить в сам компилятор, как это сделано в других языках?
nronnie
19.12.2023 14:41Это было бы, конечно, идеально. Но, я, вот, на C# пишу, и там тоже "защита от enum" самая минимальная - нет implicit cast. Но с explicit cast ты все равно можешь в enum засунуть любое самое бредовое значение - поэтому постоянно приходится проверять что же тебе под личиной этого enum на самом деле прилетело. Но у меня к enum-ам больше другие претензии. Если я вижу в коде enum с полутора десятками значений, то я сразу напрягаюсь, потому что уже знаю, что где-то в другом месте (причем еще и не в единственном) в коде будет какой-нибудь жуткий switch с ветками на все эти полтора десятка значений и на несколько экранов.
KanuTaH
19.12.2023 14:41Ну так
enum class
как раз и придуман ради того, чтобы в переменную этого типа нельзя было "загружать просто число". Но это уже C++ а не C.
0x6b73ca
19.12.2023 14:41Так линтер ещё в момент написания кода красненьким подчеркивает что здесь что то не то
Gummilion
19.12.2023 14:41Непонятно, почему решили добавить ключевое слово class, если оно вовсе не класс и ничего, кроме перечисления значений в него по-прежнему нельзя добавить?
zlat_zlat
19.12.2023 14:41Думаю, смысл был в ограничении видимости (приватности) членов этого класса.
Gummilion
19.12.2023 14:41Нет, штука конечно нужная, но почему класс-то? Если так не хотелось вводить новые ключевые слова - ну назвали бы enum namespace (тем более что using для них есть).
nronnie
19.12.2023 14:41Возможно, по аналогии с паттерном, когда вместо
enum
используют "неинстанциируемый" (например, с приватным конструктором) класс с предопределенным набором экземпляров доступных через его статические поля.Gummilion
19.12.2023 14:41Сделали бы тогда уж возможность добавлять в enum методы - к примеру, для Color могли бы быть
string name()
иuint32_t toRgba()
, а то какой-то странный класс, в котором не может быть ничего, кроме константnronnie
19.12.2023 14:41Кстати, да - один из резонов по которым может иметь смысл вообще использовать вместо какого-либо
enum
как раз класс с фиксированным набором инстансов. Это, например, часто используется в одном из приемов рефакторинга - "Замена условного оператора полиморфизмом".
XViivi
19.12.2023 14:41Предполагаю, это потому что возможно изначально это задумывали как сокращение для чего-то такого:
struct EnumName { enum { a, b, c }; };
А потом оно обросло... гипотеза, если что, но правдоподобная.
NeoCode
19.12.2023 14:41scoped перечисления это хорошо и удобно, а вот ограничения на неявное приведение типов от 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 (сейчас во всех современных языках это ввели). С ошибками из-за таких преобразований вообще ни разу не сталкивался.
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 [...] }
NeoCode
19.12.2023 14:41Запрет не в C++, а в более новых языках (в частности я столкнулся с этим в Go - пришлось писать банальную функцию конвертирования bool в int... из той же серии что и предложенный вами оператор, который по сути ничего не делает, но нужен для того чтобы код компилировался). В плюсах, когда вводили bool, это еще никому в голову не пришло:)
А чтобы не заOR'ите яблоки с апельсинами, можно запретить операции между разными enum'ами, но разрешить в некоторых случаях между enum и int.
KanuTaH
19.12.2023 14:41А чтобы не заOR'ите яблоки с апельсинами, можно запретить операции между разными enum'ами, но разрешить в некоторых случаях между enum и int.
Хм, хм,
enum class
как бы либо приводится кint
неявно, либо нет. В первом случае будет выполняться integral promotion, который выполняется в любом случае, ибо арифметические операции в C++ не работают с аргументами, меньшими чемint
, а во втором случае будет как сейчас. Сейчас правила "автоматических" арифметических конверсий в C++ и так довольно сложноваты, вы хотите сделать их еще сложнее? Это раз, а во-вторых, это никак не защитит от яблок и апельсинов, просто не в одной и той же строке, а в двух разных. А вариант с явным определением оператора защитит.
boulder
Древность какая-то. "В прошлом году мы говорили... почему следует избегать использования булевых параметров функций". И главным доводом против — "мы не можем узнать, что это за параметр, не заглядывая в описание функции." В то время, как любая современная IDE всё подробно показывает.
nronnie
В C# давно уже сделали возможность при вместе со значением указывать имя параметра. В общем-то это задумывали для случая наличия нескольких параметров со значением по умолчанию, но и в ситуациях без них это часто удобно для улучшения читаемости:
vitiok78
Даже в PHP уже сделали именованные параметры
Gummilion
Вообще какое-то частичное решение, ну хорошо, булевые значения заменили на что-то осмысленное, а как насчет других типов? Вот есть, к примеру, функция площади треугольника с 3 параметрами - как понять чисто из вызова, это три стороны, две стороны и угол или что-то еще? Что же, делать для каждого параметра обертку над float?
nronnie
Иногда тоже может иметь смысл. Например, чтобы случайно (допустим, из-за обычной опечатке в коде не начать складывать или сравнивать периметр с площадью или фамилию с номером телефона. Главное, думаю, без фанатизма к этому подходить.