Menu

Enum в Golang: перечисления через const и iota

Ключевого слова enum в Go нет. Здесь показана идиоматичная замена: именованный тип плюс блок const с iota, а также как добавить String(), проверку, разбор строки, битовые флаги и поддержку JSON.

На этой странице есть исполняемые редакторы: меняйте, запускайте и сразу видите результат.

Ключевого слова enum в Go нет. Перечисление собирается из двух частей: именованного типа и блока const со значениями этого типа, пронумерованными через iota.

Sunday равен 0, а каждая следующая строка на единицу больше. Именно именованный тип Weekday делает это перечислением, а не списком чисел: isWeekend в своей сигнатуре сообщает, что ожидает, а к типу можно добавить методы. Вывод печатает 1 5 6, потому что пока ничто не говорит Go, как показывать Weekday текстом. Об этом ниже.

Как работает iota

iota это счётчик, который компилятор предоставляет внутри блока const. Два правила объясняют все приёмы на его основе:

  1. iota равен номеру текущей строки в блоке, начиная с 0, и сбрасывается в 0 в каждом новом блоке const.
  2. Строка без = выражение повторяет выражение и тип предыдущей строки, вычисленные с новым iota.

Поэтому Monday выше это сокращение для Monday Weekday = iota, где iota уже равен 1. Раз выражение повторяется, оно может быть любым константным выражением, а не только iota:

iota считает строки, а не имена: две константы в одной строке делят одно значение iota, как показывают X и Y.

Начать с 1, и почему этого можно не делать

Переменная типа-перечисления, которой никто не присвоил значение, хранит 0, своё нулевое значение. Если 0 это настоящее значение вроде Sunday, нельзя отличить «пользователь выбрал воскресенье» от «поле так и не заполнили». Три распространённых решения:

Вариант 1 самый частый в продакшен-коде, и перечисления Go, сгенерированные из protobuf, следуют ему (..._UNSPECIFIED = 0). Нулевое значение тогда означает что-то честное.

Пропуск значений

Пустой идентификатор _ расходует значение iota, не создавая имени. С его помощью оставляют пропуски, например чтобы совпасть с числами из протокола или убрать значение, не перенумеровывая остальные:

type Opcode byte

const (
	OpContinue Opcode = iota // 0
	OpText                   // 1
	OpBinary                 // 2
	_                        // 3, reserved
	_                        // 4, reserved
	_                        // 5, reserved
	_                        // 6, reserved
	_                        // 7, reserved
	OpClose                  // 8
	OpPing                   // 9
	OpPong                   // 10
)

Когда числа зафиксированы внешней спецификацией, как у этих опкодов WebSocket, явная запись (OpClose Opcode = 8) часто понятнее, чем подсчёт пропусков. iota нужен для значений, конкретные номера которых вам безразличны.

Никогда не переставляйте и не вставляйте строки в список iota, если его числа сохраняются в базе данных, файле или отправляются по сети. Добавленная в середину строка сдвигает все значения после неё. Новые значения добавляйте в конец или задавайте числа явно.

Добавляем метод String

Дайте типу метод String() string, и fmt будет использовать его для %v, %s и Println:

В этом методе важны две детали:

  • Проверка границ. Без неё Weekday(9).String() запаникует с выходом индекса за границы, и рано или поздно это случится, потому что ничто не мешает вызывающему коду создать Weekday(9).
  • int(d) внутри Sprintf. Форматировать сам d через %d можно, но форматирование через %v снова вызовет String(), и рекурсия продолжится до переполнения стека.

%d по-прежнему печатает число, так что доступны обе формы: Wednesday is day 3.

Генерация String через stringer

Для длинных списков метод за вас напишет утилита stringer:

//go:generate go run golang.org/x/tools/cmd/stringer@latest -type=Weekday
go generate ./...

Она создаёт weekday_string.go с компактной реализацией String(), а также проверку на этапе компиляции, которая ломает сборку, если константы изменились, а код не перегенерировали. Флаг -linecomment берёт имя из комментария в конце строки, что удобно для имён с пробелами.

Проверка значений

Перечисление в Go не замкнуто. Любое значение базового типа преобразуется в него, а нетипизированные константы преобразуются неявно:

var d Weekday = 42     // compiles
d = Weekday(userInput) // compiles

Поэтому проверяйте значения, пришедшие извне вашего кода (JSON, базы данных, флаги, другие пакеты):

Неэкспортированный маркер colorCount в конце блока сохраняет корректность IsValid при добавлении новых цветов, потому что он всегда стоит на единицу дальше последнего настоящего значения.

switch по перечислению

Перечисления обычно обрабатываются через switch. Go не проверяет, что switch покрывает все значения, поэтому добавьте default, который сообщает о неожиданности:

func (c Color) Hex() string {
	switch c {
	case Red:
		return "#ff0000"
	case Green:
		return "#00ff00"
	case Blue:
		return "#0000ff"
	default:
		return "#000000"
	}
}

Сторонний линтер exhaustive (входит в golangci-lint) сообщает о switch по типам-перечислениям, где пропущен вариант, и это даёт большую часть того, что в других языках дают проверки полноты перечислений.

Перечисления битовых флагов

Когда значения комбинируются, как права доступа, используйте по одному биту на значение через 1 << iota:

| объединяет флаги, & проверяет их, а &^ (оператор AND NOT в Go) сбрасывает. Здесь правильно выбрать беззнаковый базовый тип: uint8 вмещает 8 флагов, uint64 вмещает 64.

Строковые перечисления

Когда значение всё равно хранится или передаётся текстом, тип на основе строки избавляет от слоя преобразований:

type Env string

const (
	EnvDev     Env = "dev"
	EnvStaging Env = "staging"
	EnvProd    Env = "prod"
)

Значения печатаются и сериализуются читаемо без метода String(), а столбец базы данных хранит "prod", а не число, зависящее от порядка объявления. Компромиссы: сравнения становятся сравнениями строк, битовые флаги невозможны, а проверка всё равно на вас, ведь Env("banana") тоже компилируется.

Перечисления и JSON

Целочисленное перечисление по умолчанию сериализуется как число. Чтобы читать и писать имена, реализуйте encoding.TextMarshaler и encoding.TextUnmarshaler. encoding/json использует их для значений и для ключей мап:

У MarshalText получатель-значение, поэтому он работает и для Level, и для *Level; UnmarshalText нужен получатель-указатель, потому что он меняет значение. Эти же два метода позволяют типу работать с TextVar из пакета flag и с большинством библиотек конфигурации.

Подводные камни

  • Неявное преобразование литералов. Функция, принимающая Weekday, примет и нетипизированную константу 42. Отвергаются только типизированные значения другого типа.
  • Тип не указан в первой строке. В const ( Red = iota; Green; Blue ) все три это нетипизированные целочисленные константы, а не значения Color, так что методы Color к ним не относятся. Пишите Red Color = iota, чтобы повторяемое выражение несло тип.
  • Перестановка сохраняемых перечислений. Вставка значения в середину блока iota молча меняет числа, которые уже где-то сохранены.
  • Рекурсия в String. Внутри String() никогда не форматируйте получатель через %v или %s. Сначала преобразуйте его в базовый тип.

Часто задаваемые вопросы

Есть ли в Go перечисления (enum)?

Как возможности языка нет. Ключевого слова enum не существует. Идиоматичная замена: именованный тип плюс блок типизированных констант, обычно пронумерованных через iota:

type Color int

const (
	Red Color = iota
	Green
	Blue
)

Тип даёт читаемые сигнатуры и место для методов вроде String(). Он не мешает написать Color(42), поэтому значения, пришедшие извне, нужно проверять.

Что такое iota в Go?

iota это предопределённый идентификатор, равный номеру текущей строки (спецификации константы) внутри блока const, начиная с 0. В каждом новом блоке const он сбрасывается в 0. Если в строке нет выражения, Go повторяет предыдущее выражение со следующим значением iota, поэтому Red = iota; Green; Blue даёт 0, 1, 2.

Как сделать, чтобы iota начинался с 1?

Напишите в первой строке First Kind = iota + 1 или пропустите ноль через пустой идентификатор: _ = iota, затем First. Многие Go-разработчики вместо этого оставляют 0 и называют его Unknown или Invalid, чтобы неинициализированная переменная (нулевое значение которой 0) явно не была настоящим вариантом.

Как вывести enum строкой в Go?

Дайте типу метод String() string. fmt вызывает его для %v, %s и Println, поэтому fmt.Println(Green) печатает Green, а не 1. Метод можно написать вручную через switch или массив либо сгенерировать командой go run golang.org/x/tools/cmd/stringer@latest -type=Color.

Как преобразовать строку в enum в Go?

Напишите функцию разбора, которая ищет строку, обычно в map[string]Color или через switch, и возвращает ошибку для неизвестного ввода: func ParseColor(s string) (Color, error). Если реализовать UnmarshalText с той же логикой, JSON, флаги и загрузчики конфигурации будут использовать её автоматически.

Coddy programming languages illustration

Учитесь программировать с Coddy

НАЧАТЬ