Generics und Interfaces lösen beide dasselbe Grundproblem: mehrere Typen, ein Code. Aber sie tun das mit fundamental unterschiedlichen Modellen — Generics arbeiten zur Compile-Zeit auf Typen, Interfaces zur Laufzeit auf Verhalten. Wer beide Werkzeuge hat, muss bei jeder Polymorphie-Entscheidung kurz innehalten: Type-Parameter oder Methoden-Set? Dieser Artikel führt durch Ian Lance Taylors offizielle Faustregel aus dem when-generics-Blog, übersetzt sie in konkrete Entscheidungs-Beispiele und zeigt, was Performance, API-Design und Lesbarkeit jeweils dafür oder dagegen sprechen lassen.
Zwei Werkzeuge, ein Problem
Bevor wir vergleichen können, müssen wir das Problem präzise benennen. Du hast eine Funktion oder eine Datenstruktur, die mit mehreren konkreten Typen funktionieren soll — nicht nur mit int, nicht nur mit string, sondern mit „irgendetwas, das diese eine Anforderung erfüllt". Vor Go 1.18 gab es dafür genau einen Mechanismus: Interfaces. Seit Go 1.18 gibt es einen zweiten: Type-Parameter (Generics).
Beide Mechanismen erlauben „polymorphen" Code, aber sie tun es an völlig unterschiedlichen Stellen der Pipeline:
package main
import "fmt"
// Variante 1 — Interface. Polymorphie zur LAUFZEIT.
// Der konkrete Typ ist im Interface-Header verpackt;
// jeder Aufruf einer Interface-Methode geht über eine Indirektion.
type Stringer interface {
String() string
}
func PrintIface(s Stringer) {
fmt.Println(s.String())
}
// Variante 2 — Generic. Polymorphie zur COMPILE-ZEIT.
// Der konkrete Typ ist Teil der Funktions-Signatur;
// der Compiler erzeugt spezialisierten Code pro Typ-Shape.
func PrintGeneric[T fmt.Stringer](s T) {
fmt.Println(s.String())
}Beide Funktionen tun aus Anwender-Sicht dasselbe — sie drucken die String()-Repräsentation von irgendetwas. Aber die Mechanik dahinter ist verschieden. PrintIface arbeitet mit einem kompilierten Funktions-Body, der zur Laufzeit über den Interface-Header zur passenden String()-Implementierung springt. PrintGeneric arbeitet mit einem Compile-Zeit-Template, aus dem der Compiler für jeden tatsächlich genutzten Typ-Shape eine eigene oder geteilte Instanz erzeugt. Welche Variante besser passt, hängt nicht von „funktioniert es" ab — beide funktionieren — sondern von einer Reihe Sekundär-Effekten, die wir in den nächsten Abschnitten ausarbeiten.
Die Faustregel — Ian Lance Taylor
Das offizielle Go-Blog hat zu diesem Thema einen eigenen Artikel: „When To Use Generics" von Ian Lance Taylor, einem der Architekten des Type-Parameter-Designs. Die zentrale Regel formuliert er so:
If you find yourself writing the exact same code multiple times, where the only difference between the copies is that the code uses different types, consider whether you can use a type parameter.
Das ist die positive Heuristik — Generics einsetzen, wenn du denselben Code mehrfach schreibst und sich nur die Typen unterscheiden. Im selben Blog steht die spiegelbildliche Warnung:
You should avoid type parameters until you notice that you are about to write the exact same code multiple times.
Beide Sätze gehören zusammen. Du schreibst die Funktion erst konkret, du beobachtest, dass du sie ein zweites Mal für einen anderen Typ schreiben musst — jetzt lohnt sich der Schritt zum Type-Parameter. Vorher nicht. Dieser disziplinierte Ansatz verhindert die häufigste Anti-Pattern-Falle generischer Sprachen: spekulative Abstraktion, bei der die Generics-Schicht das eigentliche Programm vergräbt, bevor sie überhaupt einen zweiten Konsumenten hat.
Taylor nennt drei konkrete Indikatoren, an denen Generics meistens sinnvoll sind. Die werten wir in den nächsten Abschnitten einzeln aus.
Indikator 1 — Operationen auf Slice/Map/Channel
Der häufigste Fall, in dem Generics offensichtlich richtig sind: Funktionen, die auf Slices, Maps oder Channels von beliebigem Element-Typ arbeiten. Der Algorithmus ist immer derselbe, das Element-Typ-System ist Go ausgeliefert.
Vor Go 1.18 hattest du zwei schlechte Alternativen: entweder du schreibst die Funktion für jeden konkreten Element-Typ neu (ContainsInt, ContainsString, ContainsFloat64 — Code-Duplikation), oder du benutzt []any und verlierst Typsicherheit plus zahlst Boxing-Kosten. Mit Type-Parametern gibt es eine dritte, saubere Lösung:
package main
import "fmt"
// Generisches Contains — funktioniert für jeden vergleichbaren Typ.
// Die einzige Anforderung an T ist comparable (== und !=).
func Contains[T comparable](haystack []T, needle T) bool {
for _, x := range haystack {
if x == needle {
return true
}
}
return false
}
func main() {
ints := []int{1, 2, 3, 4, 5}
strs := []string{"go", "rust", "zig"}
fmt.Println(Contains(ints, 3)) // true
fmt.Println(Contains(strs, "ada")) // false
}true
falseDie Funktion ist typsicher: der Aufrufer kann nicht versehentlich Contains(ints, "drei") schreiben — der Compiler verlangt, dass needle denselben Element-Typ wie der Slice hat. Sie ist bequem: der Type-Parameter T wird aus den Argumenten abgeleitet, keine explizite Annotation nötig. Und sie ist performant: bei value-types passiert kein Boxing.
Dasselbe Muster gilt für Map-Keys, Map-Values, Channel-Elemente — überall, wo der Container-Typ selbst die Polymorphie liefert und die Funktion „den Algorithmus drumherum" enthält. Die Stdlib-Pakete slices und maps (seit Go 1.21) sind genau dieser Fall, hochskaliert auf etwa 30 Funktionen.
Indikator 2 — Allgemeine Datenstrukturen
Der zweite Indikator: Datenstrukturen, die Go nicht eingebaut hat — verkettete Listen, Bäume, Sets, Priority-Queues, Ring-Buffer. Vor Generics warst du auch hier zwischen Code-Duplikation und interface{} gefangen. Mit Type-Parametern lassen sich diese Strukturen sauber schreiben, ohne ihre Typsicherheit aufzugeben.
package main
import "fmt"
type Set[T comparable] struct {
data map[T]struct{}
}
func NewSet[T comparable]() *Set[T] {
return &Set[T]{data: map[T]struct{}{}}
}
func (s *Set[T]) Add(x T) { s.data[x] = struct{}{} }
func (s *Set[T]) Has(x T) bool { _, ok := s.data[x]; return ok }
func (s *Set[T]) Len() int { return len(s.data) }
func main() {
s := NewSet[string]()
s.Add("go")
s.Add("rust")
s.Add("go") // Duplikat, wird ignoriert
fmt.Println(s.Has("go"), s.Has("zig"), s.Len())
}true false 2Das Set[T comparable] ist die kanonische generische Datenstruktur — der Element-Typ ist nirgends festgenagelt, aber die Methoden bleiben statisch typisiert. Ein Set[string] und ein Set[int64] sind verschiedene, miteinander inkompatible Typen — der Compiler verhindert, dass du in einen Set[string] versehentlich Integer einfügst. Genau diese statische Trennung ist der Hauptgewinn gegenüber einer Interface-basierten Variante.
Wichtig: die Stdlib hat solche Datenstrukturen bewusst nicht generisch nachgezogen (es gibt kein offizielles container/set[T]). Die Empfehlung in Taylors Blog lautet, Datenstrukturen erst dann generisch zu schreiben, wenn man sie tatsächlich für mehrere Typen braucht — vorher reicht ein konkretes map[string]struct{}.
Indikator 3 — Identische Methoden über Typen
Der dritte Indikator ist subtiler: Du hast mehrere Typen, die alle dieselbe Methode brauchen, und der Inhalt dieser Methode ist über alle Typen identisch. Nicht „ähnlich" — wirklich identisch, bis auf den verarbeiteten Typ.
Ein konkretes Beispiel: eine Min-Funktion, die das Minimum zweier vergleichbarer Werte zurückgibt. Vor Generics hättest du MinInt, MinFloat64, MinString geschrieben — drei Funktionen, dreimal derselbe if a < b Code, drei Stellen zum Pflegen. Mit Generics ist es eine Funktion:
package main
import (
"cmp"
"fmt"
)
func Min[T cmp.Ordered](a, b T) T {
if a < b {
return a
}
return b
}
func main() {
fmt.Println(Min(3, 7)) // 3
fmt.Println(Min(2.5, 1.1)) // 1.1
fmt.Println(Min("alpha", "beta")) // alpha
}3
1.1
alphaBeachte das Constraint cmp.Ordered — ein Interface aus der Stdlib (cmp-Paket, Go 1.21), das alle Typen abdeckt, auf denen < definiert ist. Das ist genau der Hybrid aus Abschnitt 11: ein Generic, der ein Interface als Constraint nutzt.
Wenn die Implementierungen nicht identisch sind — wenn dein „Min" für Strings einen anderen Algorithmus als für Integers verlangt — passt Generics nicht. Dann brauchst du Polymorphie zur Laufzeit, also ein Interface.
Drei Indikatoren GEGEN Generics
Spiegelbildlich gibt es klare Situationen, in denen Generics die falsche Antwort sind und Interfaces gewinnen. Taylor formuliert das in einer Kernregel:
If all you need to do with a value of some type is call a method on that value, use an interface type, not a type parameter.
Die drei häufigsten Konstellationen:
(a) Implementierungen unterscheiden sich fundamental. Ein io.Reader aus einer Datei, aus einem TCP-Socket und aus einem In-Memory-bytes.Buffer haben drei vollkommen verschiedene Read-Implementierungen — Syscalls, Netzwerk-Stack, Slice-Kopie. Hier ist Polymorphie zur Laufzeit unverzichtbar: der Aufrufer übergibt irgendeinen Reader, die Funktion ruft r.Read(buf), und welche Implementierung greift, hängt vom konkreten Typ ab. Ein generischer Type-Parameter würde diesen Vorteil aufgeben, weil er den konkreten Typ in der Funktions-Signatur sichtbar machen würde.
(b) Verschiedenes Verhalten hinter derselben Methode. Ein Stringer ist genau das: jeder Typ druckt sich anders. Du willst, dass time.Time.String() und net.IP.String() unterschiedliche Algorithmen sind. Ein Interface kommuniziert das — „diese Typen teilen einen Vertrag, nicht eine Implementierung". Ein Generic suggeriert das Gegenteil.
(c) Du brauchst echte Laufzeit-Polymorphie. Wenn du eine []Animal haben willst, in der Dog, Cat und Bird koexistieren — also heterogene Sammlungen — geht das nur mit Interfaces. Ein generischer Slice ist immer []T für einen konkreten T; []Dog und []Cat sind verschiedene, inkompatible Typen.
package main
import "fmt"
type Animal interface {
Sound() string
}
type Dog struct{ Name string }
func (d Dog) Sound() string { return d.Name + " sagt Wuff" }
type Cat struct{ Name string }
func (c Cat) Sound() string { return c.Name + " sagt Miau" }
func main() {
// Heterogene Sammlung — Generics können das nicht.
zoo := []Animal{
Dog{Name: "Rex"},
Cat{Name: "Lina"},
Dog{Name: "Bruno"},
}
for _, a := range zoo {
fmt.Println(a.Sound())
}
}Rex sagt Wuff
Lina sagt Miau
Bruno sagt WuffMit Generics ginge das nicht: []T ist monomorph. Wer ein Zoo[Dog] baut, kann keine Katze hineinlegen. Wer einen Tier-Container will, der heute Hunde und morgen Katzen halten kann, braucht ein Interface.
„Don't use type parameters prematurely"
Ein weiterer zentraler Satz aus dem when-generics-Blog verdient eigene Aufmerksamkeit:
If you start writing your program by defining type parameter constraints, you are probably on the wrong path. Start by writing functions.
Das ist der Anti-Pattern-Wegweiser. Wer ein neues Modul beginnt und als erstes Type-Parameter-Constraints designt, hat den Karren vor das Pferd gespannt. Die richtige Reihenfolge:
- Schreibe die Funktion konkret. Erstens funktioniert sie schneller, zweitens kannst du sie testen, drittens hast du ein reales Beispiel für das, was abstrahiert werden müsste.
- Warte, bis du sie zum zweiten Mal brauchst — für einen anderen Typ. Erst jetzt hast du echte Daten, welche Teile sich ändern und welche nicht.
- Dann hebe sie auf Type-Parameter. Der Refactor ist meistens trivial:
func ContainsInt(xs []int, v int) bool→func Contains[T comparable](xs []T, v T) bool. Drei Token, fertig.
Diese Disziplin ist in Sprachen wie Go besonders wichtig, weil das Sprach-Design auf Lesbarkeit zuerst ausgelegt ist. Eine generische Funktion ist immer schwerer zu lesen als eine konkrete — schon weil zusätzliche Token ([T comparable]) im Sichtfeld liegen. Wer eine Funktion generisch macht, die immer nur mit int aufgerufen wird, bezahlt diese Lesbarkeits-Steuer ohne Gegenleistung.
Performance — der gefährliche Mythos
Eine verbreitete Fehlannahme: „Generics sind schneller als Interfaces." Taylor widerspricht direkt:
Using a type parameter will generally not be faster than using an interface type. So don't change from interface types to type parameters just for speed.
Der Grund liegt in der Implementierungsstrategie. Go-Generics werden nicht wie C++-Templates pro Typ vollständig monomorphisiert (eine separate kompilierte Kopie pro T), sondern per GC Shape Stenciling: der Compiler erzeugt eine Funktion pro Typ-„Shape" — vereinfacht: pro Größen-/Pointer-Layout-Klasse. Alle Typen mit demselben Shape teilen sich denselben kompilierten Funktions-Body, und der Body bekommt zusätzlich ein Dictionary übergeben, das die typspezifischen Operationen (Methoden, Interface-Conversions) enthält.
Heißt: ein Aufruf einer generischen Funktion mit Interface-Constraint kann mehr Indirektion enthalten als ein direkter Interface-Call, weil der Methoden-Lookup über das Dictionary geht. Bei reinen Wert-Operationen ohne Methoden-Aufrufe (Min, Contains) ist Generic etwa so schnell wie eine handgeschriebene typ-spezialisierte Version; bei Methoden-Aufrufen liegt es oft zwischen Direct-Call und Interface-Call.
package main
import "fmt"
type Adder interface {
Add(x int) int
}
type Counter struct{ n int }
func (c *Counter) Add(x int) int { return c.n + x }
// Interface-Variante: Indirektion über iface-Header bei jedem Add-Aufruf.
func SumIface(items []Adder, x int) int {
s := 0
for _, it := range items {
s += it.Add(x)
}
return s
}
// Generic-Variante: ein Shape (Pointer auf Counter),
// Methodenaufruf geht ebenfalls über Dictionary — nicht zwingend schneller.
func SumGeneric[T Adder](items []T, x int) int {
s := 0
for _, it := range items {
s += it.Add(x)
}
return s
}
func main() {
items := []*Counter{{n: 1}, {n: 2}, {n: 3}}
// Iface braucht den Slice als []Adder
asIface := make([]Adder, len(items))
for i, c := range items {
asIface[i] = c
}
fmt.Println(SumIface(asIface, 10))
fmt.Println(SumGeneric(items, 10))
}Die ehrliche Antwort: Performance ist kein guter Entscheidungs-Grund. Wenn ein Hot-Path tatsächlich von der Wahl abhängt, gibt es einen Benchmark, und du misst beide Varianten direkt. In 99 % aller Code-Stellen ist die Differenz nicht messbar, und die Wahl sollte rein nach Lesbarkeit und API-Design fallen.
Eine reale Performance-Differenz, die gegen Interfaces spricht, betrifft aber Boxing — und das ist der nächste Abschnitt.
Boxing — der echte Performance-Unterschied
Wenn du einen int an eine Funktion übergibst, die ein Interface erwartet (func f(x any)), passiert das, was Go-Insider „Boxing" nennen: der Wert wird in einen Interface-Header eingepackt, der intern einen Typ-Deskriptor und einen Pointer auf den Wert enthält. Für value-types alloziert dieser Pointer typischerweise auf dem Heap — und Heap-Allokationen sind teuer (GC-Druck, Cache-Misses).
package main
import "fmt"
// any-Parameter: x wird beim Aufruf geboxt → Heap-Allokation.
func PrintAny(x any) {
fmt.Println(x)
}
// Generic: T wird zur Compile-Zeit aufgelöst, kein Boxing.
func PrintGeneric[T any](x T) {
fmt.Println(x)
}
func main() {
PrintAny(42) // alloziert (Box für int)
PrintGeneric(42) // kein Box, int direkt übergeben
PrintAny("hallo")
PrintGeneric("hallo")
}42
42
hallo
halloIn Hot-Loops mit vielen kleinen Werten kann dieses Boxing dominieren. Ein Slice-Algorithmus über []int als []any zu schreiben und über die Interface-Version zu iterieren, alloziert N mal pro Lauf; die generische Version alloziert nicht. Hier ist Generics ein klar messbarer Vorteil — und einer der wenigen Fälle, in denen die Performance-Argumentation tragfähig ist.
Aber: das gilt nur, wenn der Interface-Parameter sonst any wäre. Bei einem Methoden-tragenden Interface (io.Reader, Stringer) ist der Trade-off anders — die Boxing-Kosten fallen auch dort an, aber die Alternative wäre nicht „Generics" sondern „separate konkrete Funktionen pro Typ", und das ist meistens unpraktisch.
API-Design — Typsicherheit beim Aufrufer
Ein zweiter, oft wichtigerer Unterschied: wo bleibt der konkrete Typ sichtbar? Eine generische Funktion lässt den konkreten Typ in der Signatur beim Aufrufer stehen — der Compiler weiß, was reingeht, weiß, was rauskommt. Eine Interface-Funktion verbirgt den konkreten Typ — drinnen wie draußen.
package main
type Box[T any] struct{ V T }
// Generic: Aufrufer behält Typ-Information.
// first ist *Box[int], second ist *Box[string] — getrennte Typen.
func New[T any](v T) *Box[T] { return &Box[T]{V: v} }
// Interface-Variante: Box verliert die Element-Typsicherheit.
type AnyBox struct{ V any }
func NewAny(v any) *AnyBox { return &AnyBox{V: v} }
func main() {
first := New(42) // *Box[int]
second := New("hello") // *Box[string]
_ = first.V + 1 // typsicher, int-Arithmetik
_ = second.V + " world" // typsicher, String-Concat
anyA := NewAny(42)
// anyA.V + 1 // FEHLER — V ist any, kein int.
_ = anyA.V.(int) + 1 // explizite Type-Assertion nötig.
}Das ist der zentrale API-Vorteil von Generics: der Aufrufer arbeitet weiter mit int, nicht mit any. Keine Type-Assertions, keine Laufzeit-Panics, keine Casts. Wenn deine Funktion oder Datenstruktur ihre Inhalte später wieder herausgeben muss — Container, Pipelines, Builder — sind Generics fast immer die bessere Wahl, weil sie den konkreten Typ erhalten.
Umgekehrt: wenn die Funktion intern „etwas tut" und nichts typrelevant zurückgibt (oder nur error), spielt diese Sichtbarkeit keine Rolle, und Interfaces sind genauso gut.
Hybrid — Generic mit Interface-Constraint
Generics und Interfaces sind keine Gegner. Im Gegenteil: jedes Generic-Constraint ist syntaktisch ein Interface. [T comparable] ist ein eingebautes Constraint, aber [T fmt.Stringer] oder [T cmp.Ordered] sind echte Interfaces, die als Constraint dienen.
package main
import (
"cmp"
"fmt"
)
// Sort akzeptiert jeden Typ, der < unterstützt.
// cmp.Ordered ist ein Interface; T ist ein Type-Parameter.
func Sort[T cmp.Ordered](xs []T) {
for i := 1; i < len(xs); i++ {
for j := i; j > 0 && xs[j-1] > xs[j]; j-- {
xs[j-1], xs[j] = xs[j], xs[j-1]
}
}
}
func main() {
ints := []int{3, 1, 4, 1, 5, 9, 2, 6}
Sort(ints)
fmt.Println(ints)
strs := []string{"birne", "apfel", "kirsche"}
Sort(strs)
fmt.Println(strs)
}[1 1 2 3 4 5 6 9]
[apfel birne kirsche]cmp.Ordered ist ein Type-Set-Interface (eine spezielle Form von Interface, die Go 1.18 mit Generics eingeführt hat). Es listet konkrete Typen auf, statt Methoden zu fordern — int | int8 | ... | float64 | string. Das Constraint sagt: „T darf alles sein, was sich mit < vergleichen lässt." Damit kombinierst du:
- Compile-Zeit-Polymorphie der Generic-Mechanik (kein Boxing, Typsicherheit beim Aufrufer)
- Vertrags-Sprache des Interfaces (das Constraint ist explizit benannt, dokumentiert, wiederverwendbar)
Diese Kombination ist Standard, sobald du Generics ernsthaft einsetzt. Das Stdlib-Paket constraints (heute ersetzt durch cmp.Ordered) und das slices-Paket sind voll davon. Mehr dazu im Constraints-Artikel.
„Accept interfaces, return structs" — bleibt gültig
Eine klassische Go-Maxime aus der Zeit vor Generics:
Accept interfaces, return structs.
Heißt: Funktions-Parameter sollten möglichst Interfaces sein (maximale Flexibilität beim Aufrufer), Rückgabewerte sollten konkrete Typen sein (maximale Information beim Aufrufer). Diese Regel ist mit Generics nicht überholt — sie ergänzt sich.
Generics ersetzen das Idiom dann, wenn du die Methoden-Polymorphie nicht brauchst, sondern reine Typ-Polymorphie willst. Sie ersetzen es nicht, wenn ein Konsumenten-Vertrag im Vordergrund steht. Eine Funktion, die irgendetwas liest, nimmt weiterhin io.Reader — nicht [T Reader]. Eine Funktion, die einen typsicheren Container baut, nimmt [T any] — nicht any.
package main
import (
"io"
)
// Akzeptiert ein Interface (io.Reader), gibt einen konkreten *Result zurück.
// Klassisches accept-interfaces-return-structs.
type Result struct {
Bytes int64
Hashed [32]byte
}
func Process(r io.Reader) (*Result, error) {
// ... liest aus r, hashed, zählt Bytes
return &Result{}, nil
}
// Generischer Builder — der konkrete Item-Typ bleibt beim Aufrufer.
type Pipeline[T any] struct {
items []T
}
func NewPipeline[T any]() *Pipeline[T] {
return &Pipeline[T]{}
}Faustregel-Erweiterung: Wenn du nur die Methode aufrufen willst, nimm das Interface; wenn du den Typ konservieren willst, nimm den Generic. Beides hat seinen Platz im selben Paket, oft sogar in derselben Datei.
Praxis 1 — Refactoring Contains von any auf Generic
Eine alte Code-Basis hat eine Helfer-Funktion, die mit any arbeitet:
// VORHER — pre-Go-1.18 Style. Typsicherheit beim Aufrufer verloren.
func Contains(haystack []any, needle any) bool {
for _, x := range haystack {
if x == needle {
return true
}
}
return false
}
// Aufruf — der Caller muss explizit boxen:
// xs := []any{1, 2, 3} // boxing aller Elemente!
// Contains(xs, 2)Drei Probleme dieser Variante: erstens muss der Aufrufer seine []int-Slices in []any umkopieren (jedes Element wird einzeln geboxt — N Heap-Allokationen pro Aufruf). Zweitens kann der Compiler den Mismatch Contains([]any{1,2,3}, "drei") nicht abfangen — der Vergleich ist erlaubt, schlägt nur zur Laufzeit fehl (Type-Mismatch im == zwischen int und string ist sogar erlaubt, ergibt einfach false, was den Bug versteckt). Drittens ist der Code in der Funktion selbst schwerer zu lesen, weil any keine Typ-Information trägt.
Die generische Umschreibung beseitigt alle drei Punkte:
package main
import "fmt"
// NACHHER — generisch, typsicher, kein Boxing.
func Contains[T comparable](haystack []T, needle T) bool {
for _, x := range haystack {
if x == needle {
return true
}
}
return false
}
func main() {
xs := []int{1, 2, 3}
ys := []string{"go", "rust"}
fmt.Println(Contains(xs, 2)) // true — direkt mit []int
fmt.Println(Contains(ys, "zig")) // false — direkt mit []string
// Contains(xs, "drei") // COMPILE-FEHLER — kein silent-false mehr
}true
falseDas Refactoring ist ein Zeilen-Diff: any → [T comparable] T, fertig. Aufrufer brauchen nichts zu ändern (Contains(xs, 2) funktioniert weiter — Type-Inference holt T = int). Die Performance-Verbesserung kommt automatisch, weil das alte Boxing entfällt.
Das ist die kanonische Generics-Migration: alte any-Helfer, die nur Typ-Polymorphie ohne Methoden-Aufruf brauchten, sind heute einzeilige Generic-Funktionen. Die Stdlib hat genau diese Migration in den Paketen slices und maps für sich gemacht.
Praxis 2 — Wo Interface die einzige richtige Antwort ist
Spiegelbildlicher Fall: io.Reader. Hier wäre ein generischer Ansatz objektiv schlechter.
package main
import (
"fmt"
"io"
"os"
"strings"
)
// Klassisch: nimmt jedes io.Reader-Implementing — Datei, String,
// Netzwerk, gzip-Decompressor, was auch immer.
func CountBytes(r io.Reader) (int64, error) {
buf := make([]byte, 4096)
var total int64
for {
n, err := r.Read(buf)
total += int64(n)
if err == io.EOF {
return total, nil
}
if err != nil {
return total, err
}
}
}
func main() {
// Aufrufer kann beliebig wechseln:
n1, _ := CountBytes(strings.NewReader("hallo welt"))
fmt.Println(n1)
f, err := os.Open("/etc/hostname")
if err == nil {
defer f.Close()
n2, _ := CountBytes(f)
fmt.Println(n2)
}
}10
9Warum geht hier kein Generic? Drei Gründe:
Erstens unterscheiden sich die Implementierungen fundamental. Ein os.File.Read ist ein Syscall; strings.Reader.Read ist eine Slice-Kopie; ein net.Conn.Read blockiert auf dem Socket. Das ist genau der „verschiedenes Verhalten hinter derselben Methode"-Fall, den Taylor als klares Interface-Territorium markiert.
Zweitens kommt der konkrete Typ erst zur Laufzeit ins Spiel. Ein HTTP-Server bekommt Verbindungen, die er nicht zur Compile-Zeit kennt; eine Pipeline tauscht ihre Quelle aus, ohne dass die Verarbeitungsschicht neu kompiliert wird. Generics würden zwingen, alle möglichen Quell-Typen schon im Aufruf zu kennen — das ist absurd.
Drittens würde ein generisches func CountBytes[R io.Reader](r R) ... einen separaten Funktions-Body pro Reader-Typ generieren (im Worst Case) oder pro Shape (Standard) — und der einzige Vorteil davon wäre, dass der Compiler den konkreten Typ kennt. Den brauchst du in CountBytes aber nirgends. Du rufst nur r.Read(buf) — das funktioniert mit Interface-Indirektion genauso gut.
Ergo: io.Reader ist ein Interface, weil das Polymorphie-Modell zur Laufzeit nötig ist und die Implementierungen sich fundamental unterscheiden. Ein generischer Reader wäre eine Verschlechterung in jeder Dimension — Lesbarkeit, Kompatibilität, Lebenszeit-Flexibilität.
Entscheidungs-Matrix
Die akkumulierten Heuristiken zusammengezogen:
| Situation | Wähle |
|---|---|
| Du schreibst dieselbe Funktion gerade zum zweiten Mal für einen anderen Typ | Generic |
| Du operierst auf Slice/Map/Channel von beliebigem Element-Typ | Generic |
| Du baust eine Datenstruktur (Set, Tree, Queue), die typsicher bleiben soll | Generic |
Du willst den konkreten Typ beim Aufrufer erhalten (kein any.(T)) | Generic |
| Du brauchst Zero-Boxing in Hot-Loops mit value-types | Generic |
Die Implementierungen unterscheiden sich fundamental (io.Reader, Stringer) | Interface |
Du brauchst heterogene Sammlungen ([]Animal mit Dog/Cat/Bird) | Interface |
| Der Vertrag ist Methoden-basiert, du rufst nur Methoden auf | Interface |
| Du beginnst gerade ein neues Modul und designst Constraints | Erst konkret schreiben, später entscheiden |
Constraint-Interface (cmp.Ordered) trifft den Bedarf | Generic mit Interface-Constraint (Hybrid) |
Die Trumpf-Heuristik aus dem when-generics-Blog: „Avoid type parameters until you notice that you are about to write the exact same code multiple times." Wenn du diesen Satz in der eigenen Code-Basis als Default verinnerlichst, triffst du in 90 % der Fälle die richtige Wahl. Die restlichen 10 % sind Benchmarks und API-Design — und da hilft Lesen, Messen und ein zweiter Blick durch jemand anderen.
Interessantes
Erst konkret, dann generisch.
Der wichtigste Take-away aus Ian Lance Taylors Blog: schreibe die Funktion zuerst konkret, lass den zweiten Aufrufer auf sich warten und hebe sie erst dann auf einen Type-Parameter, wenn die Code-Duplikation real ist. Spekulative Abstraktion ist die häufigste Generics-Falle in jungen Go-Code-Basen.
Constraints sind Interfaces.
Jedes Generic-Constraint ist syntaktisch ein Interface. comparable, any, cmp.Ordered, fmt.Stringer — alles Interfaces, die im Constraint-Slot stehen. Die beiden Mechanismen sind enger verzahnt, als die Lehrbuch-Trennung suggeriert.
Performance ist selten der Grund.
Generics sind in der Regel nicht schneller als Interfaces — das GC Shape Stenciling kann sogar zusätzliche Indirektion einführen. Wer per Generic-Refactor schneller werden will, sollte erst messen, dann refactorn.
Boxing-Vermeidung ist der echte Performance-Trumpf.
Wenn die Alternative func f(x any) mit Heap-Allokation pro Aufruf wäre, gewinnt Generic deutlich — bei value-types ohne Methoden-Aufruf entfällt das Boxing komplett. Das ist eine der wenigen Performance-Argumentationen, die wirklich trägt.
Heterogene Sammlungen sind Interface-Territorium.
[]Animal mit Hunden und Katzen zusammen geht nur per Interface. Ein []T ist immer monomorph, also für genau einen konkreten Typ — wer einen Container für „beliebige Tiere" braucht, hat keine Alternative zum Interface.
accept interfaces, return structs bleibt gültig.
Die alte Go-Maxime ist mit Generics nicht überholt, sondern ergänzt. Funktions-Parameter, die nur Methoden aufrufen, bleiben Interfaces; Datenstrukturen, die typsichere Inhalte halten, werden Generic. Beide Idiome leben nebeneinander im selben Paket.
io.Reader wird kein Generic.
Wenn die Implementierungen sich fundamental unterscheiden — Datei, Socket, In-Memory — ist Polymorphie zur Laufzeit unverzichtbar. Ein generischer Reader würde alle Vorteile (heterogene Pipelines, dynamische Quellen, Wrapping-Schichten wie gzip.Reader) zerstören.
API-Typsicherheit beim Aufrufer ist der eigentliche Generic-Gewinn.
Der wichtigste Vorteil ist nicht Performance, sondern dass Box[int] und Box[string] getrennte, miteinander inkompatible Typen sind. Der Aufrufer braucht keine Type-Assertions, der Compiler fängt jede Verwechslung. Diese Eigenschaft ist in any-basierten APIs unerreichbar.
Weiterführende Ressourcen
Externe Quellen
- When To Use Generics — Ian Lance Taylor, Go Blog
- Go FAQ: Why does Go have type parameters?
- An Introduction To Generics — Go Blog
- Go 1.18 Release Notes — Generics
cmp.Ordered— Stdlibslices-Paket — Stdlib