Команда Go for Devs подготовила перевод статьи Акселя Вагнера о том, как generic интерфейсы в Go открывают новые возможности и новые сложности. В статье разбираются паттерны, ограничения и компромиссы: от self reference интерфейсов до дилеммы с ресивер-указателями.
Есть одна идея, которая не приходит в голову, пока впервые о ней не услышишь: так как интерфейсы сами по себе являются типами, у них тоже могут быть параметризованные типы. Эта мысль оказывается удивительно мощной, когда речь идёт о выражении ограничений для generic функций и типов. В этом посте мы разберём её на практике, показав, как использовать интерфейсы с параметрами типа в нескольких распространённых сценариях.
Простой TreeSet
В качестве примера предположим, что нам нужна generic версия бинарного дерева поиска. Элементы, которые хранятся в таком дереве, должны быть упорядоченными, поэтому для параметра типа необходимо задать ограничение, определяющее способ сравнения. Простейший вариант — использовать ограничение cmp.Ordered
, добавленное в Go 1.21. Оно ограничивает параметр типа упорядоченными типами (строки и числа) и позволяет методам такого типа использовать встроенные операторы сравнения.
// The zero value of a Tree is a ready-to-use empty tree.
type Tree[E cmp.Ordered] struct {
root *node[E]
}
func (t *Tree[E]) Insert(element E) {
t.root = t.root.insert(element)
}
type node[E cmp.Ordered] struct {
value E
left *node[E]
right *node[E]
}
func (n *node[E]) insert(element E) *node[E] {
if n == nil {
return &node[E]{value: element}
}
switch {
case element < n.value:
n.left = n.left.insert(element)
case element > n.value:
n.right = n.right.insert(element)
}
return n
}
Однако у такого подхода есть минус: он работает только для базовых типов, для которых определён оператор <
; вы не сможете вставлять struct-типы, такие как time.Time
.
Это можно исправить, если заставить пользователя передать функцию сравнения:
// A FuncTree must be created with NewTreeFunc.
type FuncTree[E any] struct {
root *funcNode[E]
cmp func(E, E) int
}
func NewFuncTree[E any](cmp func(E, E) int) *FuncTree[E] {
return &FuncTree[E]{cmp: cmp}
}
func (t *FuncTree[E]) Insert(element E) {
t.root = t.root.insert(t.cmp, element)
}
type funcNode[E any] struct {
value E
left *funcNode[E]
right *funcNode[E]
}
func (n *funcNode[E]) insert(cmp func(E, E) int, element E) *funcNode[E] {
if n == nil {
return &funcNode[E]{value: element}
}
sign := cmp(element, n.value)
switch {
case sign < 0:
n.left = n.left.insert(cmp, element)
case sign > 0:
n.right = n.right.insert(cmp, element)
}
return n
}
Это работает, но тоже имеет свои недостатки. Мы больше не можем использовать нулевое значение нашего контейнера, потому что ему нужна явно инициализированная функция сравнения. А наличие поля-функции усложняет компилятору встраивание вызовов сравнения, что может привести к заметным накладным расходам во время выполнения.
Использование метода у типа элемента решает эти проблемы, поскольку методы непосредственно связаны с типом. Метод не нужно передавать явно, и компилятор видит, какой именно вызов выполняется, и может его встроить. Но как выразить ограничение так, чтобы требовать от типов элементов наличия нужного метода?
Использование ресивера в ограничениях
Первое, что приходит в голову, — определить обычный интерфейс с методом Compare
:
type Comparer interface {
Compare(Comparer) int
}
Однако быстро становится ясно, что это плохо работает. Чтобы реализовать такой интерфейс, параметр метода сам должен быть типа Comparer
. Это означает не только то, что в реализации метода придётся приводить параметр к собственному типу (type assertion
), но и то, что каждому типу придётся явно ссылаться на наш пакет и упоминать по имени тип Comparer
(иначе сигнатуры методов не совпадут). Это не слишком ортогонально.
Лучше сделать сам интерфейс Comparer
generic:
type Comparer[T any] interface {
Compare(T) int
}
Теперь Comparer описывает целое семейство интерфейсов — по одному для каждого типа, с которым можно создать Comparer
. Тип, реализующий Comparer[T]
, тем самым заявляет: «я могу сравнивать себя с T
». Например, time.Time
естественным образом реализует Comparer[time.Time]
, потому что у него есть подходящий метод Compare
:
// Implements Comparer[Time]
func (t Time) Compare(u Time) int
Это лучше, но всё ещё не идеально. На самом деле нам нужно ограничение, которое говорит, что параметр типа можно сравнивать сам с собой: ограничение должно быть самореферентным. Важный нюанс: самореферентность не обязана быть частью определения интерфейса как такового; конкретно, ограничение для T
в типе Comparer
— просто any
. Вместо этого самореферентность возникает из того, как мы используем Comparer
в качестве ограничения для параметра типа MethodTree
:
// The zero value of a MethodTree is a ready-to-use empty tree.
type MethodTree[E Comparer[E]] struct {
root *methodNode[E]
}
func (t *MethodTree[E]) Insert(element E) {
t.root = t.root.insert(element)
}
type methodNode[E Comparer[E]] struct {
value E
left *methodNode[E]
right *methodNode[E]
}
func (n *methodNode[E]) insert(element E) *methodNode[E] {
if n == nil {
return &methodNode[E]{value: element}
}
sign := element.Compare(n.value)
switch {
case sign < 0:
n.left = n.left.insert(element)
case sign > 0:
n.right = n.right.insert(element)
}
return n
}
Поскольку time.Time
реализует Comparer[time.Time]
, он теперь подходит как аргумент типа для этого контейнера, и мы по-прежнему можем использовать нулевое значение как пустой контейнер:
var t MethodTree[time.Time]
t.Insert(time.Now())
Для полной гибкости библиотека может предоставить все три варианта API. Чтобы не дублировать код, все версии могут пользоваться общей реализацией. В качестве таковой можно взять функциональный вариант — он самый общий:
type node[E any] struct {
value E
left *node[E]
right *node[E]
}
func (n *node[E]) insert(cmp func(E, E) int, element E) *node[E] {
if n == nil {
return &node[E]{value: element}
}
sign := cmp(element, n.value)
switch {
case sign < 0:
n.left = n.left.insert(cmp, element)
case sign > 0:
n.right = n.right.insert(cmp, element)
}
return n
}
// Insert inserts element into the tree, if E implements cmp.Ordered.
func (t *Tree[E]) Insert(element E) {
t.root = t.root.insert(cmp.Compare[E], element)
}
// Insert inserts element into the tree, using the provided comparison function.
func (t *FuncTree[E]) Insert(element E) {
t.root = t.root.insert(t.cmp, element)
}
// Insert inserts element into the tree, if E implements Comparer[E].
func (t *MethodTree[E]) Insert(element E) {
t.root = t.root.insert(E.Compare, element)
}
Важно отметить, что общая реализация (вариант на функциях) никак не ограничивается. Она должна оставаться максимально гибкой, чтобы служить общим ядром. Мы также не храним функцию сравнения в поле структуры. Вместо этого передаём её параметром, потому что аргументы функций компилятору было проще анализировать, чем поля структур.
Разумеется, некоторый шаблонный код всё равно останется: всем экспортируемым реализациям придётся повторить полный API с немного разными способами вызова. Но эта часть проста и для написания, и для чтения.
Комбинирование методов и множеств типов
Мы можем использовать нашу новую структуру дерева, чтобы реализовать упорядоченное множество с поиском элемента за логарифмическое время. Теперь представим, что нам нужен поиск за константное время; можно попытаться добиться этого, ведя обычную map параллельно с деревом:
type OrderedSet[E Comparer[E]] struct {
tree MethodTree[E] // for efficient iteration in order
elements map[E]bool // for (near) constant time lookup
}
func (s *OrderedSet[E]) Has(e E) bool {
return s.elements[e]
}
func (s *OrderedSet[E]) Insert(e E) {
if s.elements == nil {
s.elements = make(map[E]bool)
}
if s.elements[e] {
return
}
s.elements[e] = true
s.tree.Insert(e)
}
func (s *OrderedSet[E]) All() iter.Seq[E] {
return func(yield func(E) bool) {
s.tree.root.all(yield)
}
}
func (n *node[E]) all(yield func(E) bool) bool {
return n == nil || (n.left.all(yield) && yield(n.value) && n.right.all(yield))
}
Однако при компиляции этого кода мы получим ошибку:
invalid map key type E (missing comparable constraint)
Из сообщения следует, что нам нужно дополнительно ограничить параметр типа, чтобы его можно было использовать в качестве ключа map. Ограничение comparable
— это специальное предопределённое ограничение, которому удовлетворяют все типы, для которых определены операторы равенства ==
и !=
. В Go это также множество типов, которые можно использовать как ключи встроенного типа map
.
Есть три способа добавить это ограничение к нашему параметру типа — у каждого свои компромиссы:
Первый: можно встроить comparable
в изначальное определение Comparer
(песочница):
type Comparer[E any] interface {
comparable
Compare(E) int
}
У этого подхода есть минус – он сделает наши типы Tree
пригодными только для типов, удовлетворяющих comparable
. В целом мы не хотим лишний раз сужать generic типы.
Второй: можно ввести новое определение ограничения (песочница).
type Comparer[E any] interface {
Compare(E) int
}
type ComparableComparer[E any] interface {
comparable
Comparer[E]
}
Выглядит лаконично, но добавляет новый идентификатор (ComparableComparer
) в наш API. Получается сложновато с названием.
Третий: можно добавить ограничение inline в более строгий тип (песочница):
type OrderedSet[E interface {
comparable
Comparer[E]
}] struct {
tree Tree[E]
elements map[E]struct{}
}
Такой код может быть труднее читать, особенно если это приходится делать часто. К тому же так сложнее переиспользовать ограничение в других местах.
Какой вариант выбрать — вопрос стиля и в итоге дело личных предпочтений.
(Не) накладывая ограничения на generic интерфейсы
На этом этапе стоит обсудить ограничения для generic интерфейсов. Возможно, вы захотите определить интерфейс для generic типа контейнера. Например, у вас есть алгоритм, которому требуется структура данных «множество». Существует множество реализаций множеств с разными компромиссами. Определив интерфейс для нужных вам операций над множеством, вы добавляете гибкость своему пакету, оставляя пользователю право выбрать, какие компромиссы подходят его конкретному приложению:
type Set[E any] interface {
Insert(E)
Delete(E)
Has(E) bool
All() iter.Seq[E]
}
Естественный вопрос здесь — каким должно быть ограничение у этого интерфейса. По возможности параметры типа в generic интерфейсах должны использовать any
как ограничение, разрешая произвольные типы.
Исходя из сказанного выше, причины очевидны: разные конкретные реализации могут требовать разных ограничений. Все рассмотренные нами ранее типы Tree
, а также тип OrderedSet
, могут реализовать Set
для своих типов элементов, хотя у этих типов разные ограничения.
Смысл определения интерфейса — предоставить реализацию на усмотрение пользователя. Поскольку невозможно предсказать, какие ограничения захочет наложить пользователь на свою реализацию, старайтесь оставлять любые ограничения (строже, чем any
) конкретным реализациям, а не интерфейсам.
Ресивер-указатель
Попробуем использовать интерфейс Set
в примере. Рассмотрим функцию, которая удаляет дубликаты из последовательности:
// Unique удаляет дублирующиеся элементы из входной последовательности,
// выдавая только первое вхождение каждого элемента.
func Unique[E comparable](input iter.Seq[E]) iter.Seq[E] {
return func(yield func(E) bool) {
seen := make(map[E]bool)
for v := range input {
if seen[v] {
continue
}
if !yield(v) {
return
}
seen[v] = true
}
}
}
Здесь в роли простого множества элементов типа E
используется map[E]bool
. Соответственно, это работает только для типов, которые сравнимы и для которых определены встроенные операторы равенства. Если мы хотим generic решение на произвольные типы, нужно заменить map на generic множество:
// Unique удаляет дублирующиеся элементы из входной последовательности,
// выдавая только первое вхождение каждого элемента.
func Unique[E any](input iter.Seq[E]) iter.Seq[E] {
return func(yield func(E) bool) {
var seen Set[E]
for v := range input {
if seen.Has(v) {
continue
}
if !yield(v) {
return
}
seen.Insert(v)
}
}
}
Однако это не работает. Set[E]
— интерфейсный тип, и переменная seen
будет инициализирована значением nil
. Нам нужна конкретная реализация интерфейса Set[E]
. Но, как мы уже видели в этой статье, не существует общей реализации множества, которая подошла бы для любого типа элементов.
Нам придётся попросить пользователя передать конкретную реализацию, которую мы сможем использовать, как дополнительный параметр типа:
// Unique удаляет дублирующиеся элементы из входной последовательности,
// выдавая только первое вхождение каждого элемента.
func Unique[E any, S Set[E]](input iter.Seq[E]) iter.Seq[E] {
return func(yield func(E) bool) {
var seen S
for v := range input {
if seen.Has(v) {
continue
}
if !yield(v) {
return
}
seen.Insert(v)
}
}
}
Но если инстанцировать это нашей реализацией множества, мы столкнёмся с ещё одной проблемой:
// OrderedSet[E] does not satisfy Set[E] (method All has pointer receiver)
Unique[E, OrderedSet[E]](slices.Values(s))
// panic: invalid memory address or nil pointer dereference
Unique[E, *OrderedSet[E]](slices.Values(s))
Первая проблема очевидна из сообщения об ошибке: наше ограничение требует, чтобы аргумент типа S
реализовывал интерфейс Set[E]
. Поскольку методы OrderedSet
используют ресивер-указатели, аргументом типа должен быть также указательный тип.
Попытавшись сделать так, мы упираемся во вторую проблему. Она возникает из-за того, что в реализации мы объявляем переменную:
var seen S
Если S
— это *OrderedSet[E]
, переменная, как и раньше, инициализируется nil
. Вызов seen.Insert
приводит к панике.
Если у нас есть только указательный тип, мы не можем получить валидную переменную значимого (value) типа. А если есть только значимый тип, мы не можем вызывать методы с ресивер-указателем. Следствие: нам нужны и значимый, и указательный тип. Значит, придётся ввести дополнительный параметр типа PS
с новым ограничением PtrToSet
.
// PtrToSet реализуется указательным типом, который реализует интерфейс Set[E].
type PtrToSet[S, E any] interface {
*S
Set[E]
}
// Unique удаляет дублирующиеся элементы из входной последовательности,
// выдавая только первое вхождение каждого элемента.
func Unique[E, S any, PS PtrToSet[S, E]](input iter.Seq[E]) iter.Seq[E] {
return func(yield func(E) bool) {
// We convert to PS, as only that is constrained to have the methods.
// The conversion is allowed, because the type set of PS only contains *S.
seen := PS(new(S))
for v := range input {
if seen.Has(v) {
continue
}
if !yield(v) {
return
}
seen.Insert(v)
}
}
}
Хитрость здесь — в связывании двух параметров типов в сигнатуре функции через дополнительный параметр типа в интерфейсе PtrToSet
. Сам S
не имеет ограничений, но PS
обязан иметь тип *S
и содержать нужные нам методы. По сути, мы накладываем на S
требование иметь определённые методы, причём эти методы должны использовать получатель-указатель.
Хотя определение функции с таким ограничением требует дополнительного параметра типа, важно, что это не усложняет код, который её использует: пока этот дополнительный параметр стоит в конце списка параметров типа, его можно вывести автоматически:
// Третий аргумент типа выводится как *OrderedSet[int]
Unique[int, OrderedSet[int]](slices.Values(s))
Это общий паттерн, который стоит запомнить — пригодится и при чтении чужого кода, и в собственных разработках.
func SomeFunction[T any, PT interface{ *T; SomeMethods }]()
Если у вас есть два параметра типа, и один из них ограничен быть указателем на другой, такое ограничение гарантирует, что соответствующие методы используют ресивер-указатель.
Следует ли ограничивать ресивер-указатели?
На этом этапе вы можете чувствовать себя перегруженными. Всё выглядит довольно сложно, и неразумно ожидать, что каждый Go-программист будет разбираться в том, что происходит в этой сигнатуре функции. Нам также пришлось ввести ещё больше имён в наш API. Когда в самом начале предостерегали от добавления generic в Go, именно подобных вещей многие и опасались.
Поэтому, если вы запутались в подобных проблемах, стоит сделать шаг назад. Часто мы можем избежать этой сложности, если по-другому посмотреть на задачу. В нашем примере мы написали функцию, которая принимает iter.Seq[E]
и возвращает iter.Seq[E]
только с уникальными элементами. Но чтобы убрать дубликаты, нам понадобилось собрать уникальные элементы в множество. А раз для этого нужно выделить память под весь результат целиком, реальной выгоды от представления результата в виде потока мы не получаем.
Если переосмыслить задачу, можно вовсе избежать дополнительного параметра типа, используя Set[E]
как обычное значение интерфейсного типа:
// InsertAll добавляет в set все уникальные элементы из seq.
func InsertAll[E any](set Set[E], seq iter.Seq[E]) {
for v := range seq {
set.Insert(v)
}
}
Используя Set
как обычный интерфейсный тип, мы явно даём понять, что вызывающий должен передать корректное значение своей конкретной реализации. Это очень распространённый паттерн. А если ему нужен iter.Seq[E]
, он может просто вызвать All()
у множества и получить его.
Это немного усложняет жизнь вызвавшему код, но у такого подхода есть ещё одно преимущество по сравнению с ограничением на ресивер-указатели: вспомните, что мы начинали с map[E]bool
как простого типа множества. На этой основе легко реализовать интерфейс Set[E]
:
type HashSet[E comparable] map[E]bool
func (s HashSet[E]) Insert(v E) { s[v] = true }
func (s HashSet[E]) Delete(v E) { delete(s, v) }
func (s HashSet[E]) Has(v E) bool { return s[v] }
func (s HashSet[E]) All() iter.Seq[E] { return maps.Keys(s) }
В этой реализации не используются ресивер-указатели. Так что, хотя она вполне корректна, она была бы непригодна при сложном ограничении на ресивер-указатели. Зато отлично работает с нашей версией InsertAll
. Как и со многими другими ограничениями, требование использовать ресивер-указатели может оказаться чрезмерно жёстким для многих практических сценариев.
Русскоязычное GO сообщество
Друзья! Эту статью перевела команда «Go for Devs» — сообщества, где мы делимся практическими кейсами, инструментами для разработчиков и свежими новостями из мира Go. Подписывайтесь, чтобы быть в курсе и ничего не упустить!

Заключение
Надеюсь, это помогло показать некоторые паттерны и компромиссы, которые открывают параметры типов в интерфейсах. Это мощный инструмент, но у него есть и цена. Основные выводы:
Используйте generic интерфейсы, чтобы выражать ограничения на получателя через self reference.
Применяйте их для создания связанных ограничений между разными параметрами типов.
Используйте их, чтобы абстрагироваться от разных реализаций с различными типами ограничений.
Если вы оказались в ситуации, когда нужно накладывать ограничение на получателей-указателей, подумайте, можно ли переписать код так, чтобы избежать лишней сложности.
И как всегда — не переусложняйте: менее гибкое, но более простое и читаемое решение в итоге может оказаться самым разумным выбором.