До версии 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 устроен просто и логично. Параметры типа указываются в квадратных скобках, а внутри функций и структур они ведут себя как обычные типы. Ограничения позволяют контролировать, что именно разрешено делать с данными: сравнивать, складывать или просто хранить. Всё это даёт возможность писать универсальные алгоритмы и структуры без копипаста, сохраняя безопасность типов и высокую производительность.
Самостоятельная работа
Разберём задачу без дженериков, чтобы увидеть дублирование кода.
Задача: реализовать две функции реверса — отдельно для []int
и []string
.
Шаги:
- Напишите
ReverseInts(xs []int) []int
— возвращает новый срез в обратном порядке. - Напишите
ReverseStrings(xs []string) []string
— делает то же самое для строк. - Сравните сигнатуры и логику — чем они отличаются?
fmt.Println(ReverseInts([]int{1,2,3})) // [3 2 1]
fmt.Println(ReverseStrings([]string{"a","b"})) // [b a]
Показать решение
package main
import "fmt"
func ReverseInts(xs []int) []int {
n := len(xs)
out := make([]int, n)
for i, v := range xs {
out[n-1-i] = v
}
return out
}
func ReverseStrings(xs []string) []string {
n := len(xs)
out := make([]string, n)
for i, v := range xs {
out[n-1-i] = v
}
return out
}
func main() {
fmt.Println(ReverseInts([]int{1, 2, 3})) // [3 2 1]
fmt.Println(ReverseStrings([]string{"a", "b"})) // [b a]
}
Дополнительные материалы
Для полного доступа к курсу нужен базовый план
Базовый план откроет полный доступ ко всем курсам, упражнениям и урокам Хекслета, проектам и пожизненный доступ к теории пройденных уроков. Подписку можно отменить в любой момент.