MidТеория6 min

Основы дженериков

Параметры типов, синтаксис, вывод типов, ограничения и производительность дженериков

Зачем нужны дженерики?

До Go 1.18 для написания обобщённого кода приходилось использовать неудовлетворительные подходы:

До Go 1.18:
┌──────────────────────────────────────────────────────────────┐
│  1. interface{}     — потеря типобезопасности, type asserts  │
│  2. Кодогенерация   — сложная настройка, медленный билд     │
│  3. Copy-paste      — дублирование кода для каждого типа    │
└──────────────────────────────────────────────────────────────┘

Go 1.18+:
┌──────────────────────────────────────────────────────────────┐
│  Дженерики          — типобезопасность + обобщённый код      │
│                       без потери производительности          │
└──────────────────────────────────────────────────────────────┘

Проблема без дженериков

package main

import "fmt"

// Without generics: need separate function for each type
func minInt(a, b int) int {
    if a < b {
        return a
    }
    return b
}

func minFloat64(a, b float64) float64 {
    if a < b {
        return a
    }
    return b
}

func minString(a, b string) string {
    if a < b {
        return a
    }
    return b
}

// Or use interface{} — loses type safety
func minAny(a, b any) any {
    // How to compare? Type switch for every type?
    // This is fragile and error-prone
    switch a := a.(type) {
    case int:
        if a < b.(int) { // panic if b is not int
            return a
        }
        return b
    default:
        panic("unsupported type")
    }
}

func main() {
    fmt.Println(minInt(3, 7))
    fmt.Println(minFloat64(3.14, 2.72))
    fmt.Println(minString("go", "rust"))
}

Решение с дженериками

package main

import (
    "cmp"
    "fmt"
)

// Generic min function — works for any ordered type
// cmp.Ordered constraint: all types supporting <, >, <=, >=
func min[T cmp.Ordered](a, b T) T {
    if a < b {
        return a
    }
    return b
}

func main() {
    fmt.Println(min(3, 7))         // int: 3
    fmt.Println(min(3.14, 2.72))   // float64: 2.72
    fmt.Println(min("go", "rust")) // string: go

    // Note: since Go 1.21, use built-in min() and max()
    fmt.Println(min(10, 20)) // built-in
}

Заметка: Начиная с Go 1.21, функции min() и max() встроены в язык и работают с любым количеством аргументов типа cmp.Ordered. Но понимание дженериков остаётся критически важным для написания собственных обобщённых функций и типов.

Синтаксис параметров типов

Обобщённые функции

// Syntax: func Name[T constraint](params) returnType
func Print[T any](val T) {
    fmt.Println(val)
}

// Multiple type parameters
func Pair[K comparable, V any](key K, value V) map[K]V {
    return map[K]V{key: value}
}

// Type parameter used in return type
func Zero[T any]() T {
    var zero T
    return zero
}
package main

import "fmt"

// Map transforms a slice by applying a function to each element
func Map[T any, U any](slice []T, fn func(T) U) []U {
    result := make([]U, len(slice))
    for i, v := range slice {
        result[i] = fn(v)
    }
    return result
}

// Filter returns elements matching a predicate
func Filter[T any](slice []T, pred func(T) bool) []T {
    var result []T
    for _, v := range slice {
        if pred(v) {
            result = append(result, v)
        }
    }
    return result
}

func main() {
    nums := []int{1, 2, 3, 4, 5}

    // Map: int → string
    strings := Map(nums, func(n int) string {
        return fmt.Sprintf("num_%d", n)
    })
    fmt.Println(strings) // [num_1 num_2 num_3 num_4 num_5]

    // Filter: keep even numbers
    evens := Filter(nums, func(n int) bool {
        return n%2 == 0
    })
    fmt.Println(evens) // [2 4]

    // Map: string → int (length)
    words := []string{"hello", "go", "generics"}
    lengths := Map(words, func(s string) int {
        return len(s)
    })
    fmt.Println(lengths) // [5 2 8]
}

Обобщённые типы

package main

import "fmt"

// Stack is a generic LIFO data structure
type Stack[T any] struct {
    items []T
}

// Push adds an element to the top
func (s *Stack[T]) Push(val T) {
    s.items = append(s.items, val)
}

// Pop removes and returns the top element
func (s *Stack[T]) Pop() (T, bool) {
    if len(s.items) == 0 {
        var zero T
        return zero, false
    }
    last := len(s.items) - 1
    val := s.items[last]
    s.items = s.items[:last]
    return val, true
}

// Peek returns the top element without removing
func (s *Stack[T]) Peek() (T, bool) {
    if len(s.items) == 0 {
        var zero T
        return zero, false
    }
    return s.items[len(s.items)-1], true
}

// Len returns the number of elements
func (s *Stack[T]) Len() int {
    return len(s.items)
}

func main() {
    // Stack of integers
    intStack := &Stack[int]{}
    intStack.Push(1)
    intStack.Push(2)
    intStack.Push(3)

    for intStack.Len() > 0 {
        val, _ := intStack.Pop()
        fmt.Println(val) // 3, 2, 1
    }

    // Stack of strings — same code, different type
    strStack := &Stack[string]{}
    strStack.Push("hello")
    strStack.Push("world")

    if top, ok := strStack.Peek(); ok {
        fmt.Println("Top:", top) // world
    }
}

Обобщённые интерфейсы

package main

import "fmt"

// Container is a generic interface
type Container[T any] interface {
    Add(T)
    Get(index int) (T, bool)
    Len() int
}

// SliceContainer implements Container using a slice
type SliceContainer[T any] struct {
    items []T
}

func (c *SliceContainer[T]) Add(val T) {
    c.items = append(c.items, val)
}

func (c *SliceContainer[T]) Get(index int) (T, bool) {
    if index < 0 || index >= len(c.items) {
        var zero T
        return zero, false
    }
    return c.items[index], true
}

func (c *SliceContainer[T]) Len() int {
    return len(c.items)
}

// process works with any Container implementation
func process[T any](c Container[T]) {
    fmt.Printf("Container has %d items\n", c.Len())
}

func main() {
    c := &SliceContainer[int]{}
    c.Add(10)
    c.Add(20)
    c.Add(30)

    process[int](c) // Container has 3 items

    if val, ok := c.Get(1); ok {
        fmt.Println("Item at index 1:", val) // 20
    }
}

Вывод типов (Type Inference)

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

package main

import "fmt"

func Contains[T comparable](slice []T, target T) bool {
    for _, v := range slice {
        if v == target {
            return true
        }
    }
    return false
}

func main() {
    nums := []int{1, 2, 3, 4, 5}

    // Explicit type argument
    fmt.Println(Contains[int](nums, 3)) // true

    // Type inference — T is inferred from arguments
    fmt.Println(Contains(nums, 3)) // true — T inferred as int

    // Works with strings too
    words := []string{"go", "rust", "python"}
    fmt.Println(Contains(words, "go"))   // true
    fmt.Println(Contains(words, "java")) // false
}

Когда вывод типов не работает

package main

import "fmt"

// Return type depends on type parameter, cannot be inferred from args
func Zero[T any]() T {
    var zero T
    return zero
}

func main() {
    // Must specify type explicitly — no arguments to infer from
    n := Zero[int]()
    s := Zero[string]()
    b := Zero[bool]()

    fmt.Println(n, s, b) // 0  false
}

Инстанциация (Instantiation)

Когда вы используете дженерик-функцию или тип с конкретным типом, Go создаёт инстанциацию -- конкретную версию кода:

package main

import "fmt"

func PrintSlice[T any](s []T) {
    for _, v := range s {
        fmt.Printf("%v ", v)
    }
    fmt.Println()
}

func main() {
    // Each call instantiates the function for a specific type
    PrintSlice([]int{1, 2, 3})        // instantiation: PrintSlice[int]
    PrintSlice([]string{"a", "b"})    // instantiation: PrintSlice[string]
    PrintSlice([]float64{1.1, 2.2})   // instantiation: PrintSlice[float64]

    // You can also get a reference to a specific instantiation
    printInts := PrintSlice[int]
    printInts([]int{10, 20, 30})
}

Ограничение: методы не могут иметь свои параметры типов

В Go методы не могут объявлять собственные параметры типов. Они могут использовать только параметры, объявленные на уровне типа:

// VALID: method uses type parameter from the type
type Stack[T any] struct {
    items []T
}

func (s *Stack[T]) Push(val T) {
    s.items = append(s.items, val)
}

// INVALID: method cannot declare its own type parameter
// func (s *Stack[T]) Convert[U any](fn func(T) U) []U { ... }
// Compile error: method must have no type parameters

// WORKAROUND: Use a top-level function instead
func Convert[T any, U any](s *Stack[T], fn func(T) U) []U {
    result := make([]U, len(s.items))
    for i, v := range s.items {
        result[i] = fn(v)
    }
    return result
}

Когда использовать дженерики

┌────────────────────────────────────────────────────────────────┐
│  Используйте дженерики когда:                                  │
├────────────────────────────────────────────────────────────────┤
│  1. Функция/тип работает с разными типами одинаковым образом  │
│  2. Есть дублирование кода, отличающегося только типами       │
│  3. Нужна типобезопасная коллекция/контейнер                  │
│  4. Утилитарные функции (map, filter, contains, etc.)         │
└────────────────────────────────────────────────────────────────┘

┌────────────────────────────────────────────────────────────────┐
│  НЕ используйте дженерики когда:                               │
├────────────────────────────────────────────────────────────────┤
│  1. Поведение отличается для разных типов → интерфейсы        │
│  2. Достаточно обычного интерфейса (io.Reader, fmt.Stringer)  │
│  3. Один конкретный тип достаточен                            │
│  4. Сложность перевешивает пользу                             │
└────────────────────────────────────────────────────────────────┘

Правило Роба Пайка: "Don't use generics until you know you need them." Начните с конкретных типов, переходите к интерфейсам, и только потом -- к дженерикам, если код действительно дублируется.

Производительность: GCShape Stenciling

В отличие от C++ (мономорфизация -- отдельная копия кода для каждого типа), Go использует GCShape Stenciling + словари. Это компромисс:

C++ Templates:       Полная мономорфизация
                     + Максимальная производительность
                     - Раздутый бинарник (code bloat)
                     - Долгая компиляция

Java Generics:       Стирание типов (type erasure)
                     + Один экземпляр кода
                     - Боксинг, нет типобезопасности в рантайме

Go Generics:         GCShape Stenciling
                     + Одна реализация для типов с одинаковой "формой GC"
                     + Умеренный размер бинарника
                     - Небольшие накладные расходы (dictionary lookup)

GCShape -- это группировка типов по их поведению для сборщика мусора:

  • Все указатели имеют одну GC-форму
  • Каждый примитивный тип (int, float64, etc.) имеет свою GC-форму
// For pointer types — ONE instantiation shared:
type Cache[T any] struct {
    items []*T // *int, *string, *MyStruct — same GC shape
}

// For value types — separate instantiation per type:
type Pair[T any] struct {
    First  T // int, float64, string — different GC shapes
    Second T
}

На практике производительность дженериков в Go очень близка к конкретным типам. Разница в большинстве случаев менее 5% и заметна только в горячих циклах.

Проверь себя

Когда Go может вывести (infer) параметры типов?

Могут ли методы в Go объявлять собственные параметры типов?

Какой constraint нужно использовать для параметра типа, который может быть любым типом?

Как Go реализует дженерики на уровне компиляции?

Какая версия Go добавила поддержку дженериков?