Зарегистрируйтесь, чтобы продолжить обучение

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


Самостоятельная работа

Разберём задачу без дженериков, чтобы увидеть дублирование кода.

Задача: реализовать две функции реверса — отдельно для []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]
}

Дополнительные материалы

  1. Go 1.18 — заметки релиза (дженерики)

Для полного доступа к курсу нужен базовый план

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

Получить доступ
1000
упражнений
2000+
часов теории
3200
тестов

Открыть доступ

Курсы программирования для новичков и опытных разработчиков. Начните обучение бесплатно

  • 130 курсов, 2000+ часов теории
  • 1000 практических заданий в браузере
  • 360 000 студентов
Отправляя форму, вы принимаете «Соглашение об обработке персональных данных» и условия «Оферты», а также соглашаетесь с «Условиями использования»

Наши выпускники работают в компаниях:

Логотип компании Альфа Банк
Логотип компании Aviasales
Логотип компании Yandex
Логотип компании Tinkoff