Команда 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.
-
Применяйте их для создания связанных ограничений между разными параметрами типов.
-
Используйте их, чтобы абстрагироваться от разных реализаций с различными типами ограничений.
-
Если вы оказались в ситуации, когда нужно накладывать ограничение на получателей-указателей, подумайте, можно ли переписать код так, чтобы избежать лишней сложности.
И как всегда — не переусложняйте: менее гибкое, но более простое и читаемое решение в итоге может оказаться самым разумным выбором.
ссылка на оригинал статьи https://habr.com/ru/articles/942634/
Добавить комментарий