Представьте, что вам нужна функция «найти максимум из двух значений». Для int вы её пишете. Потом приходит задача с float64 — и вы пишете почти такую же, отличается только тип. Потом string — и снова копипаста. Тело функции одно и то же, меняется лишь тип данных, но компилятор Go до версии 1.18 не давал выразить это одним куском кода.
Дженерики (обобщённое программирование) решают ровно эту боль: написать алгоритм один раз, оставив тип как «дырку», которую компилятор заполнит при вызове. При этом — и это ключевое отличие от старого приёма с interface{} — типобезопасность остаётся: ошибки ловятся на компиляции, а не падают в рантайме.
В этой главе разберём интуицию, синтаксис параметров типа, ограничения (constraints) и кратко затронем дженерик-типы. Сразу честное предупреждение: браузерный playground на этой странице (интерпретатор yaegi) поддерживает дженерики частично. Простые примеры запустятся, но всё, что посложнее, мы дадим статичным блоком с разбором — и каждый раз будем это проговаривать.
Зайдём от проблемы. Допустим, нам нужен максимум из двух чисел. Есть три исторических способа, и у первых двух — заметные минусы.
Способ 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: interface{} (пустой интерфейс). Принимаем «что угодно», но тогда теряем типы: внутри нельзя написать a > b, придётся приводить типы вручную, а компилятор перестаёт нас страховать. Ошибку типа вы получите уже во время выполнения. Это шаг назад по безопасности.
Способ 3: дженерики. Описываем функцию с параметром типа T, и компилятор сам подставляет конкретный тип в месте вызова — сохраняя проверки. Это лучшее из двух миров: одно тело и полная типобезопасность.
Синтаксис дженериков добавляет к функции список параметров типа — в квадратных скобках, сразу после имени, перед обычными аргументами.
func Identity[T any](v T) T { return v}
Разберём по частям:
[T any] — мы вводим параметр типа с именем T. Имя любое (часто берут T, K, V, E), но по традиции — одна заглавная буква.
any — это ограничение: какие типы разрешено подставлять вместо T. any означает «любой» (это просто псевдоним для interface{}).
Дальше 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)) // int -> 9 fmt.Println(Max(2.5, 1.5)) // float64 -> 2.5 fmt.Println(Max("яблоко", "груша")) // string -> яблоко}
Один алгоритм честно отрабатывает для трёх разных типов, и компилятор на каждом вызове проверяет, что тип действительно входит в Ordered. Обратите внимание на третью строку: строки сравниваются лексикографически (по порядку символов), и «я» идёт после «г», поэтому максимум — «яблоко».
Этот пример мы дали статичным, без кнопки запуска. Браузерный playground (yaegi) спотыкается именно на ограничениях-объединениях (int | float64 | ...) с выводом типа — на вызове вроде Max(2.5, 1.5) он ошибается. В настоящем компиляторе Go всё работает штатно: проверьте локально через go run. А ниже, с ограничением comparable, playground справляется — там пример запускается.
В стандартной библиотеке есть готовый набор таких ограничений — пакет golang.org/x/exp/constraints (например, constraints.Ordered). Мы задаём Ordered вручную, чтобы не зависеть от внешнего пакета; в реальном проекте можно взять готовое.
Есть встроенное ограничение 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. На практике ~ ставят почти всегда — чтобы ограничение работало и с пользовательскими типами, а не только с «голыми» встроенными.
Тильда ~ и пользовательские типы — как раз та область, где браузерный playground (yaegi) ведёт себя ненадёжно. Поэтому пример с ~ мы оставили статичным, без кнопки запуска. В настоящем компиляторе Go он работает штатно — можете проверить локально через go run.
Параметры типа бывают не только у функций, но и у типов — структур, например. Это позволяет описать контейнер, который хранит элементы какого угодно типа, но при этом остаётся типобезопасным: «стопка строк» и «стопка чисел» — разные типы, и перепутать их нельзя.
Покажем статично — браузерный playground с дженерик-методами справляется хуже, так что разберём по коду:
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. Положить в такую стопку число компилятор не даст.
Самим писать обобщённые типы на старте приходится редко, но пользоваться ими вы будете постоянно: в новых версиях Go много обобщённых функций в пакетах slices и maps (в браузерном playground они, к слову, недоступны, но в реальном проекте — ваш ежедневный инструмент).
Дженерики — мощный инструмент, и из-за этого их легко переусердствовать. Простое правило: если у вас один-два конкретных типа и дублирование невелико — часто понятнее оставить как есть. Дженерики окупаются, когда:
вы пишете структуру данных или утилиту, которой реально всё равно на тип (контейнеры, Map/Filter/Reduce по срезу, кэш);
иначе пришлось бы копировать одно и то же тело под много типов.
А вот ради единственного вызова городить параметры типа не стоит — это усложняет чтение без выгоды. «Сначала напиши конкретно, обобщи, когда дублирование станет реальным» — здоровая стратегия.
Берут any, когда нужно сравнение. Под any нельзя писать a > b или a == b — компилятор не знает, что тип это умеет. Нужно сравнивать на порядок — задайте ограничение со списком типов (как Ordered); на равенство — возьмите comparable.
Забывают ~ в ограничении. Если перечислить int без тильды, пользовательский тип type ID int под ограничение не подойдёт. В большинстве случаев пишите ~int, ~string и т.д.
Путают comparable и «упорядочиваемость».comparable — это только ==/!=, но не</>. Для «больше/меньше» нужен явный список типов, поддерживающих порядок.
Тащат дженерики туда, где хватит обычной функции. Если тип ровно один — обобщение лишь добавляет шума. Обобщайте по факту дублирования, а не на всякий случай.
Ждут, что всё запустится в браузерном playground. Здесь интерпретатор поддерживает дженерики частично: простые функции — да, дженерик-типы с методами и тонкости с ~ — ненадёжно. Сложные примеры проверяйте локально через go run.
Теперь у вас есть интуиция: дженерики — это способ написать алгоритм один раз, оставив тип параметром, и не потерять при этом проверки компилятора. Вы знаете про параметры типа [T any], про ограничения (comparable, интерфейсы со списком типов, тильду ~) и в общих чертах — про дженерик-типы.
Ограничения — это особый вид интерфейсов, так что если глава про интерфейсы ещё свежа в памяти, связь видна напрямую. А обобщённые функции из стандартной библиотеки активно работают со срезами и map — материал главы про структуры и коллекции ляжет на дженерики как родной.