Дженерики в Go
До версии 1.18 функция «сумма элементов» писалась трижды - для int, для float64, для своего типа - или один раз через any с утверждениями типа и потерей проверок. Дженерики закрыли этот разрыв: один код, сохранённая типизация, никаких утверждений на выходе.
Стержень: параметр типа с ограничением; ограничение описывает, что с типом можно делать, а тильда пропускает типы, определённые на его основе.
// Формулировки: «зачем дженерики, если есть интерфейсы?», «что означает тильда в ограничении?», «когда дженерик, а когда интерфейс?»
Параметр типа и вывод
Параметры типа пишутся в квадратных скобках после имени функции: func Sum[T Number](xs []T) T. Внутри T ведёт себя как обычный тип, а при вызове компилятор чаще всего выводит его сам из аргументов - явные скобки при вызове почти никогда не нужны.
Проверено: Sum([]int{1,2,3}) возвращает 6, вызов записан без указания типа. Функция Map с двумя параметрами типа тоже выводится: Map([]int{1,2}, func(i int) string {...}) отдаёт []string, и типы на входе и выходе разные.
Ключевое отличие от any: типизация сохраняется насквозь. Функция принимает []T и возвращает T, а не any, поэтому у вызывающего результат уже нужного типа, без утверждений и без риска паники.
// Компилятор при этом не превращает всё в интерфейсы: для типов с одинаковым представлением в памяти генерируется общий код со словарём типов, для разных - отдельные версии. Значения не уезжают в кучу так, как это происходит при укладке в any.
type Number interface{ ~int | ~float64 }
func Sum[T Number](xs []T) T {
var total T
for _, x := range xs { total += x }
return total
}
Sum([]int{1, 2, 3}) // 6, тип выведен, скобки не нужныОграничения и тильда
Ограничение - это интерфейс, но в новой роли: кроме методов он описывает допустимые базовые типы. interface{ ~int | ~float64 } читается как «любой тип, чья основа - int или float64».
Тильда здесь принципиальна. Без неё ограничение int | float64 пропускает только сами эти типы. С тильдой проходят и производные: type Celsius float64 удовлетворяет ~float64. Проверено: Sum по срезу Celsius считается и возвращает 4 при слагаемых 1,5 и 2,5.
Без тильды такая функция отказалась бы работать с любым доменным типом на базе числа - а их в нормальном коде много. Поэтому в стандартных ограничениях из пакета constraints тильда стоит везде.
// Готовые ограничения брать проще, чем писать свои: any для чего угодно, comparable для типов, которые можно сравнивать через двойное равно, и потому годящихся в ключи мапы. Именно comparable стоит в сигнатурах обобщённых функций для мап.
- тильда в ограничении
- ~T пропускает сам T и вдобавок любые типы, определённые на его основе
- comparable
- встроенное ограничение: типы, которые можно сравнивать и класть в ключи мапы
Дженерик или интерфейс
Вопрос решается одним различием. Интерфейс говорит про поведение: мне всё равно, какой тип, лишь бы у него был метод Read. Дженерик говорит про сам тип: мне важно, что вход и выход - один и тот же тип, каким бы он ни был.
Отсюда практика. Нужны разные реализации одного поведения - интерфейс: хранилище, транспорт, кодировщик. Нужна одна и та же логика для разных типов данных с сохранением типизации - дженерик: контейнеры, срезы, кеши, функции вроде Map и Filter.
Не стоит переписывать рабочий код на дженерики просто потому, что они появились. Обобщение ради обобщения читается хуже конкретного кода, а сообщения об ошибках у обобщённых функций заметно менее понятны.
// Хороший признак, что дженерик уместен: у тебя уже есть две-три почти одинаковые функции, отличающиеся только типом. Плохой признак: ты пишешь первую реализацию и заранее делаешь её обобщённой на случай, если понадобится вторая.
Как отвечать: «Зачем дженерики, если есть интерфейсы и any?»
Они решают разные задачи. Интерфейс говорит про поведение: мне неважно, какой тип, лишь бы умел Read. Дженерик говорит про сам тип: мне важно, что вход и выход одного типа, а какого именно - неважно. Через any это тоже пишется, но с потерями: типизация исчезает, на выходе нужны утверждения типа, любая ошибка вылезает во время работы, а не при компиляции, и значения уезжают в кучу при укладке в интерфейс. Дженерик всё это сохраняет: функция Sum принимает []T и возвращает T, компилятор выводит тип из аргументов, скобки при вызове писать не нужно. Про ограничения важна тильда: ~float64 пропускает сам float64 и вдобавок типы, определённые на его основе, поэтому обобщённая функция работает с доменными типами вроде Celsius - я это проверял. Из готовых ограничений чаще всего нужны any и comparable, последнее для ключей мапы. И я стараюсь не обобщать заранее: дженерик оправдан, когда уже есть две-три почти одинаковые функции, отличающиеся только типом.
Разница сформулирована одним критерием, названы конкретные потери от any, показана роль тильды с проверенным примером и добавлено правило, когда обобщать не надо.
На чём валят
- −Берут any там, где нужен дженерик, и теряют типизацию вместе с проверками компилятора.
- −Пишут ограничение без тильды, и функция отказывается работать с доменными типами.
- −Обобщают первую же реализацию заранее, на случай будущей второй.
- −Пытаются описать дженериком поведение, для которого достаточно интерфейса.
- −Забывают про comparable и не могут использовать параметр типа как ключ мапы.
- −Указывают тип в квадратных скобках при вызове, хотя компилятор выводит его сам.
Проверьте себя
Пять вопросов из банка по этой подтеме. Всего их 7, остальные разбираются в тренажёре.
- Что задаёт constraint в дженериках, например [T comparable] или [T ~int]?A)Множество допустимых типов и операции, разрешённые над TB)Значение по умолчанию для параметра типа TC)Порядок, в котором компилятор инстанцирует функцию под типыD)Список интерфейсов, которые тип обязан реализовать в рантайме
показать ответ и разбор
+A)Множество допустимых типов и операции, разрешённые над T// разбор: constraint ограничивает, какие типы можно подставить вместо T, и тем — какие операции над T легальны в теле. comparable разрешает ==/!=; объединение int|float64 разрешает арифметику и <; ~int значит «любой тип с базовым int» (включая именованные). any снимает ограничения (но тогда над T почти ничего нельзя). Проверка на этапе компиляции, не в рантайме.
- Когда дженерик уместнее обычного интерфейса в Go?A)В новом коде дженерики вытесняют интерфейсы как более современный механизмB)Когда нужно динамически менять поведение в рантайме по типу значенияC)Когда одна логика на много типов и нужно сохранить их конкретный типD)Когда типов немного и все они известны на этапе компиляции наперёд
показать ответ и разбор
+C)Когда одна логика на много типов и нужно сохранить их конкретный тип// разбор: Дженерик уместен, когда ОДИН алгоритм работает для многих типов и надо сохранить конкретный тип на входе/выходе (Map/Filter, контейнеры: []T → []U без ассерций и потери типа). Интерфейс уместен, когда нужно РАЗНОЕ поведение за общим контрактом, выбираемое в рантайме (полиморфизм, плагины). Это не замена друг другу: часто сам constraint — интерфейс.
- Нужно ли при вызове обобщённой функции указывать параметр типа явно?A)Да: для обобщённого кода компилятор типы не выводитB)Нет, если тип выводится из аргументов вызоваC)Да, если функция принимает больше одного аргументаD)Нет: параметр типа указывать не принято, он только выводится
показать ответ и разбор
+B)Нет, если тип выводится из аргументов вызова// разбор: Вывод типов работает по аргументам: Max(1, 2) не требует писать Max[int]. Явная форма нужна там, где выводить не из чего — например, тип встречается только в возвращаемом значении или в пустом контейнере. Тогда пишут Zero[string]() и получают понятную ошибку компиляции вместо загадочного «cannot infer T».
- Что означает тильда в ограничении дженерика, например interface{ ~int }?A)Что тип может быть int или иметь такой же размер в памятиB)Что параметр типа необязателен и может быть опущен при вызовеC)Что подходят типы, у которых int является базовымD)Что значение приводится к int автоматически при передаче в функцию
показать ответ и разбор
+C)Что подходят типы, у которых int является базовым// разбор: Без тильды ограничение принимает ровно int, и собственный type Celsius int уже не подойдёт. ~int расширяет его до всех типов с базовым int — именно поэтому в constraints пишут ~int | ~int64 | ~float64. Тильда и делает обобщённый код применимым к доменным типам, ради которых в Go и заводят собственные имена для чисел.
- Чем ограничение comparable отличается от any?A)comparable допускает сортировку значений операторами < и >B)comparable требует, чтобы у типа был метод Compare для упорядочиванияC)comparable разрешает только числовые типы и строки, остальные исключеныD)comparable допускает типы, поддерживающие == и !=, и годится в ключи map
показать ответ и разбор
+D)comparable допускает типы, поддерживающие == и !=, и годится в ключи map// разбор: comparable — про равенство, а не про порядок: под него подходят числа, строки, указатели, структуры из сравнимых полей, но не слайсы, map и функции. Именно оно требуется для ключей map в обобщённом коде. Упорядочивание — это отдельное ограничение cmp.Ordered, где уже разрешены < и >, и туда структуры не входят.
дальше
Теорию прочитали. Навык ставится повторением
В Сеньорчике эта подтема идёт в ежедневных сессиях: движок возвращает её, пока ответы не станут уверенными, и ведёт прогресс отдельно по каждой подтеме. Теория внутри тоже бесплатна, лимит только на количество вопросов в день.