Generics in go

Generics in go

Hello, Habre!

Generics exist in many languages, such as Java, C#, and Rust, but Go is a relatively new feature introduced in version 1.18.

Before version 1.18, Go was known for its strict and simple approach to typing. However, as the community grew, it became apparent that a more flexible tool for working with different types of data was needed. Everyone has always faced problems when they had to write a lot of boilerplate code for different data types or use interfaces and empty interfaces.

Go introduced a proposal to introduce generics, which after much discussion and testing was implemented in version 1.18. This was in response to a community request. + to karma

Generics allow you to write cleaner and more understandable code. You define a generic structure once, and then you can use it with any data type.

Let’s briefly go through the database:

Types as parameters

Let’s say you want to write a function that returns the first element from any slice. Without generics, you had to write a separate function for each data type. But with Type Parameters you write only one function:

func First[T any](s []T) T {
    return s[0]
}

T – This is a type parameter. It can be anything: int, string, your user type, whatever. AND any is a restriction, which in this case can be absolutely any type

Type set

Type Sets (called Contracts in Go versions below 1.18) are a way of describing type restrictions that can be used with generics.

Let’s say you write a universal function that should work only with numbers. Without Contracts, you would be forced to rely on the good faith of other developers (and we know how unreliable that can be). But with Contracts, you can specify that your function only works with types that support arithmetic operations:

type Number interface {
    int | float64 // только целые и вещественные числа.
}

func Sum[T Number](a, b T) T {
    return a + b
}

Type inference

Type inference tries to guess what you mean without forcing you to list every little detail. When you call a function with generics, Go looks at the arguments and tries to infer the types from the context:

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!")
}

Sometimes Go can say, “You have too many options, I can’t choose.” This happens when there is not enough information to draw a definite conclusion, or when your code is so cryptic that even a smart compiler is in a stupor:

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")
}

If Go can’t infer the type itself, you can hint by explicitly specifying the type when calling the function as in line 11 of the code above.

All this sounds cool, but you have to remember that generics are not just syntactic sugar, they can affect the performance of your code. Because each specialization of a generic function or type creates a new version of that function or type for each set of types used.

Generics in Go

Constraints and type sets

Constraints or type restrictions are a way to specify which data types can be used in our generics.

Let’s say you want to create a function that works with numbers. Without generics, you would have to write separate functions for int, float64, and so on. But with generics and constraints, you can do it in one go. Here’s how:

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 is a function that can add numbers of any type defined in Number. It is very. easy. One function for all digital types.

You don’t need to write separate functions for each data type.

comparable, any

comparable is a special interface that tells us that data types can be compared using operators == and !=:

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 uses comparable to create a universal method for removing duplicates from a slice.

Now let’s go to any. This is the basic interface in Go, which can basically be anything:

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 accepts a slice of elements of any type and prints them.

Universal functions

Let’s start with the classics – value exchange functions, Swap. Without generics, you would have to write a separate function for each data type, but with them everything is more convenient:

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 uses anywhich means it can work with any type of data. It’s like a universal key to the world of variables!

Let’s create something more complicated – a universal cache structure:

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 is a universal structure that can store values ​​of any type. You can create a cache for integers, strings, or anything you want.

Application

Slices:

The slices can stretch and contract to whatever you throw at them. Using generics, we can create universal functions for working with slices of any type, for example, we will create a filtering function:

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"]
}

Queues and stacks

The queue follows the FIFO principle and the stack follows the LIFO principle. With generics, we can make these structures work with any data type:

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 (стек пуст)
}

Trees

With Generics, we can create universal trees that can store any data. A universal binary search tree looks like this:

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
}

Sorting

QuickSort is a database that everyone knows. It is fast, efficient and, thanks to generics, can be adapted to work with any type of data:

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
}

Factories

With generics, we can create universal factories capable of generating objects of any type:

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
}

Decorators

The decorator allows you to dynamically add new functionality to objects. With generics, we can create universal decorators that work with any types:

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
}

Generics allow you to create more abstract and universal components.

Newbies, remember that generics are not a panacea and should not be used everywhere. Sometimes simple code is better.

You can read more about generics on the official golang website. And if you want to learn a specific programming language, check out OTUS’s catalog of online courses from industry-leading experts.

Keep coding, keep improvingand to new meetings in Habra.

and… Happy New Year! 🎄

Related posts