
Привет, Хабр!
Дженерики (или generics) существуют во многих языках, таких как Java, C#, и Rust, но для Go это относительно новая фича, введенная в версии 1.18.
До версии 1.18 Go был известен своим строгим и простым подходом к типизации. Однако, с ростом сообщества стало очевидно, что нужен более гибкий инструмент для работы с различными типами данных. Все вечно сталкивались с проблемами, когда им приходилось писать много шаблонного кода для разных типов данных или использовать интерфейсы и пустые интерфейсы.
Go представила предложение по введению дженериков, которое после долгих обсуждений и тестирований было реализовано в версии 1.18. Это был ответ на просьбу сообщества. + к карме
Дженерики позволяют писать более чистый и понятный код. Вы определяете общую структуру один раз, и затем можете использовать её с любым типом данных.
Кратко пройдемся по базе:
Типы как параметры
Допустим, вы хотите написать функцию, которая возвращает первый элемент из любого слайса. Без дженериков вам пришлось бы писать отдельную функцию для каждого типа данных. Но с Type Parameters вы пишете всего одну функцию:
func First[T any](s []T) T { return s[0] }
T — это type parameter. Он может быть чем угодно: int, string, вашим пользовательским типом, чем угодно. А any — это ограничение, которое в данном случае может быть абсолютно любым типом
Type set
Type Sets (в Go версиях ниже 1.18 назывались Contracts) — это способ описания ограничений на типы, которые могут быть использованы с дженериками.
Допустим, вы пишете универсальную функцию, которая должна работать только с числами. Без Contracts вы были бы вынуждены полагаться на добросовестность других разработчиков (и мы знаем, как это бывает ненадежно). Но с Contracts вы можете явно указать, что ваша функция работает только с типами, которые поддерживают арифметические операции:
type Number interface { int | float64 // только целые и вещественные числа. } func Sum[T Number](a, b T) T { return a + b }
Type inference
Type inference пытается угадать, что вы имеете в виду, не заставляя вас перечислять каждую мелочь. Когда вы вызываете функцию с дженериками, Go смотрит на аргументы и пытается вывести типы из контекста:
package main import "fmt" // Generic функция, которая работает с любым типом. func Print[T any](s T) { fmt.Println(s) } func main() { // Go выводит тип параметра T как int. Print(123) // Go выводит тип параметра T как string. Print("Hello, Generics!") }
Иногда Go может сказать: "У тебя слишком много вариантов, я не могу выбрать." Это происходит, когда информации недостаточно для однозначного вывода, или когда ваш код настолько загадочен, что даже умный компилятор в ступоре:
package main import "fmt" func Merge[T any, U any](first T, second U) { fmt.Println(first, second) } func main() { // явное указание типов необходимо, так как Go не может однозначно вывести их Merge[int, string](42, "The answer is") }
Если Go не может самостоятельно вывести тип, вы можете дать ему подсказку, явно указав тип при вызове функции как в 11 строчке кода выше.
Все это звучит классно, но нужно помнить, что дженерики — это не просто синтаксический сахар, они могут влиять на производительность вашего кода. Потому что каждая специализация дженерик-функции или типа создает новую версию этой функции или типа для каждого используемого набора типов.
Дженерики в Go
Constraints и type sets
Constraints, или ограничения типов - это способ указать, какие типы данных могут быть использованы в наших дженериках.
Допустим, вы хотите создать функцию, которая работает с числами. Без дженериков, вам пришлось бы писать отдельные функции для int, float64, и так далее. Но с дженериками и constraints, вы можете сделать это в одном маху. Вот как:
package main import "fmt" // Определяем наш собственный constraint. type Number interface { int | float64 // Может быть int или float64. } // UniversalAdd принимает два параметра любого типа, определенного в Number. func UniversalAdd[T Number](a, b T) T { return a + b } func main() { // Работает с int. fmt.Println(UniversalAdd[int](1, 2)) // Работает с float64. fmt.Println(UniversalAdd[float64](1.5, 2.3)) }
UniversalAdd - это функция, которая может складывать числа любого типа, определенного в Number. Это оч. удобно. Одна функция для всех числовых типов.
Вам не нужно писать отдельные функции для каждого типа данных.
comparable, any
comparable - это специальный интерфейс, который говорит нам, что типы данных могут быть сравнены с помощью операторов == и !=:
package main import "fmt" // Distinct позволяет нам убедиться, что все эементы в слайсе уникальны func Distinct[T comparable](list []T) []T { unique := make(map[T]bool) var result []T for _, item := range list { if !unique[item] { unique[item] = true result = append(result, item) } } return result } func main() { // Работает с любым сравнимым типом fmt.Println(Distinct([]int{1, 2, 2, 3, 4, 4, 4, 5})) fmt.Println(Distinct([]string{"котик", "кошечка", "кот", "кошка"})) }
Distinct использует comparable для создания универсального метода удаления дубликатов из слайса.
Теперь перейдем к any. Это базовый интерфейс в Go, который, по сути, может быть чем угодно:
package main import "fmt" // PrintAny принимает слайс любых элементов и печатает их. func PrintAny(items []any) { for _, item := range items { fmt.Println(item) } } func main() { // Можем смешивать разные типы данных! PrintAny([]any{1, "apple", true, 3.14}) }
PrintAny принимает слайс элементов любого типа и печатает их.
Универсальные функции
Начнем с классики - функции обмена значениями, Swap. Без дженериков, вам нужно было бы писать отдельную функцию для каждого типа данных, но с ними все гораздно удобнее:
package main import "fmt" // Swap меняет местами значения двух переменных любого типа. func Swap[T any](a, b *T) { *a, *b = *b, *a } func main() { x := 1 y := 2 Swap(&x, &y) fmt.Println(x, y) // Выведет: 1 2 s1 := "Hello" s2 := "Habr" Swap(&s1, &s2) fmt.Println(s1, s2) // Выведет: Hello Habr }
Swap использует any, что означает, что она может работать с любым типом данных. Это как универсальный ключ к миру переменных!
Создадим что-то более сложное - универсальную структуру кэша:
package main import "fmt" // Cache - универсальная структура кэша. // T - тип хранимых значений. type Cache[T any] struct { store map[string]T } // NewCache создает новый экземпляр Cache. func NewCache[T any]() *Cache[T] { return &Cache[T]{store: make(map[string]T)} } // Set добавляет значение в кэш. func (c *Cache[T]) Set(key string, value T) { c.store[key] = value } // Get возвращает значение из кэша. func (c *Cache[T]) Get(key string) (T, bool) { val, found := c.store[key] return val, found } func main() { // Создаем кэш для int. intCache := NewCache[int]() intCache.Set("key1", 10) fmt.Println(intCache.Get("key1")) // Выведет: 10 true // Создаем кэш для string. stringCache := NewCache[string]() stringCache.Set("hello", "world") fmt.Println(stringCache.Get("hello")) // Выведет: world true }
Cache - это универсальная структура, которая может хранить значения любого типа. Вы можете создать кэш для целых чисел, строк, или чего угодно, что захотите.
Применение
Слайсы:
Слайсы могут растягиваться и сжиматься, принимая на себя все, что вы им предложите. Используя дженерики, мы можем создать универсальные функции для работы со слайсами любых типов, к примеру создадим функцию фильтрации:
package main import "fmt" // Filter принимает слайс и функцию-предикат, возвращая новый слайс с элементами, удовлетворяющими условию. func Filter[T any](slice []T, predicate func(T) bool) []T { var result []T for _, v := range slice { if predicate(v) { result = append(result, v) } } return result } func main() { // Фильтруем слайс целых чисел. ints := []int{1, 2, 3, 4, 5} even := Filter(ints, func(n int) bool { return n%2 == 0 }) fmt.Println(even) // Выведет: [2 4] // Фильтруем слайс строк. strings := []string{"apple", "banana", "cherry"} withA := Filter(strings, func(s string) bool { return s[0] == 'a' }) fmt.Println(withA) // Выведет: ["apple"] }
Очереди и стеки
Очередь следует принципу FIFO, а стек — LIFO. С дженериками, мы можем создать эти структуры так, чтобы они работали с любыми типами данных:
package main import "fmt" // Stack представляет собой универсальный стек. type Stack[T any] struct { elements []T } // Push добавляет элемент в стек. func (s *Stack[T]) Push(value T) { s.elements = append(s.elements, value) } // Pop удаляет и возвращает верхний элемент стека. func (s *Stack[T]) Pop() (T, bool) { if len(s.elements) == 0 { var zero T // Возвращаем нулевое значение для типа T. return zero, false } last := s.elements[len(s.elements)-1] s.elements = s.elements[:len(s.elements)-1] return last, true } func main() { stack := Stack[int]{} stack.Push(1) stack.Push(2) fmt.Println(stack.Pop()) // Выведет: 2 true fmt.Println(stack.Pop()) // Выведет: 1 true fmt.Println(stack.Pop()) // Выведет: 0 false (стек пуст) }
Деревья
С дженериками, мы можем создать универсальные деревья, которые могут хранить любые данные. Универсальное бинарное дерево поиска выглядит так:
package main import "fmt" // TreeNode представляет узел в бинарном дереве поиска. type TreeNode[T any] struct { Value T Left *TreeNode[T] Right *TreeNode[T] } // Insert добавляет значение в бинарное дерево поиска. func (n *TreeNode[T]) Insert(value T, compare func(a, b T) int) { if compare(value, n.Value) < 0 { if n.Left == nil { n.Left = &TreeNode[T]{Value: value} } else { n.Left.Insert(value, compare) } } else { if n.Right == nil { n.Right = &TreeNode[T]{Value: value} } else { n.Right.Insert(value, compare) } } } // InOrder обходит дерево в порядке возрастания. func (n *TreeNode[T]) InOrder(visit func(T)) { if n == nil { return } n.Left.InOrder(visit) visit(n.Value) n.Right.InOrder(visit) } func main() { root := &TreeNode[int]{Value: 5} root.Insert(3, func(a, b int) int { return a - b }) root.Insert(7, func(a, b int) int { return a - b }) root.Insert(1, func(a, b int) int { return a - b }) root.Insert(9, func(a, b int) int { return a - b }) root.InOrder(func(value int) { fmt.Println(value) }) // Выведет числа в порядке возрастания: 1, 3, 5, 7, 9 }
Сортировка
QuickSort — это база, которую знает каждый. Он быстр, эффективен и, благодаря дженерикам, может быть адаптирован для работы с любым типом данных:
package main import "fmt" // QuickSort сортирует слайс любого сравнимого типа. func QuickSort[T any](data []T, compare func(a, b T) bool) { if len(data) < 2 { return } left, right := 0, len(data)-1 pivot := len(data) / 2 data[pivot], data[right] = data[right], data[pivot] for i := range data { if compare(data[i], data[right]) { data[left], data[i] = data[i], data[left] left++ } } data[left], data[right] = data[right], data[left] QuickSort(data[:left], compare) QuickSort(data[left+1:], compare) } func main() { slice := []int{9, 4, 6, 2, 10, 3} QuickSort(slice, func(a, b int) bool { return a < b }) fmt.Println(slice) // Выведет: [2 3 4 6 9 10] } =
Binary search
package main import "fmt" // BinarySearch ищет элемент в отсортированном слайсе и возвращает его индекс. func BinarySearch[T any](data []T, target T, compare func(a, b T) int) int { low, high := 0, len(data)-1 for low <= high { mid := (low + high) / 2 comparison := compare(data[mid], target) if comparison == 0 { return mid } else if comparison < 0 { low = mid + 1 } else { high = mid - 1 } } return -1 } func main() { slice := []int{2, 3, 4, 6, 9, 10} fmt.Println(BinarySearch(slice, 6, func(a, b int) int { return a - b })) // Выведет: 3 }
Фабрики
С дженериками, мы можем создать универсальные фабрики, способные порождать объекты любого типа:
package main import "fmt" // Creator определяет интерфейс для фабрики. type Creator[T any] func() T // NewInstance создает новый экземпляр типа T. func NewInstance[T any](create Creator[T]) T { return create() } // Примеры типов, которые мы можем создавать. type ( Book struct{ Title string } Game struct{ Name string } ) func main() { bookCreator := func() Book { return Book{Title: "The Go Programming Language"} } gameCreator := func() Game { return Game{Name: "Cyberpunk 2077"} } book := NewInstance(bookCreator) game := NewInstance(gameCreator) fmt.Println(book.Title) // Выведет: The Go Programming Language fmt.Println(game.Name) // Выведет: Cyberpunk 2077 }
Декораторы
Декоратор позволяет динамически добавлять новую функциональность объектам. С дженериками, мы можем создать универсальные декораторы, которые работают с любыми типами:
package main import "fmt" // Decorator оборачивает функцию, добавляя новую функциональность. func Decorator[T any](fn func(T), decorator func(T) T) func(T) { return func(input T) { fn(decorator(input)) } } func main() { print := func(n int) { fmt.Println("Number:", n) } double := func(n int) int { return n * 2 } decorated := Decorator(print, double) decorated(5) // Выведет: Number: 10 }
Дженерики позволяют создавать более абстрактные и универсальные компоненты.
Новички, помните о том, что дженерики - это не панацея и не стоит их использовать везде. Иногда лучше простой код.
Подробнее с дженериками можно ознакомиться на официальном сайте golang. А если вы хотите освоить конкретный язык программирования, заглядывайте в каталог онлайн-курсов OTUS, от ведущих экспертов индустрии.
Keep coding, keep improving, и до новых встреч на Хабре.
и... с наступающим Новым Годом! ?
