> Go Батя

Go 1.27. Дженерик-методы — то, чего не хватало

#go #разработка

Девятнадцатого августа вышел Go 1.27 — и в нём наконец появились generic-методы. Ещё в Go 1.18, когда в язык добавляли параметры типа, методам их запретили: мол, с интерфейсами не сходится, а обходные пути есть. Теперь всё изменилось.

«Нельзя» — это было надолго

В Go 1.18 добавили дженерики: параметры типа получили функции и типы. Методы — нет. В документации Go годами висела честная фраза: «We do not anticipate that Go will ever add generic methods». Логика была такая: generic-методы нельзя нормально совместить с интерфейсами, а раз нельзя сделать целиком — не будем делать вообще.

На практике это означало вот что. Есть тип. Есть операция, которая логически принадлежит этому типу. Приписать операцию типу нельзя — она уезжает в пакетную функцию:

type List[E any] []E

// До 1.27 — только так: функция в масштабе всего пакета.
func MapList[E, R any](l List[E], f func(E) R) List[R] {
	out := make(List[R], 0, len(l))
	for _, v := range l {
		out = append(out, f(v))
	}
	return out
}

users := List[User]{alice, bob}
emails := MapList(users, func(u User) string { return u.Email })

Работает? Работает. Читается? Так себе. Операция про список живёт не у списка, а где-то в пакете, рядом с десятком таких же MapX, FilterX, FoldX. Найти её в автодополнении по точке нельзя, в документации типа — тоже. И это ещё простой случай: чем больше типов, тем больше функций-сирот.

Что теперь можно

Go 1.27 разрешает объявлять параметры типа прямо у метода — между ресивером и списком аргументов:

// После 1.27 — метод на самом типе.
func (l List[E]) Map[R any](f func(E) R) List[R] {
	out := make(List[R], 0, len(l))
	for _, v := range l {
		out = append(out, f(v))
	}
	return out
}

func (l List[E]) Filter(pred func(E) bool) List[E] { /* ... */ }

// Читается как обычный чейнинг:
result := users.
	Map(func(u User) string { return u.Email }).
	Filter(nonEmpty)

Параметр R — собственный параметр метода, он не имеет отношения к параметру E ресивера. Именно это и было запрещено: раньше метод мог пользоваться только тем, что объявлено у типа, а свои параметры завести не мог.

Второе важное: ресивер не обязан быть дженериком. Обычная структура вполне может иметь дженерик-метод. Вот пример, который ближе к реальному коду, чем List:

type Store struct {
	data map[string][]byte
}

func (s *Store) Get[T any](key string) (T, error) {
	var zero T
	raw, ok := s.data[key]
	if !ok {
		return zero, fmt.Errorf("store: ключ %q не найден", key)
	}
	var v T
	if err := json.Unmarshal(raw, &v); err != nil {
		return zero, fmt.Errorf("store: %q → %T: %w", key, zero, err)
	}
	return v, nil
}

port, err := s.Get[int]("port")     // тип приходится называть руками:
name, err := s.Get[string]("name")  // в аргументах его нет, выводить не из чего

Обратите внимание: Store — самая обычная структура, дженериков у неё нет. Раньше Get[T] тут был невозможен в принципе, только func Get[T any](s *Store, key string) (T, error). Такие функции я обычно и находил через полгода после того, как забывал про их существование.

Стандартная библиотека уже пользуется

math/rand/v2 — наглядный пример, который приводят в release notes. Пакетная функция N[Int intType](Int) Int там была с 1.22, а в 1.27 к ней добавился метод на источнике:

r := rand.New(rand.NewPCG(1, 2))

n := r.N(100)              // int
d := r.N(2 * time.Second)  // time.Duration

Один метод на все целочисленные типы и длительности вместо IntN, Int32N, Int64N и их родни. Теперь граница «что принадлежит типу, а что пакету» совпадает со здравым смыслом.

Чего по-прежнему нельзя — интерфейсы

Здесь придётся принять ограничение сразу, иначе будет больно при проектировании.

Методы интерфейса не могут объявлять параметры типа. Вот так не скомпилируется:

type Mapper interface {
	Map[U any](f func(int) U) any // interface method must have no type parameters
}

Generic-метод не может реализовать метод интерфейса. То есть подставить List.Map под существующий интерфейс — нельзя.

Причина не в лени и не в политике. Go специализирует код под конкретный тип на этапе компиляции: для каждого набора типовых аргументов генерируется своя версия. У конкретного типа компилятор видит все места вызова — он может это сделать. У интерфейса — не видит: значение уже упаковано в интерфейс, его method set зафиксирован, а какой тип подставят в рантайме — неизвестно. Диспатчить некуда. Отсюда и правило: дженерик-методы — для конкретных типов, где вызов известен на компиляции.

Практический вывод: полиморфизма «через интерфейс» от этого релиза не появилось. Дженерик-метод — не виртуальный метод. Хотите полиморфизм — интерфейс, как и раньше, с конкретными методами.

Ещё пара мелочей из 1.27

Не одним дженериками релиз интересен:

  • Ключ в литерале структуры теперь может быть любым корректным селектором поля, а не только именем поля верхнего уровня. Прощай, боль с вложенными и встроенными структурами: можно сразу заполнять продвинутое поле, не расписывая вложенный тип.
  • Вывод типовых аргументов функций обобщили на все контексты, где generic-функция присваивается совместимому функциональному типу: композитные литералы, конверсии, отправка в канал. Раньше в этих местах приходилось писать типовые аргументы руками — теперь часть ошибок «cannot infer» просто исчезла.
  • Небольшое про производительность: компилятор генерирует специализированные по размеру функции аллокации, мелкие аллокации (до 80 байт) подешевели до 30%. В реальной программе это около 1% — но бесплатно, ценой ~60 КБ к бинарнику.
  • Из грустного: Go 1.27 требует macOS 13 Ventura или новее, старые версии больше не поддерживаются.

Переписывать ли код?

Спокойно. Во-первых, это обратно совместимое изменение: старые пакетные функции продолжают работать, Go 1 promise никто не отменял. Во-вторых, массовый рефакторинг «всё в методы» — это лишний шум в диффах и повод для споров на ревью.

Я бы держал одно простое правило: если операция читается как «x.СделайЧто-то()» — это метод; если это утилита без владельца (работает с любыми типами, не принадлежит ни одному) — это функция. Map у списка — метод. Retry над произвольной функцией — функция. Внутри своего пакета, где API никто снаружи не дёргает, тем более: если тип экспортируется и операция его — прячь в метод, автодополнение скажет спасибо.

В пет-проектах (у меня это, например, llmagent, где легаси нет и ломать чужие API не нужно) можно не стесняться: я такие пакетные хелперы выношу в методы по мере того, как трогаю соответствующий файл. Без фанатизма, без отдельного «эпика на рефакторинг».

Итог

Дженерик-методы — не новая парадигма и не «Go стал как C++». Это устранение одной старой неудобности: операция наконец может жить там, где ей место — на типе, а не в пакете, забитом функциями-сиротами. Язык растёт медленно, зато не ломает прод — и такие релизы как раз показывают, почему это правильный путь.

Ждали долго — дождались. Батя доволен.