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

Практика и best practices Go: Дженерики

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

Дженерики и интерфейсы — в чём разница

Интерфейсы описывают поведение. Если тип умеет выполнять определённый набор методов, он подходит для работы с данным интерфейсом. Например, интерфейс io.Reader используется для всего, что умеет читать данные, будь то файл, сетевое соединение или буфер в памяти. Интерфейсы делают акцент на возможностях типа.

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

Когда выбирать дженерики

Дженерики особенно полезны, если алгоритм одинаков для разных типов данных, но методы интерфейсов здесь не подходят.

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

package main

import "constraints"

// Max возвращает максимальное значение из среза
func Max[T constraints.Ordered](values []T) T {
    max := values[0]
    for _, v := range values {
        if v > max {
            max = v
        }
    }
    return max
}

Вызов будет выглядеть так:

ints := []int{1, 2, 3}
floats := []float64{1.2, 3.4, 2.5}

println(Max(ints))   // 3
println(Max(floats)) // 3.4

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

Когда выбирать интерфейсы

Интерфейсы лучше всего подходят там, где важно поведение, а не конкретный алгоритм.

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

package main

import (
    "fmt"
    "io"
    "strings"
)

func ReadFirstN(r io.Reader, n int) string {
    buf := make([]byte, n)
    r.Read(buf)
    return string(buf)
}

func main() {
    s := strings.NewReader("hello world")
    fmt.Println(ReadFirstN(s, 5)) // hello
}

Здесь дженерики не помогут — нам важно именно поведение (Read), а не операции сравнения или арифметики.

Ограничения компилятора

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

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

Читаемость и переиспользуемость

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

Интерфейсы, наоборот, часто повышают читаемость: код сразу объясняет, какое поведение требуется. Например, io.Writer говорит сам за себя.

Хорошее правило: если читаемость страдает от дженериков, лучше использовать интерфейс.

Как документировать и тестировать обобщённый код

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

Пример:

// Max возвращает максимальное значение из среза.
// Работает с числами и строками, поддерживающими оператор >.

Тестировать обобщённый код удобно на нескольких разных типах сразу. Для функции Max можно написать набор тестов с int, float64, string. Это гарантирует, что алгоритм ведёт себя одинаково независимо от конкретного типа.

Итоги

Интерфейсы — про поведение. Дженерики — про универсальные алгоритмы и операции над данными. Интерфейсы дают гибкость, дженерики — безопасность на этапе компиляции. В реальных проектах они часто используются вместе: интерфейсы описывают поведение систем, а дженерики помогают писать компактные и безопасные алгоритмы работы с данными.


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

Потренируемся выбирать подход — дженерики или интерфейсы — и сформулируем критерии.

Задача: что использовать, и почему.

  • Сценарий 1: Нужно сравнить два значения и вернуть минимальное.

    Показать ответ

    Дженерики с ограничением constraints.Ordered: общий алгоритм сравнения, строгая типизация, без приведения типов.

  • Сценарий 2: Нужно читать первые N байт из источника (файл/сеть/буфер).

    Показать ответ

    Интерфейс io.Reader: важна способность «читать», а не операции над данными; поведение задаёт контракт.

  • Сценарий 3: Нужно посчитать сумму числового среза (int/int64/float64).

    Показать ответ

    Дженерики с пользовательским ограничением Number (type set): один алгоритм для разных числовых типов, компилятор контролирует набор допустимых типов.


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

  1. Effective Go — Interfaces and types

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

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

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

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

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

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

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

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