- Дженерики и интерфейсы — в чём разница
- Когда выбирать дженерики
- Когда выбирать интерфейсы
- Ограничения компилятора
- Читаемость и переиспользуемость
- Как документировать и тестировать обобщённый код
- Итоги
В 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): один алгоритм для разных числовых типов, компилятор контролирует набор допустимых типов.
Дополнительные материалы
Для полного доступа к курсу нужен базовый план
Базовый план откроет полный доступ ко всем курсам, упражнениям и урокам Хекслета, проектам и пожизненный доступ к теории пройденных уроков. Подписку можно отменить в любой момент.