Enums в Go: идиоматичный способ и магия под капотом

Онлайн-тренажер Python для начинающих

Изучайте Python легко и без перегрузки теорией. Решайте практические задачи с автоматической проверкой, получайте подсказки на русском языке и пишите код прямо в браузере — без необходимости что-либо устанавливать.

Начать курс

Введение: почему Go не говорит «enum»?

Если вы пришли в Go из Python, Java или C#, вы наверняка испытали лёгкий культурный шок: в Go нет ключевого слова enum. Никаких встроенных перечислений с автоматической нумерацией, никаких методов .name() или .valueOf(). Только вы, компилятор и… константы.

Вопрос «What is an idiomatic way of representing enums in Go?» — один из самых популярных на Stack Overflow в разделе Golang (тысячи просмотров). И это не случайно. Каждый новичок рано или поздно упирается в стену: «Как мне сделать enum так, чтобы это было по-гошному?».

В этой статье мы не просто дадим ответ. Мы заглянем под капот: как Go обрабатывает константы, что такое iota и почему компилятор не считает два разных int значения разными типами. А в конце вы узнаете, как сделать enum безопасным, удобным и красивым.

Проблема: почему простые константы — это плохо?

Первое, что приходит в голову — объявить несколько констант:

const (
    StatusActive   = 1
    StatusInactive = 2
    StatusPending  = 3
)

Вроде бы работает. Но есть нюанс: любая функция, принимающая int, проглотит значение 999 или -42. Компилятор не скажет ни слова. Это не перечисление — это просто числа с именами.

Настоящий enum должен:

  • Ограничивать набор допустимых значений;
  • Быть типобезопасным (нельзя передать int туда, где ожидается статус);
  • Быть читаемым и легко расширяемым.

Go решает эту задачу идиоматично: через пользовательские типы и iota.

Идиоматичный способ: тип + iota

Вот как выглядит канонический ответ из того самого Stack Overflow:

type Status int

const (
    StatusActive   Status = iota // 0
    StatusInactive               // 1
    StatusPending                // 2
    StatusDeleted                // 3
)

Что здесь произошло?

  • type Status int — мы создали новый именованный тип. Да, под капотом это int, но для компилятора Status и int — разные вещи.
  • iota — это генератор последовательных целых чисел, который работает только внутри const-блока. Каждая новая строка увеличивает значение на 1.

Теперь функция, принимающая Status, не скомпилируется, если вы попытаетесь передать ей обычный int:

func Process(s Status) {
    // ...
}

func main() {
    Process(StatusActive) // OK
    Process(1)            // Ошибка компиляции! Cannot use 1 (type int) as type Status
}

Это и есть типобезопасность — первая и главная причина использовать пользовательские типы для enum.

Почему iota, а не ручная нумерация?

Представьте, что вы добавили новый статус в середину списка. При ручной нумерации вам пришлось бы переписывать все последующие значения. iota делает это автоматически. Это не просто удобно — это идиоматично.

Под капотом: как Go видит enum

Давайте разберёмся, что на самом деле происходит на этапе компиляции.

1. iota — это не runtime

iota не существует во время выполнения программы. Это инструмент препроцессора (точнее, часть пакетного компилятора). На этапе компиляции все iota заменяются на конкретные числа. В результирующем бинарнике вы увидите просто 0, 1, 2.

Проверить это можно с помощью go tool compile или просто посмотрев на ассемблерный код:

// Исходный код
const (
    A Status = iota
    B
)

// После компиляции (упрощённо)
// A = 0, B = 1

2. Тип Status — это отдельный мир

Хотя Status базируется на int, Go рассматривает их как разные типы. Это достигается за счёт номинальной типизации (nominal typing). В отличие от структурного подхода (как в TypeScript), здесь важно имя типа, а не его структура.

Это значит, что вы можете безопасно перегружать функции и методы для своего типа, не боясь конфликтов.

3. Методы для enum

Поскольку Status — полноценный тип, вы можете добавлять к нему методы. Например, для получения строкового представления:

func (s Status) String() string {
    switch s {
    case StatusActive:
        return "active"
    case StatusInactive:
        return "inactive"
    case StatusPending:
        return "pending"
    default:
        return "unknown"
    }
}

Теперь fmt.Println(StatusActive) выведет active, а не 0. Это один из самых полезных паттернов для enum в Go.

Продвинутые техники: enum с произвольными значениями

Иногда вам нужно, чтобы enum имел не последовательные, а конкретные значения (например, HTTP-статусы или битовые маски). iota умеет работать с выражениями:

type Flag uint8

const (
    FlagRead  Flag = 1 << iota // 1
    FlagWrite                    // 2
    FlagExec                     // 4
)

Или для пропуска значений:

type Weekday int

const (
    Monday    Weekday = iota // 0
    Tuesday                  // 1
    Wednesday                // 2
    Thursday                 // 3
    Friday                   // 4
    _                        // пропускаем субботу
    Sunday                   // 6
)

Важно: символ _ (пустой идентификатор) говорит компилятору «пропустить это значение». Счётчик iota всё равно увеличивается, но константа не создаётся.

Подводные камни: что нужно знать

Идиоматичный способ не лишён нюансов. Вот основные грабли, на которые наступают новички.

1. Нулевое значение — это всегда первый элемент

В Go любая переменная инициализируется нулевым значением. Для Status (тип int) это 0. Если вы не задали константу с iota = 0, то var s Status будет равно

Блоги

Рекомендаций по книгам