GraphLMS

Основы
Начать
Глава 10 · Основы~12 мин чтения

Введение в дженерики

О чём глава

Представьте, что вам нужна функция «найти максимум из двух значений». Для 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, и компилятор сам подставляет конкретный тип в месте вызова — сохраняя проверки. Это лучшее из двух миров: одно тело и полная типобезопасность.

Параметры типа: [T any]

Синтаксис дженериков добавляет к функции список параметров типа — в квадратных скобках, сразу после имени, перед обычными аргументами.

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 main
 
import "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, по типу переданного значения.

Ограничения (constraints): что разрешено типу

any говорит «подойдёт любой тип». Но часто этого мало. Вернёмся к Max: внутри нам нужно сравнить a > b. А оператор > определён не для всех типов — например, две структуры так сравнивать нельзя. Значит, надо сообщить компилятору: «T — не любой, а только такой, который умеет в >».

Это и есть ограничение (constraint). Технически ограничение — это интерфейс, но не совсем обычный: он описывает не методы, а множество допустимых типов. Самый прямой способ — перечислить эти типы.

Своё ограничение через интерфейс с типами

Объявим интерфейс, который перечисляет, какие типы мы разрешаем. Это полноценный список «или то, или то»:

type Ordered interface {
	int | int64 | float64 | string
}

Читается как «Ordered — это int, либо int64, либо float64, либо string». Для всех этих типов оператор > имеет смысл, поэтому внутри функции мы вправе им пользоваться. Теперь напишем Max:

package main
 
import "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 — оно разрешает типы, которые поддерживают операторы == и !=. Это не то же самое, что >/<: на равенство можно сравнивать гораздо больше типов (числа, строки, булевы, указатели), а вот «больше/меньше» — нет.

comparable нужен, например, чтобы тип можно было использовать как ключ map или искать в срезе. Вот функция «есть ли элемент в срезе»:

package main
 
import "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 main
 
import "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 — материал главы про структуры и коллекции ляжет на дженерики как родной.