Все главы учебника
Содержание учебника

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

12 мин чтенияКонтент v0.12.0

О чём глава

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

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

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

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 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)) // T = int
 
	fmt.Println(Max(2.5, 1.5)) // T = float64
 
	fmt.Println(Max("яблоко", "груша")) // T = string
}

Вывод:

9
2.5
яблоко

Один алгоритм отработал для трёх разных типов, и компилятор на каждом вызове проверил, что тип действительно входит в Ordered. Попробуйте вызвать Max(true, false): сборка упадёт, потому что bool в списке нет. Строки сравниваются побайтово; для русских букв это совпадает с алфавитным порядком (кроме «ё»), поэтому «яблоко» больше «груши».

Писать Ordered руками в рабочем коде не нужно. С Go 1.21 в стандартной библиотеке есть готовое ограничение cmp.Ordered (пакет cmp), а для двух чисел — встроенные функции max и min. Мы объявили ограничение сами, чтобы было видно, как оно устроено.

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:

package main
 
import "fmt"
 
type Number interface {
	~int | ~float64
}
 
type Age int
 
func 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}))
}

Вывод:

6
3.5
42

Уберите тильду у ~int и запустите снова: третий вызов перестанет собираться, потому что Age — не int. На практике ~ ставят почти всегда, чтобы ограничение работало и с пользовательскими типами.

Дженерик-типы (кратко)

Параметры типа бывают не только у функций, но и у типов — структур, например. Это позволяет описать контейнер, который хранит элементы какого угодно типа, но при этом остаётся типобезопасным: «стопка строк» и «стопка чисел» — разные типы, и перепутать их нельзя.

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. Положить в такую стопку число компилятор не даст.

Самим писать обобщённые типы на старте приходится редко, а пользоваться обобщёнными функциями вы будете постоянно: 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 и учебная папка из первой главы.

Продолжить

Остались вопросы?