Go: Дженерики

Теория: Практика и best practices

В 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. Это гарантирует, что алгоритм ведёт себя одинаково независимо от конкретного типа.

Итоги

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

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

+7 800 100 22 47

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

+7 495 085 21 62

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

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