В этот раз поговорим об одной из самых долгожданных фичах Go — дженериках — инструменте обобщённого программирования. Дженерики как раз завезли в Go 1.18, т.е. совсем недавно, и самое время в них разобраться.
Это текстовая версия моего ролика про дженерики:
Данный материал частично основан на официальном туториале от команды языка.
Зачем нужны дженерики
Идея обобщённого программирования заключается в том, чтобы единожды написать код, который может работать с множеством разных типов, соответствующих заданным ограничениям. Например, можно только один раз реализовать алгоритм сортировки чисел, но применять его к разным видам чисел, вместо того чтобы реализовывать его отдельно для int
, int64
, uint32
, float64
и т.д. То, что это должны быть именно числа как раз и является ограничением в данном случае. Дженерики — это также одна из форм полиморфизма.
Ограничения или констрейнты в дженериках — это требования к типам, которые обобщаются данным дженериком. В некотором смысле констрейнты — это те же интерфейсы, но позволяющие описывать требования в том числе через сами значения, а не только их поведение. Посмотрите, даже синтаксис объявления именованного констрейнта похож на синтаксис объявления интерфейса:
|
|
- С помощью оператора
|
перечисляются типы, подходящие под констрейнт. Таким образомOrdered
требует, чтобы тип был таким, что над ним задано отношение порядка. - Префикс
~
перед каждым типом означает, что это может быть, например, не толькоstring
, но и любой тип, для которогоstring
является базовым типом (т.е. типы, расширяющиеstring
тоже удовлетворяют требованиям).
Имея такое ограничение, мы можем объявить, например, вот такую функцию:
|
|
Конструкция [T Ordered]
после имени функции означает, что в качестве типа аргумента можно использовать обозначение T
, при этом T
обязан удовлетворять ограничению Ordered
, т.е. быть одним из типов, перечисленных в Ordered
.
При этом в теле функции не важно, какой конкретно тип у элементов слайса s
. Важно только то, что возможно определить, какой элемент больше, а какой меньше. С другой стороны, нам не требуется прибегать к механизму type assertion. Посмотрите, как громоздко это выглядело бы, и каким вариант type assertion обладает потенциалом для багов:
|
|
На данном примере видно, что есть случаи, когда использование дженериков удобнее и безопаснее использования interface{}
.
Пример. Сумма чисел
Давайте взглянем на пример из официального туториала. Представьте, что у вас есть функция, которая считает сумму значений в мапе:
|
|
Предполагается, что числа в мапе — это int64
. В какой-то момент вам захотелось начать считать такую же сумму, но для float64
:
|
|
Как видите, за исключением типа числа и имени функции в остальном SumInts
и SumFloats
идентичны. Было бы круто иметь только одну функцию для суммы, чтобы сократить код и поддерживать тоже только одну функцию. И там, и там числа в конце концов!
Чтобы это было возможным, в Go 1.18 появились параметры типа. Это похоже на обычные параметры функции, но работают они немного на другом уровне. Каждый параметр типа имеет ограничение (constraint), который определяет допустимый набор типов. Во время компиляции вместо этого множества типов подставляется тот, что используется фактически.
С параметрами типа достаточно написать единственную функцию для суммы чисел:
|
|
- Здесь
K
иV
являются параметрами типа, аm
параметром функции. comparable
иint64 | float64
— это ограничения на типы.comparable
— встроенное в язык (начиная с версии 1.18) ограничение, захватывающее буквально все типы, для которых уместны операторы сравнения.int64 | float64
означает, что тип может быть либоint64
, либоfloat64
.
Как видите, после того как были определены параметры типа, они могут использоваться в типах параметров функции и в качестве типа возвращаемого значения, как если бы это была обычная функция.
Вызываться такая функция может так:
|
|
Либо так:
|
|
Поскольку в данном случае компилятор способен самостоятельно вывести и подставить типы.
Вместо того чтобы писать громоздкие ограничения в сигнатуре функции, можно их объявлять отдельно и давать им имена:
|
|
Тогда функция, объявленная выше, может преобразиться в следующее:
|
|
Пример. Обобщённый репозиторий
Одна из проблем Go, которую отчасти могут помочь решить дженерики — это большое количество бойлерплейта, т.е. рутинного кода, который от модуля к модулю или от проекта к проекту практически не меняется. Примером бойлерплейта может служить репозиторий данных. До Go 1.18 разработчикам приходилось либо писать каждый репозиторий вручную, либо использовать кодогенерацию. Благодаря дженерикам возможно один раз написать код, реализующий простой CRUD-репозиторий:
|
|
При этом использование такого репозитория мало чем отличается от «традиционно» написанного:
|
|
Магия
Поговорим немного о магии. Ведь код выше выглядит как настоящая магия! Кстати, в программистском сленге у данного слова есть вполне конкретное значение:
Магия — это код, благодаря которому сокращается рутина, но появляется больше неявного поведения.
Причем первое без второго встречается очень редко. Использование магии вне Хогвартса при хотя бы концептуальном понимании, что происходит у этой самой магии под капотом — это сочетание, при котором можно писать эффективный и предсказуемый код за более короткое время. Однако у магии в коде очень часто встречается одно крайне неприятное свойство: достаточно попытаться сделать с помощью магии что-то хоть немного нестандартное (т.е. то, что авторами магического кода не задумывалось), всё вокруг начнёт рушиться.
Взгляните на написанный выше репозиторий. Что если нужно будет сделать join? А если нужно будет завернуть какую-то последовательность операций в транзакцию? Таких «нестандартных» сценариев настолько много, что их просто невозможно предусмотреть все. Да, можно написать обёртку, расширяющую возможность исходного репозитория, но со временем сложность и количество кода обёртки может вырасти настолько, что выяснится, что было бы лучше изначально написать репозиторий в лоб без всякой магии.
К чему я это всё?
Конечно, когда есть дженерики, соблазн написать репозиторий один раз и переиспользовать везде очень велик. Но, как говорится, «когда у тебя в руках молоток, всё вокруг кажется гвоздями». Дженерики, как и любую магию, стоит использовать только тогда, когда других, более простых вариантов не остаётся.
Как понять, когда использовать дженерики
Во-первых, нужно понять, насколько компонент абстрагирован от предметной области. Т.е., например, алгоритм обхода графа или реализация списка — это хорошие кандидаты на использование дженериков, а вот пользователь с разными ролями — нет. Во втором случае лучше выразить это либо через свойство сущности, либо завести отдельный тип для каждой роли, если они кардинально отличаются.
Второй важный критерий: вы используете компонент или алгоритм со множеством разных типов данных, при этом поведение компонента не зависит от конкретного типа. Например, список одинаково работает и для строк, и для чисел, и вообще для чего угодно. Если поведение компонента меняется в зависимости от типа, подумайте о том, чтобы просто использовать интерфейсы.
Библиотека lo
С момента выхода Go 1.18 появился ряд библиотек, которые добавляют всякие маленькие полезные функции с дженериками в стиле функционального программирования. Одной из самых популярных является библиотека samber/lo. Чем же она может быть полезна? На самом деле юзкейсов, которые она покрывает очень много, я покажу только часть из них.
Фильтрация
Представьте, что вам небходимо из слайса пользователей получить другой слайс пользователей, но содержащий только пользователей с правами администратора. Без lo код будет примерно таким:
|
|
Всё довольно просто, но если вам необходимо сделать много последовательных фильтраций или других манипуляций с коллекцией, нагромождение циклов может сделать код плохо читаемым. Давайте взглянем, как решение этой задачи может выглядеть с использованием lo:
|
|
Стало компактнее, не правда ли? Кроме этого, код из императивного превратился в декларативный. Декларативный подход заключается в том, что мы описываем что мы делаем, а не как. Представьте, что вы попросили официанта в ресторане бутерброд. Если вы просите его об этом императивно, вы опишете по шагам, что ему нужно делать: подойти к холодильнику, открыть холодильник, взять хлеб, взять сыр и т.д. Но официант скорее всего понимает, что нужно сделать, чтобы получился бутерброд, поэтому вы можете попросить его об это декларативно: «Я хочу бутерброд с хлебом и сыром». Причем официант вряд ли будет его делать сам, но для вас это уже всего лишь детали реализации.
Для повышения читаемости можно функцию фильтрации объявить отдельно, и тогда запись станет совсем компактной:
|
|
Маппинг
lo.Map
возвращает слайс, состоящий из результатов применения функции к элементам исходного слайса:
|
|
Перемешивание слайса
|
|
Функции в lo в основном избавляют нас от необходимости писать тривиальные обработки коллекций и фокусироваться на более высоком уровне абстракции. Это ещё один пример, где дженерики действительно повышают вашу продуктивность, но сохраняют читаемость кода.
Монады с библиотекой mo
Другая библиотека от того же автора, mo, позволяет вам использовать монады в Go. Если вы не знаете, что это такое, у меня для вас хорошая новость — почти никто по-настоящему не понимает, что это. При этом мы с вами используем их буквально каждый день в своей работе.
Если вы хотите разобраться в монадах без погружения в теорию категорий и другую сложную математику, я прикрепил ссылку на замечательный доклад Виталия Брагилевского, в котором он на пальцах и простым языком рассказывает о монадах и показывает всю их красоту.
Option
Option
позволяет сделать значение опциональным без использования хака с поинтерами:
|
|
Это пригодится, например, когда вам необходимо объявить опциональные поля у структуры или вернуть значение из функции, которое может отсутствовать.
Future
Future
— это контейнер для значения, которое может быть недоступно прямо сейчас, но будет доступно когда-то в будущем, либо произойдет ошибка, если значение никогда не будет доступно. Иными словами, Future
представляет собой удобную обёртку для выполнения асинхронных задач. Если, например, на «чистом» Go для ожидания единственного значения из горутины вам необходимо объявить канал, передать его в горутину, а затем правильно организовать чтение из канала, то с Future
всё становится проще:
|
|
Можно передать переменную с фьючей в другую функцию, а в ней уже просто вызвать
|
|
чтобы дождаться завершения операции и получить или значение, или ошибку.
Причём удобная обработка ошибок для Future
также предусмотрена:
|
|
Стоит заметить, что я не рекомендую использовать Future для работы с асинхронными вычислениями, а лишь показываю, что возможно делать с дженериками. Призываю вас всё-таки писать идиоматичный код.
Монады — это очень крутой инструмент в программировании, и теперь он полноценно доступен в Go благодаря дженерикам. Однако они могут обладать повышенным порогом входа для новичков в команде или в программировании вообще, поэтому использовать монады, как и дженерики рекомендуется вдумчиво и по необходимости.
***
- Дженерики — мощный инструмент для написания универсального кода;
- Они хорошо подходят для абстрактных и универсальных алгоритмов и структур данных, но могут принести проблемы при использовании на уровне предметной области (бизнеса);
- Благодаря дженерикам у нас теперь есть классные инструменты из мира функционального программирования, которые, тем не менее, стоит использовать осторожно;
- Если есть возможность не использовать дженерики — не используйте их.
Полезные ссылки
- Официальный туториал по дженерикам: https://go.dev/doc/tutorial/generics
- Запись в блоге Go, раскрывающая тему подробнее: https://go.dev/blog/why-generics
- Виталий Брагилевский «Монады — не приговор»: https://www.youtube.com/watch?v=IkXg_mjNgG4
Telegram: https://t.me/deferpanic
Discord: https://discord.gg/4uw7Fpp2QX
Реквизиты, если у вас есть желание поддержать defer panic:
- Boosty — https://boosty.to/deferpanic
- Patreon — https://www.patreon.com/deferpanic
- BTC —
18vz3Lr94CLqebH41hbYLwYviJ96wvXm7i
- ETH —
0x86e4Fad409BEd0aE8A8367d92d866bbDC54E8A6c