C++

Перерахування (enum)

Дізнайтеся, що таке перелічуваний тип даних у C++, як оголошувати та використовувати enum, які є внутрішні механізми роботи перерахувань та як вони роблять код читабельнішим і безпечнішим.

Перерахування (enum)

Проблема «магічних чисел»

Уявіть: ви відкриваєте чужий код — або навіть власний, написаний два тижні тому — і бачите функцію:

int readFile(const std::string& path) {
    if (!openFile(path))
        return -1;
    if (!parseFile())
        return -2;
    if (!readContents())
        return -3;
    return 0;
}

Що означає -2? Помилка парсингу? Недостатньо прав? Диск заповнений? Без коментаря або документації це просто магічне число (magic number) — значення, яке несе змістовне навантаження, але само по собі нічого не пояснює. Щоб зрозуміти, що воно означає, доводиться шукати оригінальне визначення, яке може бути в іншому файлі, або ж вгадувати з контексту.

Магічні числа — це одна з найпоширеніших причин помилок і важкого супроводу коду. Програміст, який викликає readFile(), мусить пам'ятати, що -1 — відкрити не вдалося, -2 — парсинг провалився, а -3 — читання обірвалося. Ця інформація існує лише в голові, не в коді.

Стандартна відповідь мови C++ на цю проблему — перерахування (enumeration).

Термін «магічне число» (magic number) в програмуванні означає числову константу, яка з'являється безпосередньо в коді без пояснення її значення. Добра практика вимагає замінювати їх іменованими константами або перерахуваннями.

Що таке перерахування

Перелічуваний тип даних (enumerated type, або скорочено — enumeration) — це тип, усі можливі значення якого визначені як іменовані цілочисельні константи. Кожна така константа називається енумератором (enumerator). Перерахування оголошується за допомогою ключового слова enum.

Найпростіший спосіб зрозуміти мотивацію — переписати попередній приклад через перерахування:

enum ParseResult {
    SUCCESS        =  0,
    ERR_OPEN_FILE  = -1,
    ERR_PARSE      = -2,
    ERR_READ       = -3,
};

ParseResult readFile(const std::string& path) {
    if (!openFile(path))
        return ERR_OPEN_FILE;
    if (!parseFile())
        return ERR_PARSE;
    if (!readContents())
        return ERR_READ;
    return SUCCESS;
}

Тепер код «говорить» сам за себе. Якщо функція повернула ERR_PARSE, будь-який розробник розуміє: виникла проблема з синтаксичним аналізом файлу. Жодних коментарів не потрібно — значення закладено безпосередньо в ім'я.

Зверніть увагу на декілька деталей синтаксису:

  • Оголошення починається з ключового слова enum, після якого йде ім'я типу.
  • Тіло перерахування — це список енумераторів (іменованих значень), розділених комами.
  • Весь блок завершується крапкою з комою після закриваючої дужки } — точно так само, як структура або клас.
  • У C++11 і новіших версіях дозволена кінцева кома після останнього енумератора (ERR_READ = -3,), що спрощує додавання нових значень без редагування попереднього рядка.
Імена перерахувань традиційно пишуться з великої літери (ParseResult), а самі енумератори — повністю у верхньому регістрі (ERR_OPEN_FILE). Ця конвенція допомагає відразу відрізнити тип від змінної і константу від звичайного ідентифікатора.

Синтаксис і оголошення

Загальна форма оголошення перерахування виглядає так:

enum ІмʼяТипу {
    ЕНУМЕРАТОР_1,
    ЕНУМЕРАТОР_2,
    ЕНУМЕРАТОР_3,
};

Розглянемо конкретний приклад — перерахування напрямків руху:

enum Direction {
    DIRECTION_NORTH,
    DIRECTION_SOUTH,
    DIRECTION_EAST,
    DIRECTION_WEST,
};

Після оголошення типу можна визначати змінні цього типу:

Direction playerDir = DIRECTION_NORTH;
Direction enemyDir(DIRECTION_SOUTH);  // альтернативний синтаксис ініціалізації

Важливо розрізняти оголошення типу і визначення змінної. Рядок enum Direction { ... }; лише повідомляє компілятору, що існує такий тип — пам'ять при цьому не виділяється. Пам'ять виділяється лише тоді, коли ви оголошуєте змінну цього типу (наприклад, Direction playerDir).

Перерахування є незахищеним (unscoped) типом: усі його енумератори потрапляють у той самий простір імен, де оголошено саме перерахування. Якщо Direction оголошено у глобальному просторі, то DIRECTION_NORTH також є глобальною константою. Саме тому прийнято додавати префікс назви типу до кожного енумератора — щоб уникнути конфліктів імен.

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

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

enum Color {
    COLOR_RED,    // 0
    COLOR_GREEN,  // 1
    COLOR_BLUE,   // 2
    COLOR_YELLOW, // 3
};

Якщо вивести значення змінної цього типу через std::cout, ми побачимо ціле число — не ім'я:

#include <iostream>

int main() {
    Color c = COLOR_BLUE;
    std::cout << c << "\n"; // виведе: 2
    return 0;
}
./main
$ ./main
2
Execution finished with exit code 0.

Це тому, що enum у C++ — це «тонка обгортка» навколо цілого числа. Значення енумератора неявно конвертується в int для виведення.

Явне присвоєння значень

Ви можете вручну задати значення будь-якому енумератору. Усі наступні (без явного значення) продовжують відлік з останнього визначеного:

enum StatusCode {
    SUCCESS        =  0,   // явно 0
    ERR_NOT_FOUND  = 404,  // явно 404
    ERR_SERVER     = 500,  // явно 500
    ERR_UNKNOWN,           // автоматично 501 (500 + 1)
};

Значення можуть бути від'ємними, що особливо корисно для кодів помилок:

enum ParseResult {
    PARSE_OK        = 0,
    PARSE_ERR_EMPTY = -1,
    PARSE_ERR_UTF8  = -2,
    PARSE_ERR_EOF   = -3,
};
Мова C++ дозволяє двом енумераторам мати однакові значення. Компілятор не видасть помилку, але така ситуація може спричинити непередбачувану поведінку — особливо в конструкціях switch, де обидва значення виявляться еквівалентними. Уникайте дублювання значень без вагомої причини.

Стан змінних перерахування добре підходить для візуалізації у відлагоджувачеві. Розглянемо, як виглядатиме дебаг-панель для нашого прикладу:

Local Variables
Filter
NameTypeValue
playerDirDirectionDIRECTION_NORTH (0)
enemyDirDirectionDIRECTION_SOUTH (1)
statusParseResultPARSE_OK (0)
Running
Process: 12842

Зверніть увагу: хороший відлагоджувач показує і символьне ім'я (DIRECTION_NORTH), і числове значення (0) одночасно — це одна зі справжніх переваг перерахувань над голими числами у відлагодженні.

Розмір перерахування в пам'яті

Перелічуваний тип відноситься до родини цілочисельних типів. Стандарт C++ не фіксує конкретний розмір: компілятор обирає його самостійно, щоби вмістити всі значення перерахування. На практиці, для більшості enum розмір збігається з sizeof(int) — зазвичай 4 байти.

Перевіримо це:

#include <iostream>

enum Direction { NORTH, SOUTH, EAST, WEST };

int main() {
    std::cout << "sizeof(Direction) = " << sizeof(Direction) << " байт\n";
    std::cout << "sizeof(int)       = " << sizeof(int)       << " байт\n";
    return 0;
}
./main
$ ./main
sizeof(Direction) = 4 байт
sizeof(int) = 4 байт
Execution finished with exit code 0.

Подивимося на внутрішнє представлення в пам'яті. Перше значення DIRECTION_NORTH — це просто ціле число 0, записане у 4 байти:

Stack — Direction variable
Hex Dump / ASCII
0x007FFEDC
0000000001000000
Offset: 8 bytes
Big Endian

Перші 4 байти (підсвічені) — це змінна playerDir = DIRECTION_NORTH, значення якої дорівнює 0. Наступні 4 байти — enemyDir = DIRECTION_SOUTH, значення 1. Як бачите, перерахування займає рівно стільки пам'яті, скільки int.


Конвертації між enum та int

enum → int: дозволено неявно

Оскільки enum побудований поверх цілого числа, C++ дозволяє неявну конвертацію (implicit conversion) значення перерахування в int. Саме цим пояснюється, що std::cout << COLOR_BLUE виводить число 2, а не рядок "COLOR_BLUE".

Ту саму конвертацію можна виконати явно:

Color c = COLOR_GREEN;
int numericValue = c;              // неявна конвертація: numericValue = 1
int explicit_val = static_cast<int>(c);  // явна конвертація, теж 1

Обидва варіанти валідні. Явна версія через static_cast вважається кращою практикою — вона показує, що ви усвідомлено перетворюєте тип.

int → enum: тільки явно

Зворотне перетворення — з int у enumзаборонено без явного приведення. Компілятор відмовить:

Color c = 2; // ❌ помилка компіляції: cannot convert 'int' to 'Color'

Якщо така конвертація все ж необхідна, використовуйте static_cast:

int input = 2;
Color c = static_cast<Color>(input); // ✅ явна конвертація
Конвертація static_cast<Color>(999) формально є валідним кодом, але поведінка не визначена (undefined behavior), якщо значення 999 не є жодним з оголошених енумераторів. Це особливо небезпечно при отриманні введення від користувача або з мережі — завжди перевіряйте діапазон перш ніж кастити.

Читання через std::cin

Компілятор не дозволяє читати значення перерахування безпосередньо через std::cin:

Color c;
std::cin >> c; // ❌ помилка компіляції

Правильний підхід — зчитати ціле число, перевірити його та конвертувати:

#include <iostream>

int main() {
    int input;
    std::cout << "Оберіть колір (0=червоний, 1=зелений, 2=синій): ";
    std::cin >> input;

    if (input < 0 || input > 2) {
        std::cout << "Невідомий колір!\n";
        return 1;
    }

    Color c = static_cast<Color>(input);
    std::cout << "Ви обрали COLOR_" << c << "\n"; // виведе числове значення
    return 0;
}

Виведення назви енумератора

Коли ми пишемо std::cout << COLOR_BLUE, бачимо 2 замість "Blue". Це поведінка за замовчуванням, яка нас рідко влаштовує. Щоб вивести зрозуміле текстове ім'я, потрібно написати окрему функцію. Найпоширеніший підхід — switch-вираз:

#include <iostream>

enum Color {
    COLOR_RED,
    COLOR_GREEN,
    COLOR_BLUE,
};

const char* colorName(Color c) {
    switch (c) {
        case COLOR_RED:   return "Червоний";
        case COLOR_GREEN: return "Зелений";
        case COLOR_BLUE:  return "Синій";
        default:          return "Невідомий";
    }
}

int main() {
    Color c = COLOR_GREEN;
    std::cout << "Колір: " << colorName(c) << "\n";
    return 0;
}
./main
$ ./main
Колір: Зелений
Execution finished with exit code 0.

Розберемо ключові частини:

  • Функція colorName приймає значення типу Color і повертає рядок const char* — текстовий опис.
  • Конструкція switch (c) порівнює значення з кожним case. Коли C++ виконує switch над значенням перерахування, він фактично порівнює цілі числа — але ваш код при цьому залишається читабельним.
  • Гілка default є обов'язковою, якщо ви хочете захиститися від ситуацій, коли в змінну типу Color потрапило непередбачене значення через небезпечний static_cast.
Сучасний C++ (C++23) додав можливість перевантаження оператора << для типів перерахувань. Але для C++14/17, де більшість навчальних програм ще працює, власна функція-конвертер — це найчистіше рішення.

Проблема простору імен перерахувань

Тепер поговоримо про один з найважливіших обмежень незахищених (unscoped) перерахувань. Усі енумератори потрапляють у той самий простір імен, що і навколишній код. Це означає, що два різних enum не можуть мати енумераторів з однаковими іменами в одному файлі:

enum Fruit {
    YELLOW,  // значення 0
    RED,     // значення 1
    GREEN,   // значення 2
};

enum TrafficLight {
    RED,    // ❌ помилка! RED вже оголошено в Fruit
    YELLOW, // ❌ помилка! YELLOW вже оголошено в Fruit
    GREEN,  // ❌ помилка!
};

Компілятор видасть помилку «redefinition of enumerator» для кожного конфліктуючого імені.

Конвенція префіксів як часткове рішення

Класичний обхідний шлях — додавати до кожного енумератора префікс з назви перерахування:

enum Fruit {
    FRUIT_YELLOW,
    FRUIT_RED,
    FRUIT_GREEN,
};

enum TrafficLight {
    TRAFFIC_RED,
    TRAFFIC_YELLOW,
    TRAFFIC_GREEN,
};

Тепер конфліктів немає — кожне ім'я унікальне. Але погодьтеся: TRAFFIC_RED і FRUIT_YELLOW — це досить громіздко. І все одно: якщо хтось назве ще один enum з префіксом FRUIT_, конфлікт повернеться.

Це фундаментальна вада незахищених перерахувань. У наступній статті «Класи-перерахування (enum class ми розглянемо елегантне рішення цієї проблеми, введене у стандарті C++11.

Крім конфліктів імен, незахищені перерахування мають ще один недолік: неявна конвертація в int дозволяє порівнювати значення різних перерахувань між собою без жодного попередження від компілятора. Наприклад, FRUIT_YELLOW == TRAFFIC_RED (обидва мають значення 0) компілюється без помилок і повертає true — явна помилка, яка непомітно проникає в логіку програми. Це ще одна причина надавати перевагу enum class.

Практичні патерни використання enum

Патерн 1: Коди повернення функцій

Найпоширеніший і найцінніший застосунок перерахувань — замінити числові коди помилок на іменовані константи. Ми вже бачили це у вступі, розглянемо повніший приклад з обробкою на боці викликача:

#include <iostream>
#include <string>

enum FileResult {
    FILE_OK           = 0,
    FILE_ERR_NOT_FOUND = -1,
    FILE_ERR_PERM     = -2,
    FILE_ERR_CORRUPT  = -3,
};

FileResult loadConfig(const std::string& path) {
    // Симуляція: у реальному коді тут відкриваємо файл
    if (path.empty())
        return FILE_ERR_NOT_FOUND;
    if (path == "/root/secret.cfg")
        return FILE_ERR_PERM;
    return FILE_OK;
}

int main() {
    FileResult result = loadConfig("settings.cfg");

    switch (result) {
        case FILE_OK:
            std::cout << "Конфігурацію завантажено успішно.\n";
            break;
        case FILE_ERR_NOT_FOUND:
            std::cout << "Помилка: файл не знайдено.\n";
            break;
        case FILE_ERR_PERM:
            std::cout << "Помилка: недостатньо прав доступу.\n";
            break;
        case FILE_ERR_CORRUPT:
            std::cout << "Помилка: файл пошкоджений.\n";
            break;
    }
    return 0;
}
./main
$ ./main
Конфігурацію завантажено успішно.
Execution finished with exit code 0.

Зверніть увагу на підхід до обробки: замість порівняння result == -1 ми порівнюємо result == FILE_ERR_NOT_FOUND. Читач коду одразу розуміє семантику, не заглядаючи в документацію.

Патерн 2: Стани кінцевого автомата

Кінцевий автомат (finite state machine, FSM) — це поведінкова модель, де система може перебувати в одному з кінцевого набору станів і переходить між ними за певними правилами. Перерахування — ідеальний засіб для опису таких станів збереження стану.

Уявімо простий ігровий персонаж, який може стояти, ходити або бігти:

#include <iostream>

enum PlayerState {
    PLAYER_STATE_IDLE,    // стоїть
    PLAYER_STATE_WALKING, // іде
    PLAYER_STATE_RUNNING, // біжить
    PLAYER_STATE_DEAD,    // загинув
};

const char* stateName(PlayerState state) {
    switch (state) {
        case PLAYER_STATE_IDLE:    return "Стоїть";
        case PLAYER_STATE_WALKING: return "Іде";
        case PLAYER_STATE_RUNNING: return "Біжить";
        case PLAYER_STATE_DEAD:    return "Загинув";
        default:                   return "Невідомий стан";
    }
}

void handleInput(PlayerState& state, char key) {
    switch (state) {
        case PLAYER_STATE_IDLE:
            if (key == 'w') state = PLAYER_STATE_WALKING;
            break;
        case PLAYER_STATE_WALKING:
            if (key == 'r') state = PLAYER_STATE_RUNNING;
            if (key == 's') state = PLAYER_STATE_IDLE;
            break;
        case PLAYER_STATE_RUNNING:
            if (key == 's') state = PLAYER_STATE_IDLE;
            break;
        case PLAYER_STATE_DEAD:
            // мертвий персонаж не реагує на введення
            break;
    }
}

int main() {
    PlayerState state = PLAYER_STATE_IDLE;
    std::cout << "Стан гравця: " << stateName(state) << "\n";

    handleInput(state, 'w');
    std::cout << "Після натиснення W: " << stateName(state) << "\n";

    handleInput(state, 'r');
    std::cout << "Після натиснення R: " << stateName(state) << "\n";

    handleInput(state, 's');
    std::cout << "Після натиснення S: " << stateName(state) << "\n";
    return 0;
}
./main — State Machine
$ ./main
Стан гравця: Стоїть
Після натиснення W: Іде
Після натиснення R: Біжить
Після натиснення S: Стоїть
Execution finished with exit code 0.

Важливі моменти у структурі цього прикладу:

  • Функція handleInput приймає PlayerState&посилання на стан, щоб мати можливість змінювати його всередині функції. Відповідно до вивченого в «Посиланнях», саме посилання (а не копія) дає нам змогу модифікувати оригінальну змінну.
  • Кожен case у switch відповідає за всі переходи з відповідного стану. Додавання нового стану вимагає лише додавання нового case — структура коду залишається чіткою.
  • Стан PLAYER_STATE_DEAD є термінальним: персонаж у ньому не реагує на введення з клавіатури.

Патерн 3: Напрямки та режими

Перерахування ідеально підходять для будь-якого набору взаємовиключних значень — напрямків у просторі, режимів роботи пристрою, сторін компаса:

enum CompassDirection {
    COMPASS_NORTH,
    COMPASS_NORTHEAST,
    COMPASS_EAST,
    COMPASS_SOUTHEAST,
    COMPASS_SOUTH,
    COMPASS_SOUTHWEST,
    COMPASS_WEST,
    COMPASS_NORTHWEST,
    COMPASS_COUNT, // корисний «сторожовий» елемент: завжди == кількості напрямків
};

enum SortMode {
    SORT_ASCENDING,  // від меншого до більшого
    SORT_DESCENDING, // від більшого до меншого
    SORT_SHUFFLE,    // випадковий порядок
};

void sortArray(int* arr, int size, SortMode mode) {
    // Залежно від режиму — різна логіка сортування
    if (mode == SORT_ASCENDING) {
        // ... сортування за зростанням
    } else if (mode == SORT_DESCENDING) {
        // ... сортування за спаданням
    }
}

Зверніть увагу на трюк COMPASS_COUNT — технічно це просто наступне ціле число після останнього реального напрямку (= 8), але воно дозволяє легко отримати кількість елементів у перерахуванні без ручного підрахунку або магічних чисел.


Ключові властивості enum: огляд

📌 Оголошення та синтаксис

  • Ключове слово enum, ім'я типу, список енумераторів
  • Енумератори розділяються комами, блок завершується ;
  • Кінцева кома після останнього елемента дозволена (C++11+)
  • Оголошення типу не виділяє пам'яті

🔢 Значення та нумерація

  • Автонумерація з 0, +1 на кожен наступний
  • Можуть бути від'ємними та явними
  • Два енумератори можуть мати однакове значення (небезпечно!)
  • Розмір — зазвичай 4 байти (як int)

⚠️ Обмеження unscoped enum

  • Енумератори у тому самому просторі імен, що і enum
  • Конфлікти імен між різними enum
  • enum → int дозволено неявно
  • int → enum лише через static_cast
  • Порівняння різних enum компілюється без помилки!

✅ Найкращі застосування

  • Коди повернення замість магічних чисел
  • Стани кінцевого автомата
  • Напрямки, сторони, режими
  • Будь-який закритий набір іменованих станів

Практика

Рівень 1 — Базовий

Рівень 2 — Логіка та збірки

Рівень 3 — Архітектура


Резюме

Перерахування (enum) — це один із фундаментальних інструментів для написання читабельного та семантично точного коду на C++. Замість того щоб оперувати безіменними числами, ми даємо стану програми конкретну назву: FILE_ERR_NOT_FOUND замість -1, PLAYER_STATE_RUNNING замість 2.

Ключові висновки цього розділу:

  • enum оголошує новий тип із заздалегідь визначеним набором іменованих цілочисельних констант — енумераторів.
  • Автонумерація починається з 0, але будь-яке значення може бути задано явно (включно з від'ємними).
  • Незахищений enumunscoped: всі його енумератори потрапляють у простір імен навколишнього коду, що може призводити до конфліктів.
  • enum → int відбувається неявно; int → enum вимагає static_cast і несе ризик невизначеної поведінки.
  • Практичні патерни: коди повернення, стани стейт-машин, режими роботи.
  • У сучасному C++ (C++11+) для нових кодів рекомендується enum class — захищене перерахування з власним простором імен і суворою типізацією.
Наступним кроком є стаття «Класи-перерахування (enum class — ми побачимо, як enum class вирішує всі проблеми, описані в цьому розділі, і чому у сучасному C++ він є стандартним вибором.