Введение: почему 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 будет равно