Go 1.27. Дженерик-методы — то, чего не хватало
Девятнадцатого августа вышел 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++». Это устранение одной старой неудобности: операция наконец может жить там, где ей место — на типе, а не в пакете, забитом функциями-сиротами. Язык растёт медленно, зато не ломает прод — и такие релизы как раз показывают, почему это правильный путь.
Ждали долго — дождались. Батя доволен.