Type-Parameter und Constraints sind die Werkzeuge — die Patterns sind das, wofür man sie tatsächlich tippt. Dieser Artikel führt dich durch den kanonischen Katalog: Map, Filter, Reduce über Slices, danach Find, GroupBy, Keys/Values, danach die Wrapper-Typen Result und Option, schließlich generische Container — Pool, LRU-Cache, Set. Jedes Beispiel kommt mit vollständigem Code, der Erklärung der Type-Parameter-Wahl und einem Vergleich mit dem, was die Standard-Bibliothek seit Go 1.21/1.23 schon mitbringt. Am Ende weißt du, welches Pattern du selbst schreibst und welches du aus slices, maps oder sync ziehst.
Warum Generic-Helpers fast immer Slices oder Maps betreffen
Wenn du dir die generischen Funktionen in der Standard-Bibliothek anschaust — slices.Sort, slices.IndexFunc, maps.Keys, maps.Clone — fällt ein Muster auf: praktisch jede generische Funktion arbeitet entweder auf einem Slice oder auf einer Map. Das ist kein Zufall. Sammlungen sind genau die Stellen, an denen Pre-Generics-Go entweder interface{} mit Type-Assertions oder Code-Generation gebraucht hat. Ein min(x, y) für int und float64 ist nervig, aber überlebbar — ein Map-Helper, der für jeden Element-Typ existiert, war vor Go 1.18 schlicht nicht idiomatisch machbar.
Die Praxis-Frage lautet also: Was packe ich in eine Generic-Funktion? Antwort: typischerweise die Operation, die du sonst pro Typ duplizieren würdest. Slice-Transformation, Map-Iteration, Container-Wrapper, Caching-Logik, Pool-Verwaltung. Jeder dieser Bereiche bekommt unten sein eigenes Beispiel. Und weil die Stdlib seit Go 1.21 selbst sehr viele dieser Helpers liefert, fokussiert dieser Artikel zweierlei: erstens, welche Stdlib-Funktion das Pattern bereits abdeckt, zweitens, wie du den eigenen Code schreibst, wenn die Stdlib nicht genau das richtige liefert.
Was die Stdlib bereits anbietet — slices und maps
Bevor du selbst Generics tippst, lohnt der Blick in slices und maps. Die wichtigsten Funktionen kennt jeder, der seit Go 1.21 mit Slices arbeitet: slices.Sort sortiert ein Slice mit Ordering-Constraint, slices.SortFunc sortiert mit Custom-Vergleicher, slices.Index und slices.IndexFunc suchen, slices.Contains prüft Mitgliedschaft, slices.Equal vergleicht zwei Slices elementweise. Das maps-Paket liefert seit Go 1.23 maps.Keys, maps.Values, maps.Clone und maps.Equal — Keys und Values geben heute einen iter.Seq zurück, nicht mehr ein Slice.
Das folgende Beispiel zeigt drei dieser Funktionen in Kombination — sortieren, suchen, vergleichen — und macht damit zugleich klar, wie ein ~[]E-Constraint aussieht, weil alle drei Funktionen ihn benutzen. Du wirst diesem Konstrukt in jedem eigenen Generic-Helper über Slices wieder begegnen.
package main
import (
"fmt"
"slices"
)
type UserID int64
func main() {
ids := []UserID{42, 7, 99, 13}
// ~[]E erlaubt, dass eine eigene Slice-Variante (z. B. []UserID)
// direkt akzeptiert wird — der Rückgabewert behält den Typ.
slices.Sort(ids)
fmt.Println(ids) // [7 13 42 99]
// Index nutzt comparable für E — UserID ist ein int und damit comparable.
fmt.Println(slices.Index(ids, UserID(42))) // 2
// IndexFunc lässt den Constraint auf any los und arbeitet per Prädikat.
i := slices.IndexFunc(ids, func(u UserID) bool { return u > 50 })
fmt.Println(i) // 3
}[7 13 42 99]
2
3Schau dir die Type-Inference an: nirgendwo steht ein explizites Type-Argument. Der Compiler sieht das Argument []UserID und leitet daraus S = []UserID und E = UserID ab — das ist Constraint Type Inference, die im Generics-Intro-Blogpost als Schlüssel-Mechanismus beschrieben wird. Ohne sie müsstest du slices.Sort[[]UserID, UserID](ids) schreiben, was nutzlos lang wäre. Mit ihr liest sich der Aufruf wie pre-Generics-Code, ist aber typsicher und voll wiederverwendbar.
Map[A, B any] — Element-Transformation eines Slices
Map ist das Gateway-Pattern für funktionale Slice-Helpers. Es nimmt ein Slice vom Typ []A, eine Transformations-Funktion func(A) B und gibt ein Slice []B zurück. Die Stdlib bietet keine slices.Map an — der Grund laut der Issue-Diskussion ist, dass eine simple for-Schleife mit append praktisch gleich lesbar ist und der Generic-Aufruf wenig spart. Trotzdem ist Map für die Komposition mit Filter und Reduce wertvoll. Hier die kanonische Implementierung:
package main
import "fmt"
// Map nimmt einen Slice von A und eine Funktion A -> B und liefert
// einen Slice von B. Zwei Type-Parameter, weil Eingang und Ausgang
// unterschiedliche Typen haben dürfen.
func Map[A, B any](in []A, f func(A) B) []B {
out := make([]B, len(in))
for i, v := range in {
out[i] = f(v)
}
return out
}
func main() {
// A = int, B = string — die Inferenz liest A aus dem Slice-Argument,
// B aus dem Rückgabe-Typ der Funktion.
words := Map([]int{1, 2, 3}, func(n int) string {
return fmt.Sprintf("n=%d", n)
})
fmt.Println(words)
// A = string, B = int — gleicher Helper, andere Typen.
lens := Map([]string{"go", "rust", "zig"}, func(s string) int {
return len(s)
})
fmt.Println(lens)
}[n=1 n=2 n=3]
[2 3 4]Drei Punkte zur Mechanik. Erstens: out := make([]B, len(in)) allokiert genau so viele Slots wie der Eingangs-Slice — Map ändert die Länge nicht, jeder Eingang erzeugt genau einen Ausgang. Zweitens: die Type-Inference funktioniert in beide Richtungen — A ergibt sich aus dem ersten Argument, B aus dem Rückgabetyp der Closure. Drittens: was passiert, wenn f panickt? Der bereits geschriebene Teil von out ist verloren, weil die Funktion ohne Recover panicked. Wer das anders haben will, baut eine MapErr-Variante mit Early-Return — siehe später im Result-Abschnitt.
Variante: wenn der Slice-Typ ein Custom-Typ ist (z. B. type IDs []int64), willst du eventuell den Typ erhalten. Dann schreibst du func Map[S ~[]A, A, B any](in S, f func(A) B) []B — der Constraint ~[]A greift auch für abgeleitete Typen. Der Rückgabewert bleibt []B, weil B ja gar nicht aus S kommt; wer auch den Output-Slice-Typ generisch will, braucht einen vierten Type-Parameter.
Filter[T any] — Element-Auswahl
Filter ist das Gegenstück zu Map: die Elemente bleiben unverändert, aber nur diejenigen, die ein Prädikat erfüllen, landen im Ausgang. Hier hat die Stdlib seit Go 1.21 mit slices.DeleteFunc einen In-Place-Verwandten — in-place heißt, der Rückgabewert ist ein verkürzter Slice, der dasselbe Backing-Array benutzt. Wer eine Kopie will, schreibt Filter selbst.
package main
import "fmt"
// Filter behält nur Elemente, für die pred true liefert. Ein Type-Parameter,
// weil Eingang und Ausgang denselben Element-Typ haben.
func Filter[T any](in []T, pred func(T) bool) []T {
out := make([]T, 0, len(in))
for _, v := range in {
if pred(v) {
out = append(out, v)
}
}
return out
}
func main() {
even := Filter([]int{1, 2, 3, 4, 5, 6}, func(n int) bool {
return n%2 == 0
})
fmt.Println(even)
short := Filter([]string{"go", "rust", "zig", "carbon"}, func(s string) bool {
return len(s) <= 3
})
fmt.Println(short)
}[2 4 6]
[go zig]Beachte das make([]T, 0, len(in)) — Länge 0, aber Kapazität gleich dem Eingang. Damit braucht append keine Reallokation, egal wie viele Elemente das Prädikat akzeptiert. Bei einem stark filternden Prädikat ist das etwas verschwendeter Speicher; wer das gespart haben will, ruft am Ende slices.Clip(out) und gibt das überschüssige Backing frei. Faustregel: für die meisten Anwendungen ist die voll-allokierte Variante schneller und einfacher.
Variante mit Stdlib: slices.DeleteFunc(in, func(t T) bool { return !pred(t) }) liefert dasselbe Ergebnis, aber in-place — der ursprüngliche Slice wird modifiziert, das Backing-Array verändert. Wer also nicht den ursprünglichen Slice mutieren will, muss vor DeleteFunc slices.Clone machen oder eben den Filter-Helper hier nehmen.
Reduce[A, B any] — Akkumulation zu einem Wert
Reduce (oder Fold in funktionalen Sprachen) faltet einen Slice zu einem Wert zusammen, indem es einen Akkumulator durch jedes Element schickt. Klassische Beispiele: Summe, Produkt, „längster String", Wörter zu Map zählen. Die Type-Parameter-Wahl ist hier didaktisch wichtig — Reduce hat zwei Type-Parameter, weil der Akkumulator nicht denselben Typ haben muss wie die Elemente. Eine Summe über int hat A = int, B = int, eine Längen-Akkumulation über string hat A = string, B = int.
package main
import "fmt"
// Reduce nimmt einen Slice von A, einen Start-Wert vom Typ B und
// eine Schritt-Funktion (B, A) -> B. Das Ergebnis ist ein einzelnes B.
func Reduce[A, B any](in []A, init B, step func(acc B, v A) B) B {
acc := init
for _, v := range in {
acc = step(acc, v)
}
return acc
}
func main() {
// Summe: A = int, B = int.
sum := Reduce([]int{1, 2, 3, 4}, 0, func(acc, v int) int {
return acc + v
})
fmt.Println(sum) // 10
// Längster String: A = string, B = string.
longest := Reduce([]string{"go", "rust", "zig"}, "", func(acc, v string) string {
if len(v) > len(acc) {
return v
}
return acc
})
fmt.Println(longest) // rust
// Wort-Zählung: A = string, B = map[string]int.
counts := Reduce(
[]string{"a", "b", "a", "c", "b", "a"},
map[string]int{},
func(m map[string]int, w string) map[string]int {
m[w]++
return m
},
)
fmt.Println(counts) // map[a:3 b:2 c:1]
}10
rust
map[a:3 b:2 c:1]Das dritte Beispiel zeigt das Argument für zwei separate Type-Parameter: A = string (das Wort), B = map[string]int (die Counter-Map). Mit nur einem Type-Parameter (Reduce[T any] und B = T) wäre dieser Aufruf nicht möglich — Element und Akkumulator hätten denselben Typ haben müssen. Praktisch sind Map-Reduce-Aggregationen genau die Stellen, an denen die beiden Type-Parameter ihren Wert beweisen.
Type-Inference-Verhalten: A wird aus dem Slice-Argument inferiert, B aus dem init-Argument und dem Rückgabetyp von step. Wenn beide nicht zusammenpassen, meldet der Compiler einen Konflikt — z. B. Reduce([]int{1, 2}, 0.0, func(acc int, v int) int {...}) schlägt fehl, weil init ein float64 ist, aber step ein int zurückgibt.
Find / FindFunc — erstes Element finden
Manchmal willst du nicht alle passenden Elemente (das wäre Filter), sondern nur das erste. Die Stdlib hat dafür slices.IndexFunc, das den Index zurückgibt — wer das Element selbst und ein ok-Flag will, schreibt einen kurzen Wrapper. Das ist ein typischer Fall, wo eine eigene Generic-Funktion lesbarer wird als die Stdlib-Alternative.
package main
import "fmt"
// Find liefert das erste Element, das pred erfüllt — plus ok=true.
// Wenn nichts passt, kommt der Zero-Value zurück und ok=false.
func Find[T any](in []T, pred func(T) bool) (T, bool) {
for _, v := range in {
if pred(v) {
return v, true
}
}
var zero T
return zero, false
}
func main() {
xs := []int{1, 3, 5, 8, 9}
if first, ok := Find(xs, func(n int) bool { return n%2 == 0 }); ok {
fmt.Println("erste gerade Zahl:", first)
}
if _, ok := Find(xs, func(n int) bool { return n > 100 }); !ok {
fmt.Println("keine Zahl über 100 gefunden")
}
}erste gerade Zahl: 8
keine Zahl über 100 gefundenDas var zero T ist die idiomatische Art, den Zero-Value eines Type-Parameters zu erzeugen. Du kannst nicht return T{}, false oder return nil, false schreiben, weil der Compiler nicht weiß, ob T ein Struct, Pointer, Interface oder Number ist. var zero T funktioniert immer und gibt jeweils das passende Zero — 0 für int, "" für string, nil für Pointer/Slice/Map.
Variante: Wer das ok-Flag nicht braucht (z. B. weil der Slice nie leer ist und ein Default-Element sinnvoll ist), schreibt FindOr[T any](in []T, pred func(T) bool, defaultV T) T. Beide Patterns existieren in Library-Code in freier Wildbahn — welche Variante du wählst, hängt davon ab, ob „nicht gefunden" semantisch ein Fehlerfall ist oder eine normale Möglichkeit.
Keys / Values — Map-Helpers
Seit Go 1.23 liefert das maps-Paket Keys und Values als iter.Seq-Funktionen — nicht mehr als Slice. Das ist der modernere Stil; wer einen Slice braucht, kombiniert mit slices.Collect oder slices.Sorted. Trotzdem zeigt der direkte Slice-Helper sehr schön, wie der [K comparable, V any]-Constraint für Maps aussieht.
package main
import (
"fmt"
"maps"
"slices"
)
// Eigene Helpers — geben direkt Slices zurück.
func Keys[K comparable, V any](m map[K]V) []K {
out := make([]K, 0, len(m))
for k := range m {
out = append(out, k)
}
return out
}
func Values[K comparable, V any](m map[K]V) []V {
out := make([]V, 0, len(m))
for _, v := range m {
out = append(out, v)
}
return out
}
func main() {
m := map[string]int{"a": 1, "b": 2, "c": 3}
// Eigene Variante — direkter Slice, beliebige Reihenfolge.
ks := Keys(m)
slices.Sort(ks)
fmt.Println(ks)
// Stdlib-Variante seit Go 1.23 — iter.Seq, mit Sorted kombiniert.
sortedKs := slices.Sorted(maps.Keys(m))
fmt.Println(sortedKs)
}[a b c]
[a b c]K comparable ist Pflicht, weil Map-Keys in Go immer comparable sein müssen — der Compiler würde ein generisches map[K]V mit K any gar nicht akzeptieren. V any lässt jeden Werttyp zu. Reihenfolge: undefiniert, weil Go-Maps absichtlich randomisiert iterieren. Wer eine definierte Reihenfolge braucht, ruft hinterher slices.Sort (für cmp.Ordered-Typen) oder slices.SortFunc.
Verwandter Helper, der nicht in der Stdlib steht: Invert[K, V comparable](m map[K]V) map[V]K, das eine Map umdreht. Hier braucht V ebenfalls comparable, weil es im Ergebnis-Map der Key wird. Solche kleinen Helpers sind genau die Bauteile, die ein internes myutil-Paket sammelt.
GroupBy — Slice in Map gruppieren
GroupBy ist eine der nützlichsten kombinierten Helpers: nimm einen Slice, eine Schlüssel-Funktion, sortiere die Elemente in eine Map ein, deren Werte selbst Slices sind. SQL-Leute kennen das, funktionale Programmierer kennen das, Stdlib hat es nicht — also schreiben wir es selbst.
package main
import "fmt"
// GroupBy gruppiert Elemente nach dem Ergebnis von keyFn.
// K muss comparable sein (Map-Key), T darf alles sein.
func GroupBy[K comparable, T any](in []T, keyFn func(T) K) map[K][]T {
out := make(map[K][]T)
for _, v := range in {
k := keyFn(v)
out[k] = append(out[k], v)
}
return out
}
type Person struct {
Name string
Age int
}
func main() {
people := []Person{
{"Anna", 28},
{"Ben", 34},
{"Carl", 28},
{"Diana", 34},
{"Eva", 41},
}
byAge := GroupBy(people, func(p Person) int { return p.Age })
for age, ps := range byAge {
fmt.Printf("%d: %v\n", age, ps)
}
}28: [{Anna 28} {Carl 28}]
34: [{Ben 34} {Diana 34}]
41: [{Eva 41}]Das Detail beim append(out[k], v): in Go ist append auf einem nicht-existenten Map-Eintrag legitim, weil out[k] für einen unbekannten Key den Zero-Value nil zurückgibt und append(nil, x) ein neues Slice anlegt. Das spart den expliziten Check — typisches Go-Idiom, das mit Generics direkt mitübernommen wurde.
Verwandte Patterns: CountBy[K comparable, T any](in []T, keyFn func(T) K) map[K]int zählt Elemente pro Gruppe, Partition[T any](in []T, pred func(T) bool) (yes, no []T) teilt in zwei Slices. Letzteres ist ein „GroupBy auf Bool" und kommt häufig genug vor, um einen eigenen Helper zu rechtfertigen.
Result[T any] — Wert plus Fehler als Wrapper
Go macht Fehlerbehandlung über das (value, err)-Tupel an jeder Funktions-Grenze. Wer aber eine Liste von Operationen verschicken will — etwa als Channel-Message, in einem Worker-Pool, in einer Batch-Verarbeitung —, der wickelt Wert und Fehler oft in einen Wrapper. Vor Generics hieß das entweder ein konkreter Wrapper pro Typ (IntResult, StringResult, …) oder ein interface{}. Mit Generics wird das ein eleganter, typsicherer Container.
package main
import (
"errors"
"fmt"
)
// Result trägt entweder Value oder Err — niemals beides sinnvoll gesetzt.
// T any lässt jeden Werttyp zu.
type Result[T any] struct {
Value T
Err error
}
// Konstruktoren machen die Intention klar.
func Ok[T any](v T) Result[T] { return Result[T]{Value: v} }
func Fail[T any](e error) Result[T] { return Result[T]{Err: e} }
// Unwrap gibt Value plus error im klassischen Go-Stil zurück.
func (r Result[T]) Unwrap() (T, error) {
return r.Value, r.Err
}
type Response struct {
Status int
Body string
}
func fetch(url string) Result[Response] {
if url == "" {
return Fail[Response](errors.New("leere URL"))
}
return Ok(Response{Status: 200, Body: "data from " + url})
}
func main() {
urls := []string{"https://a.example", "", "https://b.example"}
results := make([]Result[Response], len(urls))
for i, u := range urls {
results[i] = fetch(u)
}
for i, r := range results {
if r.Err != nil {
fmt.Printf("[%d] FEHLER: %v\n", i, r.Err)
} else {
fmt.Printf("[%d] OK: %s\n", i, r.Value.Body)
}
}
}[0] OK: data from https://a.example
[1] FEHLER: leere URL
[2] OK: data from https://b.exampleWarum überhaupt einen Result-Typ, wenn Go bereits (T, error) hat? Drei Gründe. Erstens: Channels können nur einen Wert pro Send transportieren — ein chan Result[Response] ist die übliche Lösung, wenn du Fehler und Werte zusammen vom Worker zurückbekommen willst. Zweitens: eine Slice von Ergebnissen ([]Result[T]) ist eine natürliche Form für Batch-APIs. Drittens: Bei Composability — etwa „Reduce über eine Liste von Results, sammle Fehler" — ist der Wrapper das Vehikel.
Was du nicht versuchen solltest: einen Result.Map- oder Result.AndThen-Stil wie in Rust nachzubauen, in dem du Monaden-artige Ketten schreibst. Das verträgt sich schlecht mit Go-Idiomatik — der einfache if err != nil { return err }-Flow bleibt klarer. Nutze Result[T] als Daten-Container, nicht als Control-Flow-Mechanismus.
Option[T any] — Optional-Wrapper
Go hat nil für Referenz-Typen und das (value, ok)-Tupel für Map-Lookups und Type-Assertions. Für Wert-Typen wie int oder time.Time gibt es keine eingebaute „nicht gesetzt"-Markierung — die Stdlib nimmt dafür meist Pointer (*int, *time.Time). Manche Codebasen wollen das expliziter und bauen einen Option[T]-Typ.
package main
import "fmt"
// Option trägt entweder einen Wert (present=true) oder „nichts".
type Option[T any] struct {
value T
present bool
}
func Some[T any](v T) Option[T] {
return Option[T]{value: v, present: true}
}
func None[T any]() Option[T] {
return Option[T]{}
}
// Get gibt den Wert und ein ok-Flag zurück — der bekannte Go-Stil.
func (o Option[T]) Get() (T, bool) {
return o.value, o.present
}
// OrElse liefert entweder den Wert oder den Default.
func (o Option[T]) OrElse(def T) T {
if o.present {
return o.value
}
return def
}
type User struct {
Name string
Nickname Option[string] // optionaler Nickname
}
func main() {
alice := User{Name: "Alice", Nickname: Some("ally")}
bob := User{Name: "Bob", Nickname: None[string]()}
for _, u := range []User{alice, bob} {
if nick, ok := u.Nickname.Get(); ok {
fmt.Printf("%s aka %s\n", u.Name, nick)
} else {
fmt.Printf("%s (kein Nickname)\n", u.Name)
}
fmt.Printf(" Anzeige: %s\n", u.Nickname.OrElse(u.Name))
}
}Alice aka ally
Anzeige: ally
Bob (kein Nickname)
Anzeige: BobBeachte den Aufruf None[string]() — bei None gibt es kein Argument, aus dem der Compiler T inferieren könnte, also muss man das Type-Argument explizit angeben. Bei Some("ally") dagegen liest die Inferenz T = string direkt aus dem Argument. Das ist die häufigste Stelle, an der man in eigenem Generic-Code mit expliziten Type-Argumenten arbeiten muss: bei Konstruktoren ohne Type-tragende Parameter.
Ehrlich zur Praxis: in Go-Code ist *T häufiger als Option[T]. Der Grund ist nicht technisch sondern kulturell — die Stdlib benutzt *T, JSON-Encoding mit omitempty arbeitet mit *T, Datenbank-Treiber liefern sql.NullString und Verwandte. Ein Option[T] zahlt sich vor allem dann aus, wenn du einen einheitlichen Stil über viele Felder ziehen willst und der nil-Dereferenz-Bug-Risiko-Gewinn dir wichtig ist.
Pool[T any] — typsicherer Wrapper über sync.Pool
sync.Pool ist die Stdlib-Antwort für Object-Pooling — Allokationen vermeiden, indem benutzte Objekte recycelt werden. Das Interface ist allerdings untypisiert: Get() liefert any, du musst per Type-Assertion zurückkonvertieren. Vor Generics war das der einzige Weg; mit Generics legt man einen dünnen Wrapper drumherum.
package main
import (
"bytes"
"fmt"
"sync"
)
// Pool ist ein typsicherer Wrapper über sync.Pool.
// T any lässt jeden Werttyp zu — meist nimmt man *Foo (Pointer).
type Pool[T any] struct {
inner sync.Pool
}
func NewPool[T any](newFn func() T) *Pool[T] {
return &Pool[T]{
inner: sync.Pool{
New: func() any { return newFn() },
},
}
}
func (p *Pool[T]) Get() T {
return p.inner.Get().(T)
}
func (p *Pool[T]) Put(v T) {
p.inner.Put(v)
}
func main() {
// Pool von *bytes.Buffer für wiederverwendbare Render-Puffer.
bufPool := NewPool(func() *bytes.Buffer {
return new(bytes.Buffer)
})
buf := bufPool.Get()
buf.Reset()
buf.WriteString("hallo welt")
fmt.Println(buf.String())
bufPool.Put(buf)
}hallo weltDie Type-Assertion p.inner.Get().(T) ist sicher, weil der Pool nur Werte enthält, die wir per Put(v T) selbst hineingelegt haben — der einzige andere Eintrag kommt aus newFn(), das auch T liefert. Es kann also nichts anderes drin sein als ein T. Trotzdem: wenn T ein Interface ist und jemand einen anderen konkreten Typ einschiebt, würde das ohne Vorwarnung kompilieren — bei Generics nicht möglich, weil der Typ-Check am Put-Aufruf greift.
Wichtig zu wissen: sync.Pool ist ein Cache, kein Garantor. Der Garbage-Collector darf Pool-Einträge jederzeit verwerfen. Wer Objekte aus dem Pool zieht, muss sie immer mit dem erwarteten Zustand initialisieren (z. B. buf.Reset()), weil der Pool entweder einen recycelten oder einen frischen Wert liefert. Pool ist also ein klassisches Optimierungs-Tool — nicht jeder Allokations-Heavy-Code profitiert, aber heißer Pfad mit kurzlebigen Buffern fast immer.
LRU-Cache[K comparable, V any] — kompletter Cache mit Capacity
Ein Least-Recently-Used-Cache hält die letzten N angefragten Werte, wirft den ältesten weg, wenn ein neuer hinzukommt. Klassische Datenstruktur — eine Hash-Map für O(1)-Lookup plus eine doppelt verkettete Liste für O(1)-Reordering. Mit Generics wird der LRU für jeden Key-Value-Typ wiederverwendbar.
package main
import (
"container/list"
"fmt"
)
// entry ist der konkrete Listen-Eintrag — Key plus Value.
// K comparable, weil es als Map-Key dient.
type entry[K comparable, V any] struct {
key K
value V
}
type LRU[K comparable, V any] struct {
capacity int
ll *list.List // Reihenfolge: hinten = älteste
index map[K]*list.Element // O(1)-Lookup auf Listen-Element
}
func NewLRU[K comparable, V any](capacity int) *LRU[K, V] {
return &LRU[K, V]{
capacity: capacity,
ll: list.New(),
index: make(map[K]*list.Element, capacity),
}
}
// Get holt einen Wert und markiert ihn als kürzlich benutzt.
func (c *LRU[K, V]) Get(key K) (V, bool) {
if el, ok := c.index[key]; ok {
c.ll.MoveToFront(el)
return el.Value.(entry[K, V]).value, true
}
var zero V
return zero, false
}
// Put fügt einen Wert ein oder updated einen bestehenden Eintrag.
func (c *LRU[K, V]) Put(key K, value V) {
if el, ok := c.index[key]; ok {
el.Value = entry[K, V]{key, value}
c.ll.MoveToFront(el)
return
}
el := c.ll.PushFront(entry[K, V]{key, value})
c.index[key] = el
if c.ll.Len() > c.capacity {
oldest := c.ll.Back()
if oldest != nil {
c.ll.Remove(oldest)
delete(c.index, oldest.Value.(entry[K, V]).key)
}
}
}
func main() {
cache := NewLRU[string, int](3)
cache.Put("a", 1)
cache.Put("b", 2)
cache.Put("c", 3)
cache.Get("a") // a wird wieder ganz vorne
cache.Put("d", 4) // verdrängt b (jetzt das Älteste)
for _, k := range []string{"a", "b", "c", "d"} {
v, ok := cache.Get(k)
fmt.Printf("%s -> %d, ok=%v\n", k, v, ok)
}
}a -> 1, ok=true
b -> 0, ok=false
c -> 3, ok=true
d -> 4, ok=trueEin paar Bemerkungen. Erstens: container/list ist untypisiert (Elemente sind any), deshalb die el.Value.(entry[K, V])-Assertion. Wir wissen, dass nur unsere eigenen Einträge in der Liste landen, also ist die Assertion sicher. Zweitens: var zero V wieder dasselbe Idiom — wir müssen einen Default zurückgeben können, ohne den konkreten V zu kennen. Drittens: der LRU ist nicht thread-sicher. Wer den concurrent benutzt, packt einen sync.Mutex als Feld dazu und lockt in Get und Put — die Generic-Struktur bleibt identisch.
Variante: ein TTL-Cache, der Einträge nach Ablauf einer Zeit wegwirft, ergänzt jedem entry ein expiresAt time.Time-Feld. Variante: ein Sized-Cache, der nicht Anzahl, sondern Byte-Größe der Werte limitiert, braucht eine sizeFn func(V) int-Callback. Beide Varianten erweitern dasselbe Generic-Skelett.
Set[T comparable] — Set-Operationen
Go hat keinen eingebauten Set-Typ — das Idiom ist map[T]struct{} mit struct{} als Zero-Cost-Wert. Mit Generics packt man das in einen sauberen Wrapper mit den üblichen Set-Operationen.
package main
import "fmt"
// Set[T comparable] — comparable, weil T als Map-Key dient.
type Set[T comparable] map[T]struct{}
func NewSet[T comparable](items ...T) Set[T] {
s := make(Set[T], len(items))
for _, it := range items {
s[it] = struct{}{}
}
return s
}
func (s Set[T]) Add(v T) { s[v] = struct{}{} }
func (s Set[T]) Remove(v T) { delete(s, v) }
func (s Set[T]) Contains(v T) bool { _, ok := s[v]; return ok }
func (s Set[T]) Len() int { return len(s) }
// Union — alle Elemente aus beiden Sets.
func (s Set[T]) Union(other Set[T]) Set[T] {
out := make(Set[T], len(s)+len(other))
for v := range s {
out[v] = struct{}{}
}
for v := range other {
out[v] = struct{}{}
}
return out
}
// Intersect — nur Elemente, die in beiden Sets sind.
func (s Set[T]) Intersect(other Set[T]) Set[T] {
out := make(Set[T])
small, large := s, other
if len(other) < len(s) {
small, large = other, s
}
for v := range small {
if _, ok := large[v]; ok {
out[v] = struct{}{}
}
}
return out
}
func main() {
a := NewSet(1, 2, 3, 4)
b := NewSet(3, 4, 5, 6)
fmt.Println("Union: ", a.Union(b).Len())
fmt.Println("Intersect:", a.Intersect(b).Len())
fmt.Println("3 in a: ", a.Contains(3))
fmt.Println("7 in a: ", a.Contains(7))
}Union: 6
Intersect: 2
3 in a: true
7 in a: falsestruct{} als Wert-Typ ist Pflicht-Wissen für Go-Idiomatik: ein leeres Struct belegt null Byte. Eine map[string]struct{} mit einer Million Einträgen braucht denselben Speicher für die Werte wie eine leere Map — nur die Keys kosten. Wer stattdessen map[T]bool nimmt, zahlt pro Eintrag ein Byte für ein Bool, das ohnehin immer true ist.
In Intersect ist die Optimierung „iteriere über das kleinere Set" ein klassischer Performance-Trick: O(min(|a|,|b|)) statt O(|a|+|b|). Bei stark unterschiedlich großen Sets macht das einen messbaren Unterschied. Solche kleinen algorithmischen Verbesserungen sind genau das, was eine gute generische Bibliothek auszeichnet — der Aufrufer profitiert, ohne den Trick selbst zu kennen.
Wann Stdlib reicht — und wann du selber schreibst
Mit Go 1.21/1.23 ist die Stdlib reich genug, dass viele der oben gezeigten Patterns dort schon existieren oder mit ein, zwei Funktionen kombiniert werden können. Die Faustregel: erst Stdlib prüfen, dann selbst schreiben. Folgende Übersicht ordnet das ein:
| Pattern | Stdlib (Go 1.21+) | Eigener Helper sinnvoll? |
|---|---|---|
| Map (Element-Transformation) | — | Ja, falls oft benötigt |
| Filter (Element-Auswahl, Kopie) | slices.DeleteFunc (in-place) | Ja, wenn Kopie gebraucht wird |
| Reduce (Akkumulation) | — | Ja |
| Find (erstes passendes Element) | slices.IndexFunc (gibt Index) | Wrapper macht es lesbarer |
| Keys / Values (Map → Slice) | maps.Keys, maps.Values (iter.Seq seit 1.23) | Nein, Stdlib reicht |
| Sort | slices.Sort, slices.SortFunc | Nein |
| Contains | slices.Contains, slices.ContainsFunc | Nein |
| Min / Max | slices.Min, slices.Max | Nein |
| GroupBy | — | Ja |
| Result-Wrapper | — | Ja, falls über Channels |
| Option-Wrapper | — (Pointer-Idiom üblicher) | Selten |
| Lazy-Init / Once-Value | sync.OnceFunc, sync.OnceValue, sync.OnceValues | Nein, Stdlib reicht |
| Pool | sync.Pool (untypisiert) | Generic-Wrapper hilft |
| LRU-Cache | — | Ja |
| Set | — (map[T]struct{} idiomatisch) | Ja, falls oft genutzt |
sync.OnceValue verdient eine Sondererwähnung. Wer eine teure Berechnung einmal machen will und das Ergebnis cachen, schreibt nicht selbst einen Generic-Memoizer — die Stdlib hat das mit sync.OnceValue[T any](f func() T) func() T seit Go 1.21 fertig. Aufruf: getConfig := sync.OnceValue(loadConfig), dann beliebig oft getConfig() aufrufen, intern wird loadConfig nur beim ersten Mal ausgeführt. Concurrent-sicher. Wer ein Resultat plus Error braucht, nimmt sync.OnceValues.
Heißt: bevor du einen eigenen Generic-Helper baust, schau in slices, maps und sync nach. Was die Stdlib liefert, ist getestet, optimiert und idiomatisch — was du selbst schreibst, musst du selbst pflegen. Die Patterns aus diesem Artikel sind genau die, wo dieser Schritt sich lohnt: weil Stdlib sie nicht liefert oder weil die Stdlib-Version untypisiert (sync.Pool) ist.
Besonderheiten
Constraint Type Inference ist der Grund, warum `slices.Sort(xs)` ohne Type-Argument funktioniert.
Bei slices.Sort[S ~[]E, E cmp.Ordered](x S) schaut der Compiler auf das Argument xs []int, leitet daraus S = []int und über den Constraint S ~[]E dann E = int ab. Ohne diese Inferenz müsstest du slices.Sort[[]int, int](xs) schreiben. In eigenen Helpers nutzt du den gleichen Mechanismus, indem du einen ~[]E-Constraint nimmst, wann immer du einen Slice-Typ akzeptierst.
`var zero T` ist das einzige zuverlässige Idiom für den Zero-Value eines Type-Parameters.
Du kannst nicht return T{} oder return nil schreiben, weil der Compiler nicht weiß, ob T ein Struct, Pointer oder Number ist. var zero T deklariert eine Variable im Zero-Value, und der Compiler weiß für jedes konkrete Argument, welcher das ist. Du wirst dieses Pattern in jedem Find-, Get-, Pop-Helper sehen.
Map und Filter sind oft schneller per `for`-Schleife — die Generics-Version lohnt erst bei Wiederverwendung.
Ein out := make([]B, 0, len(in)); for _, v := range in { out = append(out, f(v)) } ist drei Zeilen Inline-Code. Die Map[A, B]-Version spart die drei Zeilen, fügt aber einen Funktions-Aufruf pro Element hinzu, den der Compiler manchmal inlinen kann und manchmal nicht. Der Generic-Helper zahlt sich nicht durch Performance aus, sondern durch Wiederverwendung — wer denselben Transform an zwanzig Stellen braucht, kapselt ihn einmal.
Reduce mit zwei Type-Parametern erlaubt erst die nicht-trivialen Aggregationen.
Mit Reduce[A, B any] kannst du Element-Typ und Accumulator-Typ entkoppeln — Wort-Listen zu map[string]int zählen, Zahlen zu string formatieren, Personen zu Histogrammen falten. Wer nur einen Type-Parameter macht (Reduce[T any]), schneidet sich genau die Hälfte der nützlichen Anwendungen ab. Die Trennung in A und B ist die wichtigste Design-Entscheidung dieses Helpers.
`Option[T]` ist in Go selten — der `*T`-Pointer mit `nil` ist die idiomatischere Variante.
Stdlib, JSON-Encoding, Datenbank-Treiber arbeiten alle mit *T-Pointern oder konkreten sql.NullString-artigen Typen. Ein selbstgebauter Option[T]-Wrapper ist technisch sauberer, aber kulturell ungewöhnlich. Wer keinen guten Grund hat, bleibt beim Pointer-Idiom — die Lesbarkeit für andere Go-Entwickler ist meistens wichtiger als die theoretische Klarheit.
`sync.Pool` ohne Generic-Wrapper ist ein häufiger Bug-Magnet.
Die Get() any-Signatur lädt zu falschen Type-Assertions ein, und wenn ein anderer Type irrtümlich im Pool landet, kracht es zur Laufzeit. Ein Pool[T any]-Wrapper macht beide Operationen typsicher: Put(v T) akzeptiert nur den passenden Typ, Get() T braucht keine Assertion mehr. Ein zehn-Zeilen-Wrapper, der erstaunlich viele Bugs verhindert.
LRU-Cache: `container/list` ist die idiomatische Wahl, auch wenn sie untypisiert ist.
Eine doppelt verkettete Liste in Go selbst zu schreiben ist möglich, aber container/list ist battle-tested und liefert genau die O(1)-Operationen (PushFront, MoveToFront, Remove), die ein LRU braucht. Die Type-Assertion el.Value.(entry[K, V]) ist sicher, weil nur eigene Einträge in die Liste fließen. Ein eigener Listen-Typ würde Hunderte Zeilen kosten und keine messbare Verbesserung bringen.
`sync.OnceValue` ersetzt selbstgebaute Memoizer — und ist concurrent-sicher.
Vor Go 1.21 sah man oft Code wie var once sync.Once; var cached T; func get() T { once.Do(func() { cached = compute() }); return cached }. Seit Go 1.21 ist das getValue := sync.OnceValue(compute). Drei Zeilen weniger, kein globaler Cache-State, und genau dieselbe Concurrent-Safety. Wer Memoization für einen einzelnen Wert braucht, nimmt heute sync.OnceValue.
Weiterführende Ressourcen
Externe Quellen
slices-Paket — generische Slice-Funktionen (Stdlib)maps-Paket — generische Map-Funktionen (Stdlib)sync.OnceFunc,sync.OnceValue,sync.OnceValues- An Introduction To Generics — Go Blog
container/list— doppelt verkettete Liste