Go: Дженерики

Теория: Введение

До версии Go 1.18 программистам приходилось выбирать: либо дублировать один и тот же алгоритм под разные типы данных, либо использовать пустой интерфейс interface{} и терять безопасность типов и скорость. Дженерики решили эту проблему. Теперь можно писать универсальные функции и структуры, которые работают с разными типами данных, сохраняя строгую типизацию.

Зачем нужны дженерики

Представим задачу поиска максимума в срезе. Для []int мы бы писали одну функцию, для []float64 — другую, для []string — третью. Логика одинакова, но код разный. С дженериками алгоритм можно описать один раз и применять ко всем.

Без дженериков — три функции

// Для int
func MaxInt(values []int) int {
	max := values[0]
	for _, v := range values {
		if v > max { // сравниваем элементы
			max = v
		}
	}
	return max
}

// Для float64
func MaxFloat(values []float64) float64 {
	max := values[0]
	for _, v := range values {
		if v > max { // та же самая логика
			max = v
		}
	}
	return max
}

// Для string
func MaxString(values []string) string {
	max := values[0]
	for _, v := range values {
		if v > max { // и снова то же самое
			max = v
		}
	}
	return max
}

Здесь три функции делают одно и то же, различается только тип входных данных.

Решение с дженериками

// Max — обобщённая функция для поиска максимума
// T — параметр типа. Ограничение comparable гарантирует,
// что элементы можно сравнивать операторами <, >, == и т.д.
func Max[T comparable](values []T) T {
	max := values[0] // берём первый элемент как стартовый максимум
	for _, v := range values {
		if v > max { // сравниваем элементы
			max = v // обновляем максимум
		}
	}
	return max
}

func main() {
	// Массив чисел int
	ints := []int{1, 5, 3}
	fmt.Println(Max(ints)) // 5

	// Массив чисел float64
	floats := []float64{2.1, 5.4, 1.9}
	fmt.Println(Max(floats)) // 5.4

	// Массив строк
	strings := []string{"a", "c", "b"}
	fmt.Println(Max(strings)) // c
}

Что здесь важно:

  • T — параметр типа, который подставляется компилятором автоматически.
  • comparable — ограничение, которое гарантирует, что для типа T доступны операции сравнения.
  • Вместо трёх функций теперь достаточно одной.

Синтаксис дженериков в Go

Когда программист знакомится с дженериками, первое, что бросается в глаза, — это квадратные скобки []. Именно там указываются параметры типа. Дальше этот параметр можно использовать внутри функции, структуры или метода так же, как обычный тип (int, string и т. д.).

Функции с дженериками

Начнём с простого примера. Автор описывает функцию Print, которая умеет печатать значения любого типа.

// [T any] — параметр типа.
// T — имя параметра, any — ограничение (подходит любой тип).
func Print[T any](value T) {
	fmt.Println(value)
}

func main() {
	Print(10)      // здесь T = int
	Print("hello") // здесь T = string
}

Функция одна, но работает и с числами, и со строками. Если бы не было дженериков, пришлось бы писать PrintInt, PrintString и так далее.

Несколько параметров

Затем разработчик сталкивается с ситуацией, когда нужно работать с разными типами сразу. Для этого можно указать несколько параметров.

// Здесь два параметра типов: A и B.
// Для каждого можно подставить свой тип.
func Pair[A any, B any](a A, b B) {
	fmt.Println(a, b)
}

func main() {
	Pair(1, "go") // A = int, B = string
}

Теперь одна функция умеет принимать и числа, и строки, и любые другие комбинации.

Ограничения (constraints)

Но что если внутри функции нужно использовать операции сравнения или сложения? Просто any не подойдёт. Здесь на помощь приходят ограничения.

// comparable — встроенное ограничение.
// Поддерживает сравнение ==, !=, <, >.
func Max[T comparable](values []T) T {
	max := values[0]
	for _, v := range values {
		if v > max { // здесь уже точно можно сравнивать
			max = v
		}
	}
	return max
}

func main() {
	fmt.Println(Max([]int{1, 5, 3}))           // 5
	fmt.Println(Max([]float64{2.1, 5.4, 1.9})) // 5.4
	fmt.Println(Max([]string{"a", "c", "b"}))  // c
}

В этом коде Go строго проверяет: если тип нельзя сравнивать, программа даже не скомпилируется.

Свои ограничения

Иногда стандартных ограничений мало. Тогда создают собственные интерфейсы.

// Number — собственное ограничение.
// Здесь допустимы только int и float64.
type Number interface {
	int | float64
}

// Sum — обобщённая функция, которая считает сумму.
func Sum[T Number](values []T) T {
	var result T
	for _, v := range values {
		result += v // мы уверены, что для T есть операция сложения
	}
	return result
}

func main() {
	fmt.Println(Sum([]int{1, 2, 3}))      // 6
	fmt.Println(Sum([]float64{1.5, 2.5})) // 4.0
}

Таким образом, программист задаёт допустимый набор типов, и компилятор будет следить, чтобы никто не подсунул, например, строку.

Дженерики в структурах

Дальше становится интереснее. Параметры типа можно использовать и в структурах. Возьмём простой пример — коробку, которая умеет хранить значение любого типа.

// Box[T] — структура с параметром типа T.
// Она хранит одно значение.
type Box[T any] struct {
	value T
}

// Set — метод, который кладёт новое значение в коробку.
func (b *Box[T]) Set(v T) {
	b.value = v
}

// Get — метод, который возвращает значение.
func (b *Box[T]) Get() T {
	return b.value
}

func main() {
	// Коробка для чисел
	intBox := Box[int]{}
	intBox.Set(42)
	fmt.Println(intBox.Get()) // 42

	// Коробка для строк
	strBox := Box[string]{}
	strBox.Set("hello")
	fmt.Println(strBox.Get()) // hello
}

Структура одна, но работает и с числами, и со строками. Тип указывается один раз при создании коробки.

Методы с дженериками

Методы у структур ничем не отличаются от обычных функций. Они так же могут использовать параметр типа.

// Stack[T] — стек для хранения элементов.
type Stack[T any] struct {
	items []T
}

// Push — метод добавления.
func (s *Stack[T]) Push(v T) {
	s.items = append(s.items, v)
}

// Pop — метод извлечения.
func (s *Stack[T]) Pop() T {
	n := len(s.items)
	v := s.items[n-1]
	s.items = s.items[:n-1]
	return v
}

func main() {
	// Стек для int
	s1 := Stack[int]{}
	s1.Push(10)
	s1.Push(20)
	fmt.Println(s1.Pop()) // 20

	// Стек для string
	s2 := Stack[string]{}
	s2.Push("go")
	s2.Push("lang")
	fmt.Println(s2.Pop()) // lang
}

Здесь мы написали универсальный контейнер один раз, и он сразу работает для любых типов.

Итоги

Синтаксис дженериков в Go устроен просто и логично. Параметры типа указываются в квадратных скобках, а внутри функций и структур они ведут себя как обычные типы. Ограничения позволяют контролировать, что именно разрешено делать с данными: сравнивать, складывать или просто хранить. Всё это даёт возможность писать универсальные алгоритмы и структуры без копипаста, сохраняя безопасность типов и высокую производительность.

Рекомендуемые программы

+7 800 100 22 47

бесплатно по РФ

+7 495 085 21 62

бесплатно по Москве

108813 г. Москва, вн.тер.г. поселение Московский,
г. Московский, ул. Солнечная, д. 3А, стр. 1, помещ. 20Б/3
ОГРН 1217300010476
ИНН 7325174845