Go Generics: Anwendungsfälle und Muster

Typsichere wiederverwendbare Code mit Go-Generics

Inhaltsverzeichnis

Generics in Go stellen eines der bedeutendsten Sprachmerkmale dar, die seit Go 1.0 hinzugefügt wurden. Eingeführt in Go 1.18 ermöglichen Generics Ihnen, typensicheren, wiederverwendbaren Code zu schreiben, der mit mehreren Typen arbeitet, ohne Leistung oder Code-Klarheit zu opfern.

Dieser Artikel untersucht praktische Anwendungsfälle, häufige Muster und bewährte Verfahren zur Nutzung von Generics in Ihren Go-Programmen. Wenn Sie neu in Go sind oder eine Auffrischung der Grundlagen benötigen, werfen Sie einen Blick auf unseren Go Cheatsheet für wesentliche Sprachkonstrukte und Syntax.

a-lone-programmer

Verständnis von Go Generics

Generics in Go ermöglichen Ihnen, Funktionen und Typen zu schreiben, die durch Typparameter parameterisiert sind. Dies eliminiert die Notwendigkeit von Code-Duplikation, wenn Sie dieselbe Logik für verschiedene Typen benötigen, während die Typensicherheit zur Compile-Zeit aufrechterhalten wird.

Grundlegende Syntax

Die Syntax für Generics verwendet eckige Klammern, um Typparameter zu deklarieren:

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

// Verwendung
maxInt := Max(10, 20)
maxString := Max("apple", "banana")

Typbeschränkungen

Typbeschränkungen legen fest, welche Typen mit Ihrem generischen Code verwendet werden können:

  • any: Jeder Typ (äquivalent zu interface{})
  • comparable: Typen, die die Operatoren == und != unterstützen
  • Benutzerdefinierte Schnittstellenbeschränkungen: Definieren Sie Ihre eigenen Anforderungen
// Verwendung einer benutzerdefinierten Beschränkung
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
}

Häufige Anwendungsfälle

1. Generische Datenstrukturen

Einer der überzeugendsten Anwendungsfälle für Generics ist die Erstellung wiederverwendbarer Datenstrukturen:

// Generischer Stack
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
}

// Verwendung
intStack := NewStack[int]()
intStack.Push(42)
intStack.Push(100)

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

2. Slice-Dienstprogramme

Generics machen es einfach, wiederverwendbare Slice-Manipulationsfunktionen zu schreiben:

// Map-Funktion
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
}

// Filter-Funktion
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
}

// Reduce-Funktion
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
}

// Verwendung
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. Generische Map-Dienstprogramme

Die Arbeit mit Maps wird mit Generics typensicherer:

// Map-Schlüssel als Slice erhalten
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
}

// Map-Werte als Slice erhalten
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
}

// Sicheres Map-get mit Standardwert
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. Generisches Option-Muster

Das Option-Muster wird mit Generics eleganter:

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
}

Fortgeschrittene Muster

Beschränkungszusammensetzung

Sie können Beschränkungen zusammensetzen, um spezifischere Anforderungen zu erstellen:

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
}

Generische Schnittstellen

Schnittstellen können ebenfalls generisch sein und ermöglichen mächtige Abstraktionen:

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

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

Typinferenz

Go’s Typinferenz ermöglicht Ihnen oft, explizite Typparameter wegzulassen:

// Typinferenz in Aktion
numbers := []int{1, 2, 3, 4, 5}

// Keine Notwendigkeit, [int] anzugeben - Go inferiert es
doubled := Map(numbers, func(n int) int { return n * 2 })

// Explizite Typparameter, wenn nötig
result := Map[int, string](numbers, strconv.Itoa)

Bewährte Verfahren

1. Einfach anfangen

Übertreiben Sie es nicht mit Generics. Wenn eine einfache Schnittstelle oder ein konkreter Typ funktionieren würde, bevorzugen Sie diese für eine bessere Lesbarkeit:

// Bevorzugen Sie dies für einfache Fälle
func Process(items []Processor) {
    for _, item := range items {
        item.Process()
    }
}

// Zu generisch - vermeiden
func Process[T Processor](items []T) {
    for _, item := range items {
        item.Process()
    }
}

2. Verwenden Sie aussagekräftige Beschränkungsnamen

Benennen Sie Ihre Beschränkungen klar, um die Absicht zu kommunizieren:

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

// Weniger klar
type T interface {
    int | string
}

3. Dokumentieren Sie komplexe Beschränkungen

Wenn Beschränkungen komplex werden, fügen Sie Dokumentation hinzu:

// Numeric stellt Typen dar, die arithmetische Operationen unterstützen.
// Dazu gehören alle Ganzzahl- und Gleitkommatypen.
type Numeric interface {
    int | int8 | int16 | int32 | int64 |
    uint | uint8 | uint16 | uint32 | uint64 |
    float32 | float64
}

4. Berücksichtigen Sie die Leistung

Generics werden zu konkreten Typen kompiliert, sodass es keine Laufzeit-Overheads gibt. Seien Sie jedoch bewusst, wenn Sie viele Typkombinationen instanziieren:

// Jede Kombination erstellt separaten Code
var intStack = NewStack[int]()
var stringStack = NewStack[string]()
var floatStack = NewStack[float64]()

Echtweltanwendungen

Datenbank-Query-Builder

Generics sind besonders nützlich beim Erstellen von Datenbank-Query-Buildern oder beim Arbeiten mit ORMs. Wenn Sie mit Datenbanken in Go arbeiten, könnte Ihnen unser Vergleich von Go ORMs für PostgreSQL helfen, um zu verstehen, wie Generics die Typensicherheit in Datenbankoperationen verbessern können.

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) {
    // Implementierung
    return nil, nil
}

Konfigurationsmanagement

Beim Erstellen von CLI-Anwendungen oder Konfigurationssystemen können Generics helfen, typensichere Konfigurationslader zu erstellen. Wenn Sie Befehlszeilen-Tools erstellen, zeigt unser Leitfaden zu Erstellung von CLI-Anwendungen mit Cobra & Viper wie Generics die Konfigurationsverwaltung verbessern können.

type ConfigLoader[T any] struct {
    path string
}

func (cl *ConfigLoader[T]) Load() (T, error) {
    var config T
    // Konfiguration laden und entpacken
    return config, nil
}

Dienstprogrammbibliotheken

Generics glänzen bei der Erstellung von Dienstprogrammbibliotheken, die mit verschiedenen Typen arbeiten. Zum Beispiel beim Generieren von Berichten oder Arbeiten mit verschiedenen Datenformaten können Generics Typensicherheit bieten. Unser Artikel über Generieren von PDF-Berichten in Go zeigt, wie Generics auf Berichterstellungs-Dienstprogramme angewendet werden können.

Leistungskritischer Code

In leistungskritischen Anwendungen wie serverlosen Funktionen können Generics helfen, Typensicherheit ohne Laufzeit-Overhead beizubehalten. Wenn Sie Sprachauswahlen für serverlose Anwendungen in Betracht ziehen, ist das Verständnis der Leistungscharakteristika entscheidend. Unsere Analyse der AWS Lambda-Leistung in JavaScript, Python und Golang demonstriert, wie Go’s Leistung, kombiniert mit Generics, vorteilhaft sein kann.

Häufige Fallstricke

1. Übermäßige Einschränkung von Typen

Vermeiden Sie es, Einschränkungen zu streng zu machen, wenn dies nicht notwendig ist:

// Zu streng
func Process[T int | string](items []T) { }

// Besser - flexibler
func Process[T comparable](items []T) { }

2. Ignorieren der Typinferenz

Lassen Sie Go die Typen bei Möglichkeit ableiten:

// Unnötige explizite Typen
result := Max[int](10, 20)

// Besser - lassen Sie Go ableiten
result := Max(10, 20)

3. Vergessen der Nullwerte

Denken Sie daran, dass generische Typen Nullwerte haben:

func Get[T any](slice []T, index int) (T, bool) {
    if index < 0 || index >= len(slice) {
        var zero T  // Wichtig: Nullwert zurückgeben
        return zero, false
    }
    return slice[index], true
}

Fazit

Generics in Go bieten eine leistungsstarke Möglichkeit, typsicheren, wiederverwendbaren Code zu schreiben, ohne Leistung oder Lesbarkeit zu opfern. Durch das Verständnis der Syntax, Einschränkungen und häufigen Muster können Sie Generics nutzen, um Code-Duplikate zu reduzieren und die Typsicherheit in Ihren Go-Anwendungen zu verbessern.

Denken Sie daran, Generics mit Bedacht einzusetzen – nicht jede Situation erfordert sie. Bei Zweifeln bevorzugen Sie einfachere Lösungen wie Schnittstellen oder konkrete Typen. Wenn Sie jedoch mit mehreren Typen arbeiten müssen, während Sie die Typsicherheit beibehalten, sind Generics ein hervorragendes Werkzeug in Ihrem Go-Werkzeugkasten.

Da sich Go weiterentwickelt, werden Generics zu einem wesentlichen Merkmal für den Aufbau moderner, typsicherer Anwendungen. Ob Sie Datenstrukturen, Utility-Bibliotheken oder komplexe Abstraktionen erstellen – Generics können Ihnen helfen, sauberen, wartbaren Code zu schreiben.