Привет, Хабр!

Дженерики (или 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, и до новых встреч на Хабре.

и... с наступающим Новым Годом! ????

Комментарии (10)


  1. dopusteam
    26.12.2023 06:17
    +7

    func Merge[T any, U any](first T, second U) {
        fmt.Println(first, second)
    }

    Иногда Go может сказать: "У тебя слишком много вариантов, я не могу выбрать." Это происходит, когда информации недостаточно для однозначного вывода, или когда ваш код настолько загадочен, что даже умный компилятор в ступоре:

    Merge[int, string](42, "The answer is")

    А почему компилятор тут не может вывести типы? Тут, кажется, информации более чем достаточно


    1. demoth
      26.12.2023 06:17
      +3

      Вроде, даже без уточнения может


    1. olivera507224
      26.12.2023 06:17

      Без проблем выведет в данном примере.


    1. Magic_B
      26.12.2023 06:17
      +1

      Это первое, на что я обратил внимание в этой статье. Статья явно не стоит внимания! Пол статьи рассказывается про структуры данных. Назвать статью надо было "Дженерики и структуры данных в Go" или как-то так... Автор, а как же читателю понять может ли он свой тип создать, чтобы он удовлетворял ограничение comparable? Информации - ноль. Зачем эта статья!?


  1. skovpen
    26.12.2023 06:17
    +2

    // Swap меняет местами значения двух переменных любого типа.
    func Swap[T any](a, b *T) {
        *a, *b = *b, *a
    }
    
        x := 1
        y := 2
        Swap(&x, &y)
        fmt.Println(x, y) // Выведет: 1 2

    Почему это выведет "1 2", а не "2 1"? У нас же Swap отработал. В следующем примере со строками тоже самое.


    1. olivera507224
      26.12.2023 06:17
      +1

      Всё нормально тут выведет: https://go.dev/play/p/MMNrjvhi1zh.

      Автор либо невнимательно пишет, либо просто не проверяет что делает его код перед тем как добавить его в статью. Там ещё много подобных косяков.


  1. bromzh
    26.12.2023 06:17
    +8

    Это оч. удобно. Одна функция для всех числовых типов.

    Вам не нужно писать отдельные функции для каждого типа данных.

    Так умиляет, как гошники радуются таким простым вещам, которые в других языках уже кучу лет существуют. Представьте, какая будет радость, когда для `if err != nil` придумают синтаксический сахар.


    1. olivera507224
      26.12.2023 06:17
      -1

      Для меня это будет подобно второму пришествию. Даже не знаю что буду делать с той кучей лет, которые мне поможет сэкономить этот сахар.


      1. Mispon
        26.12.2023 06:17

        Вероятно, пытаться разобраться в чужом, облепленном в синт. сахаре, коде))


  1. Magic_B
    26.12.2023 06:17
    +1

    Это материал для песочницы. Уровень низкий