Generics in Go: type parameter anhand von Beispielen

1. Übersicht

Ab Go 1.18 können Sie Funktionen und Datentypen mit type parameter schreiben. Dadurch müssen Sie nicht dieselbe Logik für int, float64, string wiederholen und nicht mehr von interface{} casten.

Dieser Artikel geht vom ursprünglichen Problem zu generischen Funktionen, constraints und der Tilde-Notation ~ über. Dann behandeln wir generische Typen wie Stack[T] und Cache[K, V], das Schreiben von Tests sowie häufige Fehler. Nach der Lektüre wissen Sie, wann Sie generics verwenden sollten und wann ein normales interface ausreicht.

Alle Beispiele verwenden Go 1.22 oder höher. Das Package cmp ist ab Go 1.21 verfügbar. Minimale go.mod:

module example.com/shop

go 1.22

Keine externen Bibliotheken erforderlich. Alles in diesem Artikel ist in der Standardbibliothek enthalten.

2. Das Problem vor Generics

Angenommen, Sie müssen die Gesamtmenge der Artikel (int) und den Gesamtbetrag (float64) berechnen. Vor Go 1.18 schrieben Sie normalerweise zwei identische Funktionen:

func SumInts(xs []int) int {
	var total int
	for _, x := range xs {
		total += x
	}
	return total
}

func SumFloats(xs []float64) float64 {
	var total float64
	for _, x := range xs {
		total += x
	}
	return total
}

Die Alternative bestand darin, []interface{} zu akzeptieren und type switch zu verwenden. Dieser Ansatz verliert type safety. Fehler erscheinen nur zur Laufzeit, und Sie müssen überall beim Funktionsaufruf casten.

Generics lösen genau dieses Problem: Sie schreiben die Logik einmal, und der Compiler prüft die Typen für Sie.

3. Die erste generische Funktion

3.1. Syntax von type parameter

Type parameter werden in eckigen Klammern direkt nach dem Funktionsnamen deklariert:

package main

import "fmt"

type Number interface {
	~int | ~int64 | ~float64
}

func Sum[T Number](xs []T) T {
	var total T
	for _, x := range xs {
		total += x
	}
	return total
}

func main() {
	quantities := []int{2, 1, 5}
	prices := []float64{120.5, 99.9, 15}

	fmt.Println(Sum(quantities))
	fmt.Println(Sum(prices))
}

Erklärung der einzelnen Teile:

  • [T Number] deklariert den type parameter T. T muss das constraint Number erfüllen.
  • Number ist eine interface, die eine type set enthält: int, int64, float64 und Typen, die von ihnen abgeleitet sind (die Bedeutung von ~ finden Sie in Abschnitt 4.3).
  • var total T erstellt den zero value von T, also 0 oder 0.0.
  • Der Operator += ist gültig, da alle Typen im type set Addition unterstützen.

Ausgabe:

8
235.4

3.2. Type Inference

Haben Sie bemerkt, dass wir Sum(quantities) aufrufen und nicht Sum[int](quantities)? Der Compiler leitet T aus den Argumenten ab. Das ist type inference.

Sie müssen den Typ nur explizit angeben, wenn der Compiler nicht genug Informationen hat, um ihn herzuleiten, z.B. wenn der type parameter nur im Rückgabetyp vorkommt:

func Zero[T any]() T {
	var z T
	return z
}

func main() {
	fmt.Println(Zero[string]() == "") // true
}

3.3. Mehrere type parameter: Map und Filter

Dies sind die zwei am häufigsten verwendeten Utility-Funktionen. Wir wenden sie auf eine Liste von Order an:

package main

import "fmt"

type Order struct {
	ID       string
	Customer string
	Total    float64
}

func Map[T, U any](xs []T, f func(T) U) []U {
	out := make([]U, 0, len(xs))
	for _, x := range xs {
		out = append(out, f(x))
	}
	return out
}

func Filter[T any](xs []T, keep func(T) bool) []T {
	var out []T
	for _, x := range xs {
		if keep(x) {
			out = append(out, x)
		}
	}
	return out
}

func main() {
	orders := []Order{
		{"A01", "Lan", 250_000},
		{"A02", "Minh", 1_200_000},
		{"A03", "Lan", 890_000},
	}

	big := Filter(orders, func(o Order) bool { return o.Total >= 500_000 })
	ids := Map(big, func(o Order) string { return o.ID })

	fmt.Println(ids)
}
  • Map[T, U any] hat zwei type parameter, daher können Ein- und Ausgabetyp unterschiedlich sein.
  • any ist ein alias für interface{}. Es erlaubt jeden Typ, aber Sie können auf dem Wert keine Operatoren aufrufen außer Zuweisen und Weitergeben.
  • Der Compiler leitet T = Order und U = string aus der Funktion func(o Order) string ab.

Ausgabe:

[A02 A03]

4. Constraint: Einschränkung, was T kann

Das constraint bestimmt, was Sie mit einem Wert vom Typ T in der Funktion tun können.

4.1. comparable

comparable ist ein vordefiniertes constraint. Es erlaubt die Verwendung von == und != und ist auch die Bedingung für T, um ein key einer map zu sein:

func Unique[T comparable](xs []T) []T {
	seen := make(map[T]struct{}, len(xs))
	var out []T
	for _, x := range xs {
		if _, ok := seen[x]; !ok {
			seen[x] = struct{}{}
			out = append(out, x)
		}
	}
	return out
}

// Unique([]string{"Lan", "Minh", "Lan"}) => [Lan Minh]

Das Struct Order oben ist auch comparable, da alle seine Felder vergleichbar sind. Ein Struct, das eine slice, map oder func enthält, erfüllt nicht comparable, und der Compiler meldet einen Fehler.

4.2. cmp.Ordered

Wenn Sie Größer- oder Kleinvergleiche brauchen, verwenden Sie cmp.Ordered aus der Standardbibliothek:

import "cmp"

func MaxBy[T any, K cmp.Ordered](xs []T, key func(T) K) (T, bool) {
	var best T
	if len(xs) == 0 {
		return best, false
	}
	best = xs[0]
	for _, x := range xs[1:] {
		if key(x) > key(best) {
			best = x
		}
	}
	return best, true
}

// top, _ := MaxBy(orders, func(o Order) float64 { return o.Total })
// top.ID => "A02"
  • K cmp.Ordered erlaubt die Verwendung von > auf dem Schlüsselwert.
  • Die Funktion gibt (T, bool) zurück, um leere slices zu handhaben, ohne zu paniken.

Hinweis: Bei einfachen numerischen slices müssen Sie nicht selbst schreiben. Go 1.21 hat die built-in Funktionen max/min, und das Package slices hat bereits slices.Max, slices.Contains, slices.Sort. Prüfen Sie slices und maps, bevor Sie eigene generische Utilities schreiben.

4.3. Die Tilde-Notation ~

In der Praxis definieren Sie häufig einen eigenen Typ für Geld:

type VND int64

func main() {
	payments := []VND{150_000, 350_000}
	fmt.Println(Sum(payments)) // 500000
}

Diese Zeile wird nur kompiliert, weil ~int64 in Number vorhanden ist. Die Notation ~int64 bedeutet „jeder Typ mit underlying type int64“. Wenn Sie int64 ohne ~ schreiben, erfüllt VND das constraint nicht, und der Compiler meldet:

VND does not satisfy Number (possibly missing ~ for int64 in Number)

Einfache Regel: Beim Schreiben von Constraints für Grundtypen sollten Sie fast immer ~ verwenden.

4.4. Constraint mit Methoden

Ein constraint kann auch Methoden verlangen, wie eine normale interface:

type Identifiable interface {
	comparable
	Key() string
}

Jeder Typ, der Identifiable erfüllen soll, muss sowohl vergleichbar als auch eine Methode Key() string haben.

5. Generische Typen

5.1. Stack[T]

Type parameter sind nicht nur für Funktionen, sondern auch für Structs:

type Stack[T any] struct {
	items []T
}

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

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

func (s *Stack[T]) Len() int { return len(s.items) }

Verwendungsbeispiel:

var history Stack[Order]
history.Push(Order{ID: "A01"})
history.Push(Order{ID: "A02"})

o, _ := history.Pop()
fmt.Println(o.ID, history.Len()) // A02 1
  • Der receiver wird als *Stack[T] geschrieben und muss [T] enthalten.
  • Bei der Deklaration einer Variable müssen Sie den Typ explizit angeben: Stack[Order]. Es gibt hier keine Inferenz.
  • Der zero value von Stack[Order] funktioniert sofort, da append gut mit nil slices funktioniert.

5.2. Cache[K, V] sicher für mehrere goroutine

Ein praxisorientiertes Beispiel: ein In-Memory-Cache, der von Customer und Order verwendet wird.

package cache

import "sync"

type Cache[K comparable, V any] struct {
	mu   sync.RWMutex
	data map[K]V
}

func New[K comparable, V any]() *Cache[K, V] {
	return &Cache[K, V]{data: make(map[K]V)}
}

func (c *Cache[K, V]) Put(key K, value V) {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.data[key] = value
}

func (c *Cache[K, V]) Get(key K) (V, bool) {
	c.mu.RLock()
	defer c.mu.RUnlock()
	v, ok := c.data[key]
	return v, ok
}
type Customer struct {
	ID   int
	Name string
}

customers := cache.New[int, Customer]()
customers.Put(1, Customer{ID: 1, Name: "Lan"})

orders := cache.New[string, Order]()
orders.Put("A01", Order{ID: "A01", Total: 250_000})

c, ok := customers.Get(1)
fmt.Println(c.Name, ok) // Lan true

Eine Implementation bedient zwei Datentypen. customers.Get gibt genau Customer zurück, keine type assertion nötig. Falls Sie versehentlich einen Order in customers einfügen, wird der Code nicht kompiliert.

6. Tests für generischen Code schreiben

Go verwendet das Standard-Package testing. Table-driven Tests funktionieren normalerweise mit generischen Funktionen. Jede Testtabelle entspricht einem konkreten Typ:

package main

import "testing"

func TestSum(t *testing.T) {
	ints := []struct {
		name string
		in   []int
		want int
	}{
		{"empty", nil, 0},
		{"positive", []int{2, 1, 5}, 8},
		{"mixed", []int{10, -3}, 7},
	}
	for _, tc := range ints {
		t.Run(tc.name, func(t *testing.T) {
			if got := Sum(tc.in); got != tc.want {
				t.Errorf("Sum(%v) = %d, want %d", tc.in, got, tc.want)
			}
		})
	}

	if got := Sum([]VND{150_000, 350_000}); got != 500_000 {
		t.Errorf("Sum VND = %d, want 500000", got)
	}
}

func TestStackPopEmpty(t *testing.T) {
	var s Stack[string]
	v, ok := s.Pop()
	if ok || v != "" {
		t.Fatalf("Pop() on empty = (%q, %v), want (\"\", false)", v, ok)
	}
}

Tests ausführen:

go test ./... -v
=== RUN   TestSum
=== RUN   TestSum/empty
=== RUN   TestSum/positive
=== RUN   TestSum/mixed
--- PASS: TestSum (0.00s)
=== RUN   TestStackPopEmpty
--- PASS: TestStackPopEmpty (0.00s)
PASS

Testen Sie mindestens einen abgeleiteten Typ wie VND. Dies ist der schnellste Weg, um fehlende ~ in Constraints zu erkennen.

7. Häufige Fehler und wann man Generics nicht verwenden sollte

Methoden können keine eigenen type parameter haben. Der folgende Code wird nicht kompiliert:

// Fehler: Methoden können keine type parameter haben
func (s *Stack[T]) Map[U any](f func(T) U) *Stack[U] { ... }

Schreiben Sie es stattdessen als top-level Funktion: func MapStack[T, U any](s *Stack[T], f func(T) U) *Stack[U].

Verwenden Sie kein type switch direkt auf T. Wenn Sie je nach Typ verzweigen möchten, müssen Sie zuerst zu any konvertieren: switch v := any(x).(type) { ... }. Wenn Sie das häufig tun, ist generics möglicherweise nicht das richtige Werkzeug.

Ein Constraint mit type set kann nicht als Variablentyp verwendet werden. var n Number meldet einen Fehler. Interfaces mit | oder ~ können nur als Constraint verwendet werden.

comparable mit interface kann panic auslösen. Wenn K ein interface type ist und der dynamische Wert darin eine slice ist, können Vergleiche oder die Verwendung als map key zur Laufzeit paniken.

Hinweis: Generics sind nicht automatisch schneller als interfaces. Der Go-Compiler verwendet die Technik „GC shape stenciling” mit einem dictionary. Verschiedene Zeigertypen können denselben Code gemeinsam nutzen, und method calls über constraints haben immer noch indirekten overhead. Wenn Performance wichtig ist, messen Sie mit benchmarks, anstatt zu spekulieren.

Wann man Generics NICHT verwenden sollte:

  • Wenn die Logik von Verhalten (Methoden) abhängt, nicht vom Typ. Zum Beispiel nimmt PaymentService einen Notifier mit einer Methode Send. Interfaces sind normalerweise prägnanter und lesbarer.
  • Wenn nur ein konkreter Typ die Funktion verwendet. Machen Sie es nicht „generisch für den Fall, dass es später nötig ist”.
  • Wenn slices, maps, cmp bereits die Funktionen haben, die Sie brauchen.

Eine leicht zu merkende Regel: Wenn Sie denselben Code für mehrere Typen schreiben und dieser nur im Typnamen unterscheidet, ist es Zeit für generics.

8. Fazit

Dieser Artikel behandelt die Hauptteile von generics in Go:

  • Deklarieren Sie type parameter [T C] und lassen Sie den Compiler den Typ selbst ableiten.
  • Verwenden Sie die Constraints any, comparable, cmp.Ordered und definieren Sie eigene Constraints mit ~, um abgeleitete Typen wie VND zu unterstützen.
  • Erstellen Sie generische Typen Stack[T] und Cache[K, V], die sicher für mehrere goroutine sind.
  • Schreiben Sie table-driven Tests für generische Funktionen.
  • Erkennen Sie die Grenzen: Methoden können keine eigenen type parameter haben, Constraints sind keine Variablentypen, und generics ersetzen interfaces nicht.

Beginnen Sie dort, wo Code wirklich in Ihrem Projekt wiederholt wird. Schreiben Sie generics minimal, bevorzugen Sie die Standardbibliothek, und testen Sie immer mit mindestens zwei verschiedenen Typen.

golanggenericstype parameterconstraint