03 мая 2024

🏃 Самоучитель по Go для начинающих. Часть 12. Обобщенное программирование. Дженерики

Энтузиаст-разработчик, автор статей по программированию. Сфера интересов - backend, web 3.0, кибербезопасность.
В этой части самоучителя подробно изучим парадигму обобщенного программирования и её реализацию в языке Go, на практическом примере рассмотрим дженерики и их основные составляющие. В конце статьи решим несколько интересных задач для закрепления материала.
🏃 Самоучитель по Go для начинающих. Часть 12. Обобщенное программирование. Дженерики

Обобщенное программирование

Обобщенное программирование (ОП) представляет собой парадигму разработки программного обеспечения, которая позволяет писать гибкий и универсальный код, способный работать с различными типами данных.

До версии 1.18 в Go было несколько способов реализации обобщенного программирования:

  1. С помощью интерфейсов, конструкций switch-case и приведения типов.
  2. При помощи пакета reflect, который реализует механизм runtime-рефлексии, позволяя взаимодействовать с объектами произвольных типов. Рефлексия – это способность программы исследовать собственную структуру, в частности, через типы.
  3. Посредством механизма кодогенерации.

На протяжении долгих лет разработчикам приходилось самим реализовывать функционал ОП в Go. Только в версии 1.18, выпущенной в марте 2022 года, была добавлена поддержка дженериков, реализующих механизмы ОП, что стало самым значимым нововведением с момента выпуска языка. Дженерики, как и представленные выше подходы, имеют свои преимущества и недостатки и до сих пор являются темой споров в сообществе разработчиков.

👨‍💻 Библиотека Go разработчика
Больше полезных материалов вы найдете на нашем телеграм-канале «Библиотека Go разработчика»
🎓 Библиотека Go для собеса
Подтянуть свои знания по Go вы можете на нашем телеграм-канале «Библиотека Go для собеса»
🧩 Библиотека задач по Go
Интересные задачи по Go для практики можно найти на нашем телеграм-канале «Библиотека задач по Go»

Дженерики

Основополагающей концепцией ОП являются дженерики – это способ написания кода, позволяющий различным сущностям программы не зависеть от конкретных типов.

Давайте изучим поведение и основные составляющие дженериков на примере конкретной задачи. Допустим, при работе над проектом нас попросили реализовать функцию суммирования целочисленных значений мапы с ключами типа string. После освоения всех предыдущих частей самоучителя это задание не должно вызвать особого затруднения:

        func SumMapValues(mp map[string]int) int {
	var sum int
	for _, val := range mp {
		sum += val
	}
	return sum
}
    

Спустя некоторое время функционал приложения расширился, и появилась необходимость сделать такую же функцию для суммирования значений типа float64 мапы с целочисленными ключами. Не беда, нужно всего лишь скопировать предыдущий вариант SumMapValues, поменять типы данных, и все готово. Но в дальнейшем может понадобиться, чтобы функция SumMapValues могла работать со значениями и ключами произвольных типов, поэтому применяемый нами подход приведет лишь к дублированию кода и возможным ошибкам. Что также немаловажно, он нарушает принцип разработки ПО под названием DRY (don`t repeat yourself – не повторяй себя).

Самое время переписать функцию с использованием дженериков, чтобы она могла не зависеть от конкретных типов:

        func SumMapValues[K comparable, V int64 | float64](mp map[K]V) V {
	var sum V
	for _, val := range mp {
		sum += val
	}
	return sum
}
    

Можно заметить несколько отличий обобщенной функции от обычной. Самое явное заключается в указании в квадратных скобках специальных значений, которые называются «типизированные параметры» или «типы как параметры» (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 при выполнении хотя бы одного из условий:

  1. Type set T является подмножеством type set Inter, при этом T является интерфейсом.
  2. T содержится в type set Inter, при этом T не является интерфейсом.

Стоит запомнить, что в общем случае элемент интерфейса может быть представлен тремя способами: произвольным типом T, базовым типом ~T и объединением типов вида type1 | type2 | … | typeN. Рассмотрим каждое из этих обозначений подробнее:

  • Назначение произвольного типа T понятно из его названия, он может заменять любой тип. С ним мы сталкивались при реализации функции SumMapValues.
  • Базовый тип ~T содержит токен тильда ("~"), добавленный в версии 1.18, и обозначает набор типов, базовым для которых является T. К примеру, в коде ниже тип int является базовым для типа ImplementSome, который, в свою очередь, реализует интерфейс SomeInterface:
        // Интерфейс для обозначения всех типов с базовым типом int, реализующих метод SomeMethod()
type SomeInterface interface {
	~int
	SomeMethod()
}

type ImplementSome int

func (is ImplementSome) SomeMethod() {
	// реализация метода
}
    
  • Объединение типов определяет совокупность всех типов, которые могут использоваться данным интерфейсом. К примеру, мы можем создать интерфейс Values с объединением типов int64 и float64, который выступит в качестве type constraint значений мапы и заменит запись int64 | float64:
        type Values interface {
	int64 | float64
}

func SumMapValues[K comparable, V Values](mp map[K]V) V {}
    

Отметим, что формы записи объектов, подобные представленным ниже, недопустимы:

        type CustomFloat float64

type InvalidInterface interface {
	T                      // ошибка: T - type parameter
	string | ~T            // ошибка: ~T - type parameter
	~float64 | CustomFloat // ошибка: ~float64 включает CustomFloat
	~error                 // ошибка: error является интерфейсом
}

var FloatVar Float           // ошибка
var comparableVar comparable // ошибка

type FloatType Float         // ошибка

type FloatStruct struct {
	flt Float // ошибка
}

    

Представленный интерфейс Float является частью пакета constraints, в котором разработчики языка собрали часто используемые ограничения. Приведем некоторые из них:

        // // Float допускает любой вещественный тип
type Float interface {
	~float32 | ~float64
}

// Integer допускает любой целочисленный тип
type Integer interface {
	Signed | Unsigned
}

// Ordered допускает любой тип, поддерживающий операторы сравнения (<, <, <=, >=)
type Ordered interface {
	Integer | Float | ~string
}
    

Инстанцирование и type inference

Продолжим рассмотрение механизмов ОП на примере функции SumMapValues. Вызовем её для двух различных мап, явно указав в квадратных скобках используемый тип данных:

        func main() {
	mapFloat := map[string]float64{
		"1": 1.5,
		"2": 2.5,
	}

	mapInt := map[string]int64{
		"1": 10,
		"2": 20,
	}

	sumMapFloat := SumMapValues[string, float64](mapFloat)
	sumMapInt := SumMapValues[string, int64](mapInt)

	fmt.Println("mapFloat:", sumMapFloat) // mapFloat: 4
	fmt.Println("mapInt:", sumMapInt)     // mapInt: 30
}
    

Указание «типа в качестве аргумента» (type argument), как в примере выше [string, float64] , принято называть инстанцированием. При этом процессе компилятор заменяет все аргументы типов на требуемые параметры и проверяет, что каждый тип соответствует его type constraint.

Стоит отметить важное нововведение в версии 1.18: при инстанцировании нет необходимости явно указывать типы передаваемых аргументов. Автоматическое сопоставление типов аргументов с типами параметров производится компилятором и носит название «выведение типа аргумента функции» (type inference). Применительно к нашему коду, этот механизм позволяет не указывать типы [string, float64] и [string, int64] при вызовах функции SumMapValues:

        sumMapFloat := SumMapValues(mapFloat)
sumMapInt := SumMapValues(mapInt)
    

Type inference распространяется только на type parameters, указанные в параметрах функции, но не по отдельности в её теле или возвращаемых значениях.

Дженерики в стандартной библиотеке Go

В версии 1.18 стандартная библиотека была расширена тремя экспериментальными пакетами, основанными на дженериках: slices для работы со слайсами, maps для взаимодействия с мапами и constraints для задания распространенных ограничений.

Давайте посмотрим на реализации некоторых функций из пакетов slices и maps, чтобы на примерах увидеть рассмотренные ранее концепции:

  • slices.Equal сравнивает два слайса по длине и значениям:
        func Equal[S ~[]E, E comparable](s1, s2 S) bool {
	if len(s1) != len(s2) {
		return false
	}
	for i := range s1 {
		if s1[i] != s2[i] {
			return false
		}
	}
	return true
}
    
  • maps.Equal проверяет, что две мапы содержат одинаковые пары ключ/значение:
        func Equal[M1, M2 ~map[K]V, K, V comparable](m1 M1, m2 M2) bool {
	if len(m1) != len(m2) {
		return false
	}
	for k, v1 := range m1 {
		if v2, ok := m2[k]; !ok || v1 != v2 {
			return false
		}
	}
	return true
}
    

Задачи

Пришло время закрепить изученную теорию на практических задачах. Отметим, что две последние являются довольно объемными, зато их выполнение позволит хорошо разобраться в дженериках. В случае возникновения трудностей вы всегда можете заглянуть в решение и посмотреть вариант реализации функций.

Фильтрация слайса

Напишите дженерик-функцию Filter для отбора элементов слайса по определенному признаку. В качестве параметров она принимает слайс произвольного типа и функцию-предикат predicate с параметром произвольного типа и возвращаемым значением типа bool. Если элемент удовлетворяет заданному в предикате условию, то predicate возвращает true, иначе – false. Функция Filter возвращает слайс с отобранными элементами.

Пример вызова функции Filter:

        slc := []int{1, 2, 3, 4}
predicate := func(val int) bool {
	return val%2 == 0
}
fmt.Println(Filter(slc, predicate)) // [2 4]
    

Решение

        func Filter[T any](slc []T, predicate func(T) bool) []T {
	var result []T
	for _, value := range slc {
		if predicate(value) {
			result = append(result, value)
		}
	}
	return result
}

    

Удаление повторов

Напишите дженерик-функцию RemoveDuplicates для удаления повторов из слайса comparable типа. Она принимает в качестве параметра слайс и возвращает новый слайс без повторяющихся значений.

Пример вызова функции RemoveDuplicates:

        func main() {
	slc := []int{1, 2, 1, 3, 2, 9, 5}
	fmt.Println(RemoveDuplicates(slc)) // 1 2 3 9 5
}
    

Решение

        func RemoveDuplicates[T comparable](slc []T) []T {
	var result []T
	checked := make(map[T]bool)
	for _, val := range slc {
		if _, ok := checked[val]; !ok {
			checked[val] = true
			result = append(result, val)
		}
	}
	return result
}
    

Обобщенный кэш

Реализуйте с помощью дженериков механизм кэширования данных. Он состоит из нескольких компонентов:

  1. Структура Cache, содержащая мапу с ключами типа string и значениями произвольного типа T, которая будет выступать в качестве хранилища данных.
  2. Метод-конструктор NewCache для создания объекта кэша.
  3. Метод Set для добавления значения в кэш по ключу.
  4. Метод Get для получения значения из кэша по ключу. Если запрашиваемого элемента не найдено в мапе, то в качестве второго значения метод должен вернуть false.

Пример работы методов:

        func main() {
	cache := NewCache[int]()
	cache.Set("1", 1)
	fmt.Println(cache.Get("1")) // 1 true
	fmt.Println(cache.Get("2")) // 0 false
}
    

Решение

        type Cache[T any] struct {
	storage map[string]T
}

func NewCache[T any]() *Cache[T] {
	return &Cache[T]{
		storage: make(map[string]T),
	}
}

func (c *Cache[T]) Set(key string, value T) {
	c.storage[key] = value
}

func (c *Cache[T]) Get(key string) (T, bool) {
	value, found := c.storage[key]
	if !found {
		return value, false
	}
	return value, true
}
    

Обобщенное множество

Напишите дженерик-реализацию множества – структуры данных, позволяющей хранить уникальные значения определённого типа в неупорядоченном виде. Программа должна состоять из следующих частей:

  1. Тип мапы с comparable ключами и значениями типа struct{}. Вместо пустой структуры, можно использовать тип bool, но такой подход будет менее эффективен, так как bool занимает больше памяти, чем struct{}.
  2. Метод-конструктор NewSet для инициализации ключей мапы. В качестве параметра принимает список значений comparable типа.
  3. Метод Add для добавления значений в множество. В качестве параметра принимает список значений comparable типа.
  4. Метод Contains для проверки наличия значения во множестве. Возвращает true или false.
  5. Метод GetElements для получения всех элементов множества. Возвращает слайс comparable типа.

Пример работы методов:

        func main() {
	set := NewSet(1, 2)
	fmt.Println(set.Contains(9)) // false
	set.Add(3, 4)
	fmt.Println(set.GetElements()) // числа 1 2 3 4 в произвольном порядке
}
    

Решение

        type Set[E comparable] map[E]struct{}

func NewSet[E comparable](values ...E) Set[E] {
	set := Set[E]{}
	for _, value := range values {
		set[value] = struct{}{}
	}
	return set
}

func (set Set[E]) Add(values ...E) {
	for _, val := range values {
		set[val] = struct{}{}
	}
}

func (set Set[E]) Contains(value E) bool {
	_, found := set[value]
	return found
}

func (set Set[E]) GetElements() []E {
	var elements []E
	for value := range set {
		elements = append(elements, value)
	}
	return elements
}
    

Заключение

В этой статье мы познакомились с парадигмой обобщенного программирования, рассмотрели дженерики и их компоненты: type parameter, type constraint и type inference. В конце закрепили материал на четырех практических задачах, которые наглядно демонстрируют применение дженериков.

В следующей части узнаем о способах хранения времени в компьютере и в языке Go, изучим основные функции пакета time и в конце решим парочку несложных задач

***

Содержание самоучителя

  1. Особенности и сфера применения Go, установка, настройка
  2. Ресурсы для изучения Go с нуля
  3. Организация кода. Пакеты, импорты, модули. Ввод-вывод текста.
  4. Переменные. Типы данных и их преобразования. Основные операторы
  5. Условные конструкции if-else и switch-case. Цикл for. Вложенные и бесконечные циклы
  6. Функции и аргументы. Области видимости. Рекурсия. Defer
  7. Массивы и слайсы. Append и сopy. Пакет slices
  8. Строки, руны, байты. Пакет strings. Хеш-таблица (map)
  9. Структуры и методы. Интерфейсы. Указатели. Основы ООП
  10. Наследование, абстракция, полиморфизм, инкапсуляция
  11. Обработка ошибок. Паника. Восстановление. Логирование
  12. Обобщенное программирование. Дженерики

МЕРОПРИЯТИЯ

Комментарии

ВАКАНСИИ

Добавить вакансию
DevOps
от 350000 RUB
C# Developer
Москва, по итогам собеседования

ЛУЧШИЕ СТАТЬИ ПО ТЕМЕ