Представьте, что вам нужна функция «найти максимум из двух значений». Для int вы её пишете. Потом приходит задача с float64 — и вы пишете почти такую же, отличается только тип. Потом string — и снова копипаста. Тело функции одно и то же, меняется лишь тип данных, но компилятор Go до версии 1.18 не давал выразить это одним куском кода.
Дженерики (обобщённое программирование) позволяют написать алгоритм один раз, оставив тип «пустым местом», которое компилятор заполнит при вызове. В отличие от приёма с any из главы об интерфейсах, проверки типов при этом сохраняются: ошибка обнаружится при сборке, а не во время работы программы.
Глава опирается на функции, срезы и интерфейсы. Самим писать обобщённый код в начале карьеры приходится нечасто, а вот читать его — постоянно: обобщёнными сделаны пакеты slices и maps стандартной библиотеки. Поэтому главная цель — научиться читать сигнатуры вроде func Contains[T comparable](xs []T, target T) bool. В курсе по конкурентности дженерики не понадобятся, и упражнение этой главы можно отложить.
Зайдём от проблемы. Допустим, нам нужен максимум из двух чисел. Есть три исторических способа, и у первых двух — заметные минусы.
Способ 1: дублирование. Пишем отдельную функцию под каждый тип.
func MaxInt(a, b int) int { if a > b { return a } return b}func MaxFloat(a, b float64) float64 { if a > b { return a } return b}
Тело идентично, а функций две. Добавится новый тип — будет три. Это шумно и легко рассинхронизировать.
Способ 2: any (пустой интерфейс). Принимаем «что угодно», но тогда теряем типы: внутри нельзя написать a > b, придётся доставать значения через type assertion, а компилятор перестаёт нас страховать. Передадите по ошибке строку и число — узнаете об этом только во время работы программы.
Способ 3: дженерики. Описываем функцию с параметром типа T, и компилятор сам подставляет конкретный тип в месте вызова, сохраняя все проверки. Тело одно, и типы проверяются, как в первом способе.
Синтаксис дженериков добавляет к функции список параметров типа — в квадратных скобках, сразу после имени, перед обычными аргументами.
func Identity[T any](v T) T { return v}
Разберём по частям:
[T any] — мы вводим параметр типа с именем T. Имя любое (часто берут T, K, V, E), но по традиции — одна заглавная буква.
any — это ограничение: какие типы разрешено подставлять вместо T. any означает «любой» — это тот же пустой интерфейс из главы об интерфейсах.
Дальше T используется как обычный тип: и в аргументе v T, и в типе результата T.
Вызвать можно явно указав тип в скобках — Identity[int](5) — но чаще тип выводится из аргумента автоматически:
package mainimport "fmt"func Identity[T any](v T) T { return v}func main() { fmt.Println(Identity(42)) // T выведен как int fmt.Println(Identity("привет")) // T выведен как string fmt.Println(Identity(3.14)) // T выведен как float64}
Одна функция — три разных типа, и ни одного приведения. Компилятор сам понял, что подставить вместо T, по типу переданного значения.
any говорит «подойдёт любой тип». Но часто этого мало. Вернёмся к Max: внутри нам нужно сравнить a > b. А оператор > определён не для всех типов — например, две структуры так сравнивать нельзя. Значит, надо сообщить компилятору: «T — не любой, а только такой, который умеет в >».
Это и есть ограничение (constraint). Технически ограничение — это интерфейс, но не совсем обычный: он описывает не методы, а множество допустимых типов. Самый прямой способ — перечислить эти типы.
Объявим интерфейс, который перечисляет, какие типы мы разрешаем. Это полноценный список «или то, или то»:
type Ordered interface { int | int64 | float64 | string}
Читается как «Ordered — это int, либо int64, либо float64, либо string». Для всех этих типов оператор > имеет смысл, поэтому внутри функции мы вправе им пользоваться. Теперь напишем Max:
package mainimport "fmt"type Ordered interface { int | int64 | float64 | string}func Max[T Ordered](a, b T) T { if a > b { return a } return b}func main() { fmt.Println(Max(3, 9)) // T = int fmt.Println(Max(2.5, 1.5)) // T = float64 fmt.Println(Max("яблоко", "груша")) // T = string}
Вывод:
92.5яблоко
Один алгоритм отработал для трёх разных типов, и компилятор на каждом вызове проверил, что тип действительно входит в Ordered. Попробуйте вызвать Max(true, false): сборка упадёт, потому что bool в списке нет. Строки сравниваются побайтово; для русских букв это совпадает с алфавитным порядком (кроме «ё»), поэтому «яблоко» больше «груши».
Писать Ordered руками в рабочем коде не нужно. С Go 1.21 в стандартной библиотеке есть готовое ограничение cmp.Ordered (пакет cmp), а для двух чисел — встроенные функции max и min. Мы объявили ограничение сами, чтобы было видно, как оно устроено.
Есть встроенное ограничение comparable — оно разрешает типы, которые поддерживают операторы == и !=. Это не то же самое, что >/<: на равенство можно сравнивать гораздо больше типов (числа, строки, булевы, указатели), а вот «больше/меньше» — нет.
comparable нужен, например, чтобы тип можно было использовать как ключ map или искать в срезе. Вот функция «есть ли элемент в срезе»:
package mainimport "fmt"func Contains[T comparable](xs []T, target T) bool { for _, x := range xs { if x == target { return true } } return false}func main() { nums := []int{2, 4, 6, 8} fmt.Println(Contains(nums, 6)) // true fmt.Println(Contains(nums, 5)) // false words := []string{"go", "rust", "zig"} fmt.Println(Contains(words, "rust")) // true}
Внутри мы пользуемся ==, и comparable гарантирует, что для любого подставленного T это корректно. Передать сюда тип, который сравнивать на равенство нельзя (скажем, срез), компилятор просто не позволит.
Есть тонкость: формально под comparable подходят и интерфейсные типы, но если внутри интерфейса окажется несравнимое значение (например, срез), сравнение == может вызвать панику уже в рантайме. На практике для джуна это редкий случай — детальный разбор выходит за рамки главы.
Есть нюанс. В Go можно объявить свой тип на базе существующего:
type Age int
Age — отдельный тип, хотя устроен он как int. И вот загвоздка: если ограничение перечисляет ровно int, то Age под него не подойдёт, ведь формально это уже не int, а Age.
Чтобы охватить и базовый тип, и всех его «потомков», в ограничении ставят тильду ~:
type Number interface { ~int | ~float64}
~int читается как «int и любой тип, в основе которого лежит int». Теперь под Number подойдёт и int, и наш Age:
package mainimport "fmt"type Number interface { ~int | ~float64}type Age intfunc Sum[T Number](xs []T) T { var total T // нулевое значение T: 0 для любого числа for _, x := range xs { total += x } return total}func main() { fmt.Println(Sum([]int{1, 2, 3})) fmt.Println(Sum([]float64{1.5, 2})) fmt.Println(Sum([]Age{20, 22}))}
Вывод:
63.542
Уберите тильду у ~int и запустите снова: третий вызов перестанет собираться, потому что Age — не int. На практике ~ ставят почти всегда, чтобы ограничение работало и с пользовательскими типами.
Параметры типа бывают не только у функций, но и у типов — структур, например. Это позволяет описать контейнер, который хранит элементы какого угодно типа, но при этом остаётся типобезопасным: «стопка строк» и «стопка чисел» — разные типы, и перепутать их нельзя.
package mainimport "fmt"// Stack — стопка элементов типа T (last in, first out).type Stack[T any] struct { items []T}func (s *Stack[T]) Push(v T) { s.items = append(s.items, v)}func (s *Stack[T]) Pop() (T, bool) { var zero T if len(s.items) == 0 { return zero, false // пусто — отдаём нулевое значение T } last := s.items[len(s.items)-1] s.items = s.items[:len(s.items)-1] return last, true}func main() { var s Stack[string] // стопка именно строк s.Push("a") s.Push("b") v, ok := s.Pop() fmt.Println(v, ok) // b true}
Несколько важных деталей:
Stack[T any] — параметр типа объявляется у самого типа, в его заголовке.
В методах получатель пишется как *Stack[T] — мы «протаскиваем» тот же T внутрь методов.
var zero T — частый приём: так получают нулевое значение параметра типа, не зная заранее, что это (для чисел — 0, для строк — "", для указателей — nil). Удобно возвращать из методов, когда «ничего нет».
Stack[string] при объявлении переменной фиксирует T = string. Положить в такую стопку число компилятор не даст.
Самим писать обобщённые типы на старте приходится редко, а пользоваться обобщёнными функциями вы будете постоянно: slices.Contains, slices.Sort, slices.Index, maps.Keys из стандартной библиотеки устроены именно так. Они подключаются обычным import "slices" и import "maps", в примерах с кнопкой тоже.
Дженериками легко увлечься. Простое правило: если у вас один-два конкретных типа и дублирование невелико — часто понятнее оставить как есть. Дженерики окупаются, когда:
вы пишете структуру данных или утилиту, которой реально всё равно на тип (контейнеры, Map/Filter/Reduce по срезу, кэш);
иначе пришлось бы копировать одно и то же тело под много типов.
А вот ради единственного вызова городить параметры типа не стоит — это усложняет чтение без выгоды. «Сначала напиши конкретно, обобщи, когда дублирование станет реальным» — здоровая стратегия.
Берут any, когда нужно сравнение. Под any нельзя писать a > b или a == b — компилятор не знает, что тип это умеет. Нужно сравнивать на порядок — задайте ограничение со списком типов (как Ordered); на равенство — возьмите comparable.
Забывают ~ в ограничении. Если перечислить int без тильды, пользовательский тип type ID int под ограничение не подойдёт. В большинстве случаев пишите ~int, ~string и т.д.
Путают comparable и «упорядочиваемость».comparable — это только ==/!=, но не</>. Для «больше/меньше» нужен явный список типов, поддерживающих порядок.
Тащат дженерики туда, где хватит обычной функции. Если тип ровно один — обобщение лишь добавляет шума. Обобщайте по факту дублирования, а не на всякий случай.
Напишите First[T any](xs []T) (T, bool): для непустого среза она возвращает первый элемент и true, для пустого — нулевое значение типа T и false. Проверьте []int{4, 9}, []string{"Go"} и []string{}. Объясните, почему ограничение comparable не требуется.
Результаты — 4 true, Go true и пустая строка с false:
func First[T any](xs []T) (T, bool) { if len(xs) == 0 { var zero T return zero, false } return xs[0], true}
Здесь нет сравнения элементов друг с другом, поэтому подходит any. Проверка длины защищает от выхода за границы на xs[0]. Чтобы пустая строка была видна в выводе, печатайте результат через %q.
Обратите внимание на вызов с пустым срезом: First([]string{}) работает, а First(nil) не соберётся. Из nil компилятор не может понять, что подставить вместо T, и попросит указать тип явно: First[string](nil).
Пока весь наш код помещался в один файл. Дальше — «Пакеты и модули»: разложим код по нескольким файлам и пакетам и напишем первый тест. Понадобится локальный Go и учебная папка из первой главы.