Головна / Статті

Що говорять енумерації в C, Java, Rust, Go та TypeScript про проектування типів

Порівняйте, як сім мов програмування визначають енумерації — від іменованих цілих чисел до об’єднань з мітками та об’єктів під час виконання, та дізнайтеся, що це означає для моделювання в TypeScript.

Видавець: REACTAPP.TOP17 хв читання3847 слів

Джерела: 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.
  • Enum у TypeScript компілюються у об’єкти JavaScript, які існують під час виконання.
  • Odin навмисно тримає enum простими та передає пов’язані завдання іншим функціям, таким як множини бітів та масиви з індексами enum.

Причина відсутності єдиного визначення полягає у тому, що „enum“ насправді є сукупністю проблем, які випадково мають спільний ключове слово. Розробник мови повинен вирішити, які з наведених функцій має виконувати ця конструкція:

  • надання імен фіксованому набору значень
  • створення окремого типу замість повторного використання існуючого
  • приєднання даних до окремих значень
  • представлення кількох можливих станів значення
  • дозволити компілятору довести, що кожен випадок було оброблено
  • компактне представлення комбінацій флагів
  • вирішення того, що, якщо взагалі щось, залишиться у запущеній програмі

Кожна мова обирає різний піднабір цих функцій, і ці вибори багато чого розкривають про її пріоритети.

C: читабельні імена для цілих чисел

Традиційний enum у C є зручним інструментом для найменування цілочисельних констант. Тип статусу може бути оголошений ось так:

enum Status {
    STATUS_OK,
    STATUS_ERROR,
    STATUS_PENDING
};

Під спудом ці три імені просто відповідають послідовним цілим числам, які починаються з нуля:

0
1
2

Це може здатися тривіальним, але це вирішує справжню проблему читабельності. Уявіть порівняння з простим числом, прихованим у логіці конфігурації:

if (status == 2) {
    ...
}

У цьому рядку нічого не пояснює, що означає 2. Та сама перевірка, написана з використанням іменованої константи, сама по собі є поясненням:

if (status == STATUS_PENDING) {
    ...
}

Це найстаріше обґрунтування використання enums: вони замінюють „магічні числа“ на імена, які несуть зміст. Вони також природно поєднуються з оператором switch, де кожен випадок розглядається як твердження про сферу діяльності, а не про арифметику:

switch (status) {
    case STATUS_OK:
        ...
        break;

Решта випадків слідують тому самому шаблону:

    case STATUS_ERROR:
        ...
        break;    case STATUS_PENDING:
        ...
        break;
}

Оскільки значення є звичайними, передбачуваними цілими числами, енумерації в C ідеально підходять для коду низького рівня: вони без проблем відповідають апаратним регістрам, полям протоколу та форматам файлів.

Недоліком є слабке забезпечення правил. C не розглядає енумерацію як замкнений набір, як це робить Rust. Константи енумерації поводяться як цілі числа, змінні енумерації зазвичай можна присвоювати будь-яке ціле число без проблем, а компілятор не зобов’язаний позначати відсутній case. Багато компіляторів видають попередження щодо непроцесованих значень енумерації в конструкції 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 можна описати приблизно так:

Якщо значення походить із фіксованого набору, чому обмежувати його цілим числом, якщо воно може бути об’єктом із власною поведінкою?

Це робить enum у Java ідеальним варіантом для машин станів, парсерів, таблиць операторів із пріоритетами та механізмів розподілу за принципом стратегій, де кожен варіант знає, як виконувати свою роботу.

Коли форма даних залежить від варіанту

Enum у 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, і це звичайні enumи. Порівняйте це із підходом з окремими полями, поширеним у багатьох кодових базах:

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, і компілятор не перевіряє, чи є switch з цими константами вичерпним. Команди, які хочуть такої гарантії, зазвичай додають інструменти лінтингу чи генератори коду.

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 типу string присвоюються явні рядкові значення:

enum Direction {
    Up = "UP",
    Down = "DOWN",
    Left = "LEFT",
    Right = "RIGHT"
}

У них немає зворотних мапувань, а їхні значення набагато легше читати у журналах подій, мережевих даних та виводі з дебагера, оскільки „UP“ сам по собі є зрозумілим, на відміну від 0. TypeScript також підтримує обчислювані елементи та const enum, який інлайнується у місцях використання та видаляється з кінцевого коду. Зверніть увагу, що const enum погано взаємодіє з інструментами, які компілюють один файл за раз, тому багато проєктів уникають його використання.

Альтернатива — object-plus-as-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 могла б з самого початку визначити enums як функцію першого класу під час виконання. TypeScript змушена існувати разом із JavaScript, у якого немає власних enum. Тож формулювання цього питання для неї стає таким:

У що має перетворитися функція рівня типу, коли основне середовище — JavaScript?

Чіткої відповіді немає, саме тому у TypeScript з’явилося кілька перекриваючих одне одного варіантів.

Odin: простий enum та ортогональні інструменти

Odin обирає інший підхід. Його енумерації залишаються близькими до класичної концепції названих множин значень, і замість того, щоб перетворювати енумерацію на ієрархію об’єктів чи алгебраїчний тип даних, мова надає окремі засоби для вирішення схожих проблем: окремі типи, масиви з енумерацією, індексовані енумерацією, об’єднання та множини бітів, елементами яких може бути енумерація.

Звичайна енумерація описує напрямки компаса:

Direction :: enum {
    North,
    East,
    South,
    West,
}

Множина бітів, створена на основі цієї енумерації, тоді представляє будь-яку комбінацію напрямків:

Direction_Set :: bit_set[Direction]

Принцип проектування полягає у розподілі обов’язків:

  • фіксована множина значень — це енумерація
  • колекція цих значень — це множина бітів
  • дані, пов’язані з кожним значенням, знаходяться в масиві, індексованому енумерацією
  • альтернативи з різною структурою даних — це об’єднання

Оскільки кожен інструмент виконує одну функцію, його можна розглядати окремо, і жоден з них не мусить нести тягар усіх інших. Порівняйте це з Go, де флаги створюються вручну за допомогою операцій зсуву, або з Rust, де сам enum охоплює як прості списки, так і об’єднання з мітками.

Жодна мова не реалізувала enums ідеально

Хочеться ранжувати ці підходи, але вони відповідають на різні запитання. Коротке порівняння:

  • C: константи-цілі числа з іменами, оптимізовані для простоти, читабельності та сумісності на низькому рівні.
  • C++: та сама основа з необов’язковим обмеженням області видимості, суворішою типізацією та явним базовим типом.
  • Java: фіксований набір екземплярів класу enum, здатний містити поля, конструктори, методи та поведінку для кожної константи.
  • Rust: закритий набір варіантів, кожен з яких може містити власні дані, причому компілятор забезпечує повне зіставлення шаблонів.
  • Go: немає спеціальної конструкції; константи та iota генерують послідовні або обчислені значення.
  • TypeScript: переліки, які на час виконання існують у вигляді об’єктів JavaScript, на відміну від більшості функцій типізації TypeScript, а також існують альтернативи, які можна видалити.
  • Odin: прості переліки, доповнені множинами бітів, переліченими масивами та об’єднаннями для виконання схожих завдань.
  • Жоден з цих підходів не є оновленою версією попереднього. Кожен з них відображає те, за що його розробники хотіли, аби відповідала ця функція.

    Що розкриває дизайн переліку про мову

    Енумерації настільки прості, що їх можна опанувати за один день, проте вони відображають філософію мови, яка їх оточує. Це можна сформулювати як запитання, яке, здається, ставить кожна мова:

    • C: як ефективно представити це за допомогою простих примітивів?
    • C++: як зберегти контроль на рівні апаратного забезпечення та водночас додати більш безпечні абстракції?
    • Java: що, якби кожен елемент замкнутого набору був повноцінним об’єктом із власними методами?
    • Rust: чи може один тип описати всі допустимі стани разом із контентом кожного з них?
    • Go: чи справді потрібна окрема функція для цього?
    • TypeScript: у що має перетворитися функція, яка працює на етапі компіляції, коли їй потрібно виконуватися як звичайний JavaScript?
    • Odin: чи може основна структура залишатися простою, поки спеціалізовані функції вирішують пов’язані завдання?

    Оскільки мови оптимізуються під різні цілі, енумерації так і не об’єдналися в єдину концепцію.

    Enums — це рішення щодо моделювання, а не синтаксис

    Легко уявити 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 часто є простішим вибором.
    • Перш ніж вдаватися до енумерацій, вирішіть, які гарантії вам потрібні. Обраний конструкт має випливати з цих гарантій, а не навпаки.