Generiki w Go: Przypadki użycia i wzorce

Bezpieczny typowo kod ponownego użycia z wykorzystaniem generyk w Go

Page content

Generiki w Go reprezentują jedną z najważniejszych funkcji językowych dodanych od Go 1.0. Wprowadzone w Go 1.18, generiki umożliwiają tworzenie kodu bezpiecznego pod względem typów, ponownego wykorzystania, który działa z wieloma typami bez poświęcania wydajności ani przejrzystości kodu.

W tym artykule omówimy praktyczne przypadki użycia, typowe wzorce oraz najlepsze praktyki dotyczące wykorzystania generik w Twoich programach napisanych w Go. Jeśli jesteś nowy w Go lub potrzebujesz przypomnienia podstaw, sprawdź nasz Go Cheatsheet dla istotnych konstrukcji językowych i składni.

a-lone-programmer

Zrozumienie generik w Go

Generiki w Go pozwalają tworzyć funkcje i typy parametryzowane przez parametry typu. To eliminuje potrzebę duplikowania kodu, gdy potrzebujesz tej samej logiki do działania z różnymi typami, jednocześnie zachowując bezpieczeństwo typów w czasie kompilacji.

Podstawowy składnia

Składnia generików korzysta z nawiasów kwadratowych do deklarowania parametrów typu:

// Funkcja generik
func Max[T comparable](a, b T) T {
    if a > b {
        return a
    }
    return b
}

// Użycie
maxInt := Max(10, 20)
maxString := Max("apple", "banana")

Ograniczenia typów

Ograniczenia typów określają, jakie typy mogą być używane w kodzie generik:

  • any: Dowolny typ (równoważny z interface{})
  • comparable: Typy, które obsługują operatory == i !=
  • Niestandardowe ograniczenia interfejsu: Definiuj własne wymagania
// Użycie niestandardowego ograniczenia
type Numeric interface {
    int | int8 | int16 | int32 | int64 | 
    uint | uint8 | uint16 | uint32 | uint64 | 
    float32 | float64
}

func Sum[T Numeric](numbers []T) T {
    var sum T
    for _, n := range numbers {
        sum += n
    }
    return sum
}

Typowe przypadki użycia

1. Struktury danych generik

Jednym z najbardziej przekonujących przypadków użycia generik jest tworzenie ponownie wykorzystywalnych struktur danych:

// Stos generik
type Stack[T any] struct {
    items []T
}

func NewStack[T any]() *Stack[T] {
    return &Stack[T]{items: make([]T, 0)}
}

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

func (s *Stack[T]) Pop() (T, bool) {
    if len(s.items) == 0 {
        var zero T
        return zero, false
    }
    item := s.items[len(s.items)-1]
    s.items = s.items[:len(s.items)-1]
    return item, true
}

// Użycie
intStack := NewStack[int]()
intStack.Push(42)
intStack.Push(100)

stringStack := NewStack[string]()
stringStack.Push("hello")

2. Narzędzia do manipulacji wycinkami

Generiki ułatwiają tworzenie ponownie wykorzystywalnych funkcji do manipulacji wycinkami:

// Funkcja Map
func Map[T, 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
}

// Funkcja Filter
func Filter[T any](slice []T, fn func(T) bool) []T {
    var result []T
    for _, v := range slice {
        if fn(v) {
            result = append(result, v)
        }
    }
    return result
}

// Funkcja Reduce
func Reduce[T, U any](slice []T, initial U, fn func(U, T) U) U {
    result := initial
    for _, v := range slice {
        result = fn(result, v)
    }
    return result
}

// Użycie
numbers := []int{1, 2, 3, 4, 5}
doubled := Map(numbers, func(n int) int { return n * 2 })
evens := Filter(numbers, func(n int) bool { return n%2 == 0 })
sum := Reduce(numbers, 0, func(acc, n int) int { return acc + n })

3. Narzędzia do pracy z mapami

Praca z mapami staje się bardziej bezpieczna pod względem typów dzięki generikom:

// Pobierz klucze mapy jako wycinek
func Keys[K comparable, V any](m map[K]V) []K {
    keys := make([]K, 0, len(m))
    for k := range m {
        keys = append(keys, k)
    }
    return keys
}

// Pobierz wartości mapy jako wycinek
func Values[K comparable, V any](m map[K]V) []V {
    values := make([]V, 0, len(m))
    for _, v := range m {
        values = append(values, v)
    }
    return values
}

// Bezpieczne pobranie wartości z mapy z wartością domyślną
func GetOrDefault[K comparable, V any](m map[K]V, key K, defaultValue V) V {
    if v, ok := m[key]; ok {
        return v
    }
    return defaultValue
}

4. Wzorzec generik Option

Wzorzec Option staje się bardziej elegancki dzięki generikom:

type Option[T any] struct {
    value *T
}

func Some[T any](value T) Option[T] {
    return Option[T]{value: &value}
}

func None[T any]() Option[T] {
    return Option[T]{value: nil}
}

func (o Option[T]) IsSome() bool {
    return o.value != nil
}

func (o Option[T]) IsNone() bool {
    return o.value == nil
}

func (o Option[T]) Unwrap() T {
    if o.value == nil {
        panic("attempted to unwrap None value")
    }
    return *o.value
}

func (o Option[T]) UnwrapOr(defaultValue T) T {
    if o.value == nil {
        return defaultValue
    }
    return *o.value
}

Zaawansowane wzorce

Kompozycja ograniczeń

Możesz komponować ograniczenia, aby stworzyć bardziej szczegółowe wymagania:

type Addable interface {
    int | int8 | int16 | int32 | int64 |
    uint | uint8 | uint16 | uint32 | uint64 |
    float32 | float64 | string
}

type Multiplicable interface {
    int | int8 | int16 | int32 | int64 |
    uint | uint8 | uint16 | uint32 | uint64 |
    float32 | float64
}

type Numeric interface {
    Addable
    Multiplicable
}

func Multiply[T Multiplicable](a, b T) T {
    return a * b
}

Interfejsy generik

Interfejsy mogą być również generikami, umożliwiając potężne abstrakcje:

type Repository[T any, ID comparable] interface {
    FindByID(id ID) (T, error)
    Save(entity T) error
    Delete(id ID) error
    FindAll() ([]T, error)
}

// Implementacja
type InMemoryRepository[T any, ID comparable] struct {
    data map[ID]T
}

func NewInMemoryRepository[T any, ID comparable]() *InMemoryRepository[T, ID] {
    return &InMemoryRepository[T, ID]{
        data: make(map[ID]T),
    }
}

func (r *InMemoryRepository[T, ID]) FindByID(id ID) (T, error) {
    if entity, ok := r.data[id]; ok {
        return entity, nil
    }
    var zero T
    return zero, fmt.Errorf("entity not found")
}

Wnioskowanie typów

Wnioskowanie typów w Go często pozwala pominąć jawne parametry typu:

// Wnioskowanie typów w akcji
numbers := []int{1, 2, 3, 4, 5}

// Nie ma potrzeby określania [int] - Go wnioskuje to
doubled := Map(numbers, func(n int) int { return n * 2 })

// Jawne parametry typu, gdy są potrzebne
result := Map[int, string](numbers, strconv.Itoa)

Najlepsze praktyki

1. Zacznij od prostego

Nie nadużywaj generik. Jeśli prosty interfejs lub konkretny typ wystarczy, wybierz to dla lepszej czytelności:

// Preferuj to dla prostych przypadków
func Process(items []Processor) {
    for _, item := range items {
        item.Process()
    }
}

// Zbyt generik - unikaj
func Process[T Processor](items []T) {
    for _, item := range items {
        item.Process()
    }
}

2. Używaj znaczących nazw ograniczeń

Nazwij swoje ograniczenia jasno, aby przekazać intencję:

// Dobre
type Sortable interface {
    int | int8 | int16 | int32 | int64 |
    uint | uint8 | uint16 | uint32 | uint64 |
    float32 | float64 | string
}

// Mniej jasne
type T interface {
    int | string
}

3. Dokumentuj złożone ograniczenia

Gdy ograniczenia stają się złożone, dodaj dokumentację:

// Numeric reprezentuje typy, które obsługują operacje arytmetyczne.
// W tym przypadku obejmuje wszystkie typy całkowite i zmiennoprzecinkowe.
type Numeric interface {
    int | int8 | int16 | int32 | int64 |
    uint | uint8 | uint16 | uint32 | uint64 |
    float32 | float64
}

4. Rozważ wydajność

Generiki są kompilowane do konkretnych typów, więc nie ma obciążenia czasu wykonywania. Jednak uważaj na rozmiar kodu, jeśli instancjonujesz wiele kombinacji typów:

// Każda kombinacja tworzy oddzielny kod
var intStack = NewStack[int]()
var stringStack = NewStack[string]()
var floatStack = NewStack[float64]()

Przykłady z życia

Budownicze zapytań do bazy danych

Generiki są szczególnie przydatne przy budowaniu budowniczych zapytań do bazy danych lub pracy z ORM. Pracując z bazami danych w Go, możesz znaleźć pomocny nasz porównanie Go ORMs dla PostgreSQL w zrozumieniu, jak generiki mogą poprawić bezpieczeństwo typów w operacjach baz danych.

type QueryBuilder[T any] struct {
    table string
    where []string
}

func (qb *QueryBuilder[T]) Where(condition string) *QueryBuilder[T] {
    qb.where = append(qb.where, condition)
    return qb
}

func (qb *QueryBuilder[T]) Find() ([]T, error) {
    // Implementacja
    return nil, nil
}

Zarządzanie konfiguracją

Przy budowaniu aplikacji CLI lub systemów konfiguracji, generiki mogą pomóc stworzyć bezpieczne pod względem typów ładowarki konfiguracji. Jeśli budujesz narzędzia wiersza poleceń, nasz przewodnik budowanie aplikacji CLI z Cobra & Viper pokazuje, jak generiki mogą poprawić obsługę konfiguracji.

type ConfigLoader[T any] struct {
    path string
}

func (cl *ConfigLoader[T]) Load() (T, error) {
    var config T
    // Załaduj i odserializuj konfigurację
    return config, nil
}

Biblioteki narzędziowe

Generiki świetnie sprawdzają się przy tworzeniu bibliotek narzędziowych, które działają z różnymi typami. Na przykład, przy generowaniu raportów lub pracy z różnymi formatami danych, generiki mogą zapewnić bezpieczeństwo typów. Nasz artykuł na temat generowania raportów PDF w Go pokazuje, jak generiki mogą być stosowane do narzędzi do generowania raportów.

Kod krytyczny pod względem wydajności

W aplikacjach krytycznych pod względem wydajności, takich jak funkcje bezserwerowe, generiki mogą pomóc utrzymać bezpieczeństwo typów bez obciążenia czasu wykonywania. Gdy rozważasz wybór języka dla aplikacji bezserwerowych, zrozumienie cech wydajnościowych jest kluczowe. Nasza analiza wydajności AWS Lambda w JavaScript, Pythonie i Golang pokazuje, jak wydajność Go w połączeniu z generikami może być korzystna.

Powszechne pułapki

1. Nadmierne ograniczanie typów

Unikaj nadmiernej restrykcyjności ograniczeń, jeśli nie jest to konieczne:

// Zbyt restrykcyjne
func Process[T int | string](items []T) { }

// Lepsze - bardziej elastyczne
func Process[T comparable](items []T) { }

2. Ignorowanie wnioskowania typów

Pozwól Go wnioskować typy, kiedy to możliwe:

// Niepotrzebne jawne typy
result := Max[int](10, 20)

// Lepsze - pozwól Go wnioskować
result := Max(10, 20)

3. Zapominanie o wartościach zerowych

Pamiętaj, że typy generik mają wartości zerowe:

func Get[T any](slice []T, index int) (T, bool) {
    if index < 0 || index >= len(slice) {
        var zero T  // Ważne: zwróć wartość zerową
        return zero, false
    }
    return slice[index], true
}

Podsumowanie

Generiki w Go oferują potężny sposób na tworzenie bezpiecznego pod względem typów, ponownie wykorzystywalnego kodu bez poświęcania wydajności ani czytelności. Rozumiejąc składnię, ograniczenia i typowe wzorce, możesz wykorzystać generiki, aby zmniejszyć duplikację kodu i poprawić bezpieczeństwo typów w swoich aplikacjach napisanych w Go.

Pamiętaj, aby stosować generiki ostrożnie – nie każdy przypadek wymaga ich. Kiedy wątpisz, preferuj prostsze rozwiązania, takie jak interfejsy lub konkretny typ. Jednak kiedy potrzebujesz pracy z wieloma typami, zachowując bezpieczeństwo typów, generiki są świetnym narzędziem w Twoim zestawie narzędzi w Go.

Zarówno w Go, jak i w jego dalszym rozwoju, generiki stają się niezbędną funkcją przy budowaniu nowoczesnych, bezpiecznych pod względem typów aplikacji. Niezależnie od tego, czy budujesz struktury danych, biblioteki narzędziowe, czy złożone abstrakcje, generiki mogą pomóc Ci napisać czystszy, bardziej utrzymany kod.

Przydatne linki i powiązane artykuły