Обобщенное программирование
Обобщенное программирование (ОП) представляет собой парадигму разработки программного обеспечения, которая позволяет писать гибкий и универсальный код, способный работать с различными типами данных.
До версии 1.18 в Go было несколько способов реализации обобщенного программирования:
- С помощью интерфейсов, конструкций switch-case и приведения типов.
- При помощи пакета reflect, который реализует механизм runtime-рефлексии, позволяя взаимодействовать с объектами произвольных типов. Рефлексия – это способность программы исследовать собственную структуру, в частности, через типы.
- Посредством механизма кодогенерации.
На протяжении долгих лет разработчикам приходилось самим реализовывать функционал ОП в Go. Только в версии 1.18, выпущенной в марте 2022 года, была добавлена поддержка дженериков, реализующих механизмы ОП, что стало самым значимым нововведением с момента выпуска языка. Дженерики, как и представленные выше подходы, имеют свои преимущества и недостатки и до сих пор являются темой споров в сообществе разработчиков.
Дженерики
Основополагающей концепцией ОП являются дженерики – это способ написания кода, позволяющий различным сущностям программы не зависеть от конкретных типов.
Давайте изучим поведение и основные составляющие дженериков на примере конкретной задачи. Допустим, при работе над проектом нас попросили реализовать функцию суммирования целочисленных значений мапы с ключами типа string
. После освоения всех предыдущих частей самоучителя это задание не должно вызвать особого затруднения:
Спустя некоторое время функционал приложения расширился, и появилась необходимость сделать такую же функцию для суммирования значений типа float64 мапы с целочисленными ключами. Не беда, нужно всего лишь скопировать предыдущий вариант SumMapValues
, поменять типы данных, и все готово. Но в дальнейшем может понадобиться, чтобы функция SumMapValues
могла работать со значениями и ключами произвольных типов, поэтому применяемый нами подход приведет лишь к дублированию кода и возможным ошибкам. Что также немаловажно, он нарушает принцип разработки ПО под названием DRY (don`t repeat yourself – не повторяй себя).
Самое время переписать функцию с использованием дженериков, чтобы она могла не зависеть от конкретных типов:
Можно заметить несколько отличий обобщенной функции от обычной. Самое явное заключается в указании в квадратных скобках специальных значений, которые называются «типизированные параметры» или «типы как параметры» (type parameters). Они позволяют передавать в функцию тип в качестве параметра. В нашем примере type parameters представлены символами K
и V
и изменяются в пределах ограничений вида comparable
и int64 | float64
соответственно, которые называются type constraint.
Ключевое слово comparable – это предопределенный интерфейс для описания типов данных, поддерживающих сравнение с помощью операторов ==
и !=
. Примерами comparable типов являются bool, int, float, string и другие. Comparable типами не являются слайсы, функции, мапы и некоторые другие с определенными условиями.
В версии 1.18 помимо comparable был также добавлен интерфейс any – псевдоним для interface{}
, который может представлять любой тип.
Type constraint
В Go type constraint должен представлять из себя интерфейс, который определяет набор типов (type set), а именно типы, реализующие методы этого интерфейса.
Чтобы в полной мере понять type constraints, нужно расширить наше представление об интерфейсах. До введения поддержки ОП в спецификации Go содержалось уже известное нам правило: тип, реализующий все методы интерфейса, удовлетворяет ему. Теперь представьте, что вместо методов указываются типы данных, и принято следующее соглашение: тип, входящий в набор типов интерфейса, реализует этот интерфейс. Иными словами, произвольный тип T
удовлетворяет интерфейсу Inter при выполнении хотя бы одного из условий:
Type set T
является подмножествомtype set Inter
, при этомT
является интерфейсом.T
содержится вtype set Inter
, при этомT
не является интерфейсом.
Стоит запомнить, что в общем случае элемент интерфейса может быть представлен тремя способами: произвольным типом T
, базовым типом ~T
и объединением типов вида type1 | type2 | … | typeN
. Рассмотрим каждое из этих обозначений подробнее:
- Назначение произвольного типа T понятно из его названия, он может заменять любой тип. С ним мы сталкивались при реализации функции SumMapValues.
- Базовый тип ~T содержит токен тильда ("~"), добавленный в версии 1.18, и обозначает набор типов, базовым для которых является T. К примеру, в коде ниже тип
int
является базовым для типаImplementSome
, который, в свою очередь, реализует интерфейсSomeInterface
:
- Объединение типов определяет совокупность всех типов, которые могут использоваться данным интерфейсом. К примеру, мы можем создать интерфейс
Values
с объединением типов int64 и float64, который выступит в качествеtype constraint
значений мапы и заменит записьint64 | float64
:
Отметим, что формы записи объектов, подобные представленным ниже, недопустимы:
Представленный интерфейс Float
является частью пакета constraints, в котором разработчики языка собрали часто используемые ограничения. Приведем некоторые из них:
Инстанцирование и type inference
Продолжим рассмотрение механизмов ОП на примере функции SumMapValues
. Вызовем её для двух различных мап, явно указав в квадратных скобках используемый тип данных:
Указание «типа в качестве аргумента» (type argument), как в примере выше [string, float64]
, принято называть инстанцированием. При этом процессе компилятор заменяет все аргументы типов на требуемые параметры и проверяет, что каждый тип соответствует его type constraint.
Стоит отметить важное нововведение в версии 1.18: при инстанцировании нет необходимости явно указывать типы передаваемых аргументов. Автоматическое сопоставление типов аргументов с типами параметров производится компилятором и носит название «выведение типа аргумента функции» (type inference). Применительно к нашему коду, этот механизм позволяет не указывать типы [string, float64]
и [string, int64]
при вызовах функции SumMapValues
:
Type inference
распространяется только на type parameters
, указанные в параметрах функции, но не по отдельности в её теле или возвращаемых значениях.
Дженерики в стандартной библиотеке Go
В версии 1.18 стандартная библиотека была расширена тремя экспериментальными пакетами, основанными на дженериках: slices для работы со слайсами, maps для взаимодействия с мапами и constraints для задания распространенных ограничений.
Давайте посмотрим на реализации некоторых функций из пакетов slices и maps, чтобы на примерах увидеть рассмотренные ранее концепции:
slices.Equal
сравнивает два слайса по длине и значениям:
maps.Equal
проверяет, что две мапы содержат одинаковые пары ключ/значение:
Задачи
Пришло время закрепить изученную теорию на практических задачах. Отметим, что две последние являются довольно объемными, зато их выполнение позволит хорошо разобраться в дженериках. В случае возникновения трудностей вы всегда можете заглянуть в решение и посмотреть вариант реализации функций.
Фильтрация слайса
Напишите дженерик-функцию Filter
для отбора элементов слайса по определенному признаку. В качестве параметров она принимает слайс произвольного типа и функцию-предикат predicate с параметром произвольного типа и возвращаемым значением типа bool
. Если элемент удовлетворяет заданному в предикате условию, то predicate
возвращает true
, иначе – false
. Функция Filter
возвращает слайс с отобранными элементами.
Пример вызова функции Filter:
Решение
Удаление повторов
Напишите дженерик-функцию RemoveDuplicates
для удаления повторов из слайса comparable
типа. Она принимает в качестве параметра слайс и возвращает новый слайс без повторяющихся значений.
Пример вызова функции RemoveDuplicates:
Решение
Обобщенный кэш
Реализуйте с помощью дженериков механизм кэширования данных. Он состоит из нескольких компонентов:
- Структура
Cache
, содержащая мапу с ключами типа string и значениями произвольного типаT
, которая будет выступать в качестве хранилища данных. - Метод-конструктор
NewCache
для создания объекта кэша. - Метод
Set
для добавления значения в кэш по ключу. - Метод
Get
для получения значения из кэша по ключу. Если запрашиваемого элемента не найдено в мапе, то в качестве второго значения метод должен вернутьfalse
.
Пример работы методов:
Решение
Обобщенное множество
Напишите дженерик-реализацию множества – структуры данных, позволяющей хранить уникальные значения определённого типа в неупорядоченном виде. Программа должна состоять из следующих частей:
- Тип мапы с
comparable
ключами и значениями типаstruct{}
. Вместо пустой структуры, можно использовать типbool
, но такой подход будет менее эффективен, так как bool занимает больше памяти, чемstruct{}
. - Метод-конструктор
NewSet
для инициализации ключей мапы. В качестве параметра принимает список значенийcomparable
типа. - Метод
Add
для добавления значений в множество. В качестве параметра принимает список значенийcomparable
типа. - Метод
Contains
для проверки наличия значения во множестве. Возвращаетtrue
илиfalse
. - Метод
GetElements
для получения всех элементов множества. Возвращает слайсcomparable
типа.
Пример работы методов:
Решение
Заключение
В этой статье мы познакомились с парадигмой обобщенного программирования, рассмотрели дженерики и их компоненты: type parameter
, type constraint
и type inference
. В конце закрепили материал на четырех практических задачах, которые наглядно демонстрируют применение дженериков.
В следующей части узнаем о способах хранения времени в компьютере и в языке Go, изучим основные функции пакета time и в конце решим парочку несложных задач
Содержание самоучителя
- Особенности и сфера применения Go, установка, настройка
- Ресурсы для изучения Go с нуля
- Организация кода. Пакеты, импорты, модули. Ввод-вывод текста.
- Переменные. Типы данных и их преобразования. Основные операторы
- Условные конструкции if-else и switch-case. Цикл for. Вложенные и бесконечные циклы
- Функции и аргументы. Области видимости. Рекурсия. Defer
- Массивы и слайсы. Append и сopy. Пакет slices
- Строки, руны, байты. Пакет strings. Хеш-таблица (map)
- Структуры и методы. Интерфейсы. Указатели. Основы ООП
- Наследование, абстракция, полиморфизм, инкапсуляция
- Обработка ошибок. Паника. Восстановление. Логирование
- Обобщенное программирование. Дженерики
- Работа с датой и временем. Пакет time
- Интерфейсы ввода-вывода. Буферизация. Работа с файлами. Пакеты io, bufio, os
Комментарии