Что говорят энумерации в C, Java, Rust, Go и TypeScript о проектировании типов
Сравните, как семь языков определяют энумерации — от именованных целых чисел до объединений с метками и объектов во время выполнения, — и узнайте, что это означает для моделирования в TypeScript.
Источники: Enums Were Never Just Constants
Почти в каждом основном языке существует так называемый enum, причем почти в каждом он означает что-то разное. В одном языке это просто метка над целым числом, в другом — класс с методами, в третьем — объединение данных разной структуры, а в TypeScript это одна из немногих конструкций уровня типов, которые выходят за рамки обычного JavaScript. Сравнение языков C, C++, Java, Rust, Go, TypeScript и Odin позволяет конкретно увидеть эти различия и дает более точные ориентиры для выбора того, какой механизм следует использовать в собственном коде на TypeScript, когда значение может быть только одним из нескольких вариантов.
Одно понятие, несколько отдельных проблем
Типичная картина выглядит просто: вы указываете несколько имен, каждое из которых соответствует одному из допустимых значений:
Red
Green
Blue
Это кажется универсальным понятием, однако как только начинается сравнение реализаций, оно расколывается на отдельные варианты:
- В C enum в основном рассматривается как именованная группа целочисленных констант.
- В C++ сохраняется эта модель, но добавляется конструкция
enum class, которая обеспечивает ограничение области видимости и более строгую типизацию. - В Java каждая константа enum становится настоящим объектом класса enum class, содержащим поля, конструкторы, методы и даже тело класса для каждой константы отдельно.
- В Rust один тип enum может хранить варианты, содержащие совершенно разные данные.
- В Go нет ключевого слова
enum; вместо него используются константы и идентификаторiota. - Энумерации в TypeScript компилируются в объекты JavaScript, существующие во время выполнения.
- В Odin энумерации намеренно оставляются простыми, а связанные задачи передаются другим механизмам, таким как наборы битов и массивы с индексацией по энумерациям.
Причина отсутствия единого определения заключается в том, что «enum» на самом деле представляет собой набор проблем, случайно имеющих общий ключевое слово. Разработчик языка должен решить, какие из следующих функций должна выполнять эта конструкция:
- присвоение имен фиксированному набору значений
- введение отдельного типа вместо повторного использования существующего
- привязка данных к отдельным значениям
- отображение нескольких возможных состояний значения
- позволение компилятору подтвердить, что все случаи учтены
- компактное представление комбинаций флагов
- определение того, что, если вообще что-то, будет сохранено в работающей программе
Каждый язык выбирает свой набор функций, и эти выборы многое говорят о его приоритетах.
C: читаемые имена для целых чисел
Традиционные enum в C представляют собой удобство для именования целочисленных констант. Тип статуса может быть объявлен следующим образом:
enum Status {
STATUS_OK,
STATUS_ERROR,
STATUS_PENDING
};
По сути, эти три имени просто соответствуют последовательным целым числам, начинаясь с нуля:
0
1
2
Это может показаться тривиальным, но это решает реальную проблему читаемости. Представьте сравнение с простым числом, скрытым в логике конфигурации:
if (status == 2) {
...
}
В этой строке ничего не говорит ревизору о том, что означает 2. Та же проверка, написанная с использованием именованной константы, сама по себе является пояснением:
if (status == STATUS_PENDING) {
...
}
Это старейшее обоснование использования enum: они заменяют «магические числа» на имена, несущие смысл. Они также естественно сочетаются с конструкцией switch, где каждый случай понимается как утверждение, связанное с контекстом задачи, а не с арифметикой:
switch (status) {
case STATUS_OK:
...
break;
Остальные случаи следуют тому же принципу:
case STATUS_ERROR:
...
break; case STATUS_PENDING:
...
break;
}
Поскольку значения представляют собой обычные, предсказуемые целые числа, enum в C отлично подходит для использования в низкоуровневом коде: они однозначно соответствуют аппаратным регистрам, полям протоколов и форматам файлов без каких-либо неожиданностей.
Недостатком является слабая проверка соблюдения правил. C не рассматривает enum как замкнутый набор, как это делает Rust. Константы enum ведут себя как целые числа, переменной типа enum обычно можно присвоить любое целое число без ошибок, и компилятор не обязан указывать на отсутствующий case. Многие компиляторы выдают предупреждения при использовании неперечисленных значений enum в конструкции switch, но это скорее удобство инструментов, чем гарантия языка. В стандарте C23 была добавлена синтаксис для указания фиксированного основного типа, что сокращает пробел, который C++ устранил много лет назад.
Философию C можно свести к следующему:
Давайте целым числам более подходящие имена и сводим окружающую их архитектуру к минимуму.
В системном программировании именно такой минимализм часто бывает именно тем, что нужно.
C++: та же идея с более строгими ограничениями
C++ унаследовал перечисления в стиле C, а позже ввёл более строгую форму — перечисления с ограничением области видимости:
enum class Status {
Ok,
Error,
Pending
};
Это изменение — не просто синтаксическая конвенция. Элементы перечисления с ограничением области видимости должны указываться с упоминанием имени типа:
Status::Ok
вместо того чтобы выступать в качестве простого идентификатора в окружающей области видимости:
Ok
Не менее важно то, что перечисление с ограничением области видимости не преобразуется косвенно в целое число. Нельзя случайно сравнивать объект типа Status с несвязанным объектом типа Color или передавать его туда, где требуется int, без явного преобразования, что исключает целый ряд скрытых ошибок.
C++ также позволяет явно выбирать тип хранения:
enum class Status : uint8_t {
Ok,
Error,
Pending
};
Это имеет значение всякий раз, когда структура лейаута является частью спецификации, например в сетевых пакетах или структурах, обмениваемых с кодом на языке C. Язык различает энумерации без области видимости и те, что объявлены с использованием enum class или enum struct; обе формы поддерживают фиксированный основной тип.
Следует отметить, что C++ не переопределяет понятие энумерации. Он добавляет возможность управления тремя аспектами, связанными с ней:
- областью видимости имен
- степенью строгого разделения типа от целых чисел
- способом представления значения в памяти
В сущности это по-прежнему список именованных значений; программист лишь может более четко определить границы этого списка.
Java: энумерации как часть объектной модели
Java еще больше отдаляется от использования целых чисел. Синтаксис объявления кажется знакомым:
enum Status {
OK,
ERROR,
PENDING
}
Однако OK, ERROR и PENDING не являются числовыми псевдонимами. Согласно спецификации языка Java, enum — это особая, ограниченная форма класса, экземпляры которого заранее объявляются по имени. Каждая константа представляет собой объект, создаваемый один раз при инициализации класса, и поскольку это объект, он может хранить состояние и иметь поведение.
Константы, хранящие данные
Аргумент конструктора может присваивать значение каждой константе. Здесь каждая планета хранит информацию о своей массе:
enum Planet {
EARTH(5.97),
MARS(0.642);
Поле и конструктор, которые его заполняют, завершают объявление:
final double mass; Planet(double mass) {
this.mass = mass;
}
}
Теперь каждая константа сопровождается собственными данными, и код, нуждающийся в массе планеты, читает значение из поля вместо того, чтобы обращаться к отдельной таблице поиска, которая может потерять синхронизацию.
Константы, имеющие поведение
Энумерации также могут объявлять методы, причем каждая константа может содержать собственную реализацию. Следующий тип операции переопределяет абстрактный метод apply для каждой константы:
enum Operation {
ADD {
@Override
int apply(int a, int b) {
return a + b;
}
},
Вторая константа и абстрактное объявление выглядят следующим образом:
MULTIPLY {
@Override
int apply(int a, int b) {
return a * b;
}
}; abstract int apply(int a, int b);
}
На этом этапе энумерация явно представляет собой класс, и это сделано намеренно. Java интегрирует энумерации в свою объектно-ориентированную модель, поэтому константа может иметь поля, аргументы конструктора, общие методы и переопределения, специфичные для константы. Константа с собственным телом класса компилируется как анонимный подкласс типа энумерации, и в новых версиях Java такой класс энумерации считается имплицитно закрытым, поскольку набор подклассов фиксируется объявлением.
Таким образом, позиция Java можно описать примерно следующим образом:
Если значение берётся из фиксированного набора, зачем ограничивать его целым числом, если оно может быть объектом с собственным поведением?
Именно поэтому перечисления в Java идеально подходят для машин состояний, парсеров, таблиц операторов с приоритетами и механизмов выбора по стратегии, где каждый вариант знает, как выполнять свою работу.
Когда форма данных зависит от варианта
Перечисления в Java проявляют себя наилучшим образом, когда у каждой константы одинаковый тип заранее определённой информации. Однако возникает другая проблема, когда каждый вариант требует разной структуры.
Рассмотрим сообщения в протоколе чата:
TextMessage
ImageMessage
VideoMessage
Текстовое сообщение содержит строку, изображение хранится в виде байтов и метаданных, а видео сообщение — что-то совершенно другое. Здесь меняется не значение поля, а сама структура данных. Энумерация в стиле Java, при которой каждая константа имеет один и тот же набор полей, плохо подходит для этой задачи. Именно для решения таких проблем и были созданы энумерации в Rust.
Rust: энумерации как маркированные союзы
В Rust у каждого варианта энумерации может быть свой собственный набор данных:
enum Message {
Quit,
Move { x: i32, y: i32 },
Write(String),
ChangeColor(u8, u8, u8),
}
Quit не содержит ничего, Move имеет именованные поля, Write оборачивает объект типа String, а ChangeColor хранит три байта. Для создания значения выбирается соответствующий вариант и передаются его данные:
let message = Message::Move { x: 10, y: 20 };
Другой вариант того же типа выглядит так:
let message = Message::Write(String::from("Hello"));
Оба выражения возвращают объект типа Message, однако информация, находящаяся внутри них, имеет совершенно разную структуру. Такая конструкция в теории типов называется суммарным типом, а программисты-системные инженеры часто называют её маркированным союзом: метка указывает, какой вариант активен, а полезная нагрузка содержит данные этого варианта. Тип описывает замкнутый набор альтернатив:
Message
├── Quit
├── Move { x, y }
├── Write(String)
└── ChangeColor(r, g, b)
Полное сопоставление
Компилятор знает обо всех вариантах, и оператор match использует эту информацию. Первый вариант обрабатывает команду Quit:
match message {
Message::Quit => {
println!("Quit");
}
Остальные варианты разбирают структуру полезной нагрузки каждого из вариантов и присваивают его поля локальным именам:
Message::Move { x, y } => {
println!("Move to {}, {}", x, y);
} Message::Write(text) => {
println!("{}", text);
} Message::ChangeColor(r, g, b) => {
println!("RGB: {}, {}, {}", r, g, b);
}
}
Если удалить один из таких вариантов, программа больше не компилируется: Rust отклоняет неполный оператор match. Именно по этой причине в документации Rust перечисления и сопоставление шаблонов рассматриваются вместе. Добавление нового варианта позже превращает каждый неполный оператор match в кодовой ошибку, указывающую на места, требующие обновления.
В этом и заключается основное отличие от перечислений в C. Перечисление в C отвечает на вопрос:
Какой из именованных констант соответствует этому числу?
Перечисление в Rust отвечает на вопрос:
Какой вариант сейчас активен и какое значение он несет?
Второй вопрос охватывает гораздо более широкий спектр возможных конструкций.
Сделать недопустимые состояния не представимыми
Практическая польза наиболее очевидна на примере конкретных результатов. Операция, которая может завершиться успехом или неудачей, может быть представлена в виде типа с двумя вариантами:
Result
├── Success(value)
└── Error(error)
Значение, которое может присутствовать или отсутствовать, обрабатывается одинаково:
Option
├── Some(value)
└── None
Стандартная библиотека Rust предоставляет оба этих подхода в виде типов Result и Option, причем это обычные перечисления. Сравните это с подходом с отдельными полями, распространенным во многих кодовых базах:
value = null
hasValue = false
error = ...
При использовании отдельных полей ничто не мешает значению hasValue быть равным true, в то время как value равен null, или когда ошибка и значение могут быть установлены одновременно. Каждый пользователь должен запоминать неписаные правила. При использовании типа-суммы такие комбинации вообще невозможны, поскольку единственный способ создания значения — это через один из объявленных вариантов.
Это отражает одну из глубочайших целей проектирования enum:
Enum позволяет непосредственно в системе типов закодировать правила, касающиеся структуры данных.
Как только правила находятся в типе, компилятор может обнаружить отсутствующие случаи ещё до запуска программы. Разработчики TypeScript имеют похожий инструмент в виде дискриминируемых союзов, где общее поле с литералами, такое как kind, выполняет роль маркера, а проверка типа never обеспечивает полноту охвата.
Go: совсем нет ключевого слова enum
После Rust подход Go может показаться шагом назад во времени. Здесь нет ключевого слова enum. Вместо этого используется блок констант, формируемый с помощью iota:
const (
StatusOK = iota
StatusError
StatusPending
)
Внутри объявления константы iota возвращает индекс текущего указания на константу, начиная с нуля, и может использоваться в выражениях. Если на какой-либо строке в блоке отсутствует выражение, Go повторяет предыдущее, поэтому:
const (
A = iota
B
C
)
результат будет следующим:
A = 0
B = 1
C = 2
Поскольку имплицитное повторение копирует всё выражение, iota может использоваться не только для подсчёта. Сдвиг на его значение позволяет получать степени двойки:
const (
Read = 1 << iota
Write
Execute
)
Результаты таких операций будут следующими:
Read = 1
Write = 2
Execute = 4
Отдельные биты делают эти константы подходящими флагами: Read | Write объединяет разрешения, а операция побитового И проверяет наличие определённого разрешения. Также можно ввести именованный тип, чтобы константы не были просто обычными целыми числами:
type Permission uint8
и затем объявить константы с использованием этого типа:
const (
Read Permission = 1 << iota
Write
Execute
)
Только Read является полным названием для Permission, но из-за повторения этого выражения тип также передается, поэтому Write и Execute тоже считаются значениями Permission. Спецификация Go различает типизированные и нетипизированные константы, а именованный тип обеспечивает определённую защиту от смешивания несвязанных значений. Однако он не создаёт той идентичности энумерации, которая присутствует в языках с специальным конструктом.
Это характерно для Go: вместо добавления новой функции он предоставляет гибрую систему констант и оставляет более высокий уровень абстракции на усмотрение программиста. При этом язык никогда формально не связывает
these constants
с
this closed set of variants
как это делают Rust или Java. Любое значение основного типа может быть преобразовано в Permission, и компилятор не проверяет, является ли переключение между этими константами исчерпывающим. Команды, желающие такую гарантию, обычно добавляют инструменты проверки кода или генераторы кода.
TypeScript: enum, существующий во время выполнения
TypeScript — это в основном слой статических типов поверх JavaScript. Такой псевдоним типа:
type User = {
name: string;
};
не оставляет никаких следов в генерируемом JavaScript; он удаляется во время компиляции. Enum нарушают это правило:
enum Direction {
Up,
Down,
Left,
Right
}
Обычный enum в TypeScript компилируется в настоящий объект JavaScript, и руководство TypeScript прямо описывает это поведение во время выполнения. Для числовых enum генерируемый объект включает как прямые, так и обратные сопоставления, поэтому можно переходить от имени к числу и обратно:
Direction.Up -> 0
Direction[0] -> "Up"
Это удивляет разработчиков, которые считают, что всё, что добавляет TypeScript, работает только на этапе компиляции. У этого также есть практические последствия: enum добавляет код в ваш бандл, а итерация над числовым enum с помощью Object.keys возвращает числовые ключи в виде строк наряду с их названиями.
У строковых enum задаются явные строковые значения:
enum Direction {
Up = "UP",
Down = "DOWN",
Left = "LEFT",
Right = "RIGHT"
}
У них нет обратных сопоставлений, и их значения гораздо легче читать в логах, данных сетевых запросов и выводе отладчика, поскольку "UP" сам по себе понятен, в отличие от 0. TypeScript также поддерживает вычисляемые члены и const enum, который встраивается в места использования и удаляется из итогового кода. Следует помнить, что const enum плохо совместим с инструментами, которые компилируют файлы по одному, поэтому многие проекты избегают его использования.
Альтернатива — объект с ключом const
В документации TypeScript также показан более простой вариант, созданный с использованием обычного объекта и утверждения as const:
const Direction = {
Up: 0,
Down: 1,
Left: 2,
Right: 3,
} as const;
Это обычный JavaScript, который понимают все инструменты, в то время как as const предоставляет TypeScript литеральные типы для каждого свойства. Соответствующий объединённый тип можно получить с помощью (typeof Direction)[keyof typeof Direction]. Этот подход стал ещё более актуальным, поскольку среды выполнения обрели возможность напрямую удалять типы: такой способ удаляет только аннотации, а перечисления, требующие реальной генерации кода, к нему не подходят. Проверьте актуальную документацию вашей среды выполнения и компилятора для получения точных правил; статья о том, что на самом деле делает встроенная поддержка TypeScript в Node.js подробно рассматривает этот аспект.
Суть в том, что концепция enum в TypeScript формируется под влиянием её языка-хоста. Java мог бы с нуля определить enum как функцию первого класса во время выполнения. TypeScript вынужден сосуществовать с JavaScript, у которого нет собственных enum. Вопрос для TypeScript звучит так:
Во что должна превратиться функция уровня типов, когда основой является JavaScript?
Четкого ответа нет, и поэтому в TypeScript осталось несколько перекрывающихся вариантов.
Odin: простые enum плюс ортогональные инструменты
Odin выбирает другой подход. Его enum остаются близки к классической идее названного набора значений, и вместо того чтобы превращать enum в иерархию объектов или алгебраический тип данных, язык предоставляет отдельные механизмы для решения схожих задач: отдельные типы, массивы с перечислениями, индексируемые enum, объединения и наборы битов, элементами которых может быть enum.
Простой enum описывает направления компаса:
Direction :: enum {
North,
East,
South,
West,
}
Набор битов, основанный на этом enum, затем представляет любую комбинацию направлений:
Direction_Set :: bit_set[Direction]
Принцип проектирования заключается в разделении обязанностей:
- фиксированный набор значений — это enum
- коллекция этих значений — это набор битов
- данные, связанные с каждым значением, хранятся в массиве, индексируемом enum
- альтернативы с разным форматом данных — это объединения
Поскольку каждый инструмент выполняет одну задачу, его можно рассматривать отдельно, и ни один из них не обязан нести бремя всех остальных. Сравните это с Go, где флаги создаются вручную с использованием операций сдвига, или с Rust, где сам enum покрывает как простые списки, так и объединения с метками.
Ни один язык не справился с enum идеально
Хочется расставить эти решения по уровням, но они отвечают на разные вопросы. Краткое сравнение:
- C: константы целых чисел с именами, оптимизированные под простоту, читаемость и совместимость на низком уровне.
- C++: та же основа, но с возможностью определения области видимости, более строгой типизацией и явным базовым типом.
- Java: фиксированный набор экземпляров класса enum, способный хранить поля, конструкторы, методы и поведение для каждой константы.
iota генерируют последовательные или вычисляемые значения.Ни один из этих языков не является улучшенной версией предыдущего. Каждый отражает ту функцию, которую его создатели хотели, чтобы выполняла данная возможность.
Что говорит о языке дизайн перечислений
Энумерации настолько просты, что их можно изучить за один день, но в то же время они отражают философию языка, в котором используются. Они можно рассматривать как ответы на вопросы, которые, по-видимому, ставит каждый язык:
- C: как эффективно представить это с помощью простых примитивов?
- C++: как сохранить контроль на уровне аппаратного обеспечения и в то же время добавить более безопасные абстракции?
- Java: что, если каждый элемент замкнутого набора будет полным объектом со своими методами?
- Rust: может ли один тип описать все допустимые состояния вместе с данными, хранящимися в каждом из них?
- Go: действительно ли нужна специальная функция для этого?
- TypeScript: во что должна превратиться функция, работающая на этапе компиляции, когда ей предстоит выполняться как обычный JavaScript?
- Odin: может ли основная конструкция оставаться простой, в то время как специализированные функции обрабатывают связанные задачи?
Поскольку языки оптимизируются с учетом разных аспектов, концепция энумераций так и не сформировала единого подхода.
Enum — это решения по моделированию, а не синтаксис
Легко воспринимать enum как элемент синтаксиса:
enum {
A,
B,
C
}
Синтаксис — это самая менее интересная часть. Настоящий вопрос заключается в том, что вы хотите, чтобы система типов гарантировала относительно значений, которые вы указали:
A, B, C
Чтобы ответить на этот вопрос, необходимо решить несколько более мелких:
- Являются ли это просто именами для чисел?
- Требуется ли к каждому значению своя дополнительная информация?
- Требуется ли к каждой варианте своё поведение?
- Должен ли компилятор знать, что это единственные допустимые состояния, и обеспечивать полную обработку всех случаев?
- Нужны ли вам компактные флаги битов?
- Должны ли значения существовать во время выполнения для итерации или проверки?
- Как их следует сериализовать при передаче по сети или сохранении в памяти?
Разные ответы приводят к разным решениям по проектированию, и последствия выходят далеко за рамки синтаксиса: расположение данных в памяти, безопасность типов, то, что может анализировать компилятор, затраты во время выполнения, сопоставление шаблонов, структура API, формат сериализации и, в конечном счёте, то, как команда подходит к обработке своих данных.
Иногда подходящий enum — это не enum
Это имеет прямые последствия для повседневного использования TypeScript. Язык предоставляет enum потому, что существует определённая проблема, но это не означает, что каждый фиксированный набор значений должен превращаться в enum. В зависимости от ситуации:
- соединение строковых литералов часто более понятно и не влечёт никаких затрат во время выполнения
- обычный объект с атрибутом
as constпозволяет использовать значения и типы во время выполнения без специальной компиляции - специализированный класс имеет смысл, когда каждый вариант сопровождается значительным набором действий
- целое число с масками битов — подходящий инструмент для комбинаций
Хороший дизайн типов начинается с данных и их неизменяемых свойств, а не с какого-либо ключевого слова, предоставляемого языком.
Основные выводы
- «Enum» объединяет несколько независимых аспектов: именование, различные типы данных, привязанные данные, формы вариантов, полноту охвата, флаги и наличие во время выполнения.
- C и C++ сосредоточены на целых числах и их представлении; Java — на объектах; Rust — на суммарных типах с проверкой соответствия компилятором.
- Go и Odin демонстрируют два противоположных подхода к избежанию универсального enum: создать его самостоятельно из констант или разделить задачу между специализированными функциями.
- Энумерации TypeScript — это настоящие объекты во время выполнения, что влияет на размер бандлов, процесс итерации и совместимость с инструментами удаления типов; литеральные объединения и объекты
as constчасто являются более простым решением. - Прежде чем использовать энумерацию, определите, какие гарантии вам нужны. Выбранный конструкт должен следовать из этих гарантий, а не наоборот.