Die Monomorphisierung ist das, was Rusts Generics von denen vieler anderer Sprachen unterscheidet. Statt zur Laufzeit über einen generischen Typ zu dispatchen (wie Java mit Type-Erasure oder C# mit Reified Generics plus Vtable), erzeugt der Rust-Compiler zur Compile-Zeit für jede konkrete Typ-Kombination eine eigene spezialisierte Version. Das macht Generics kostenlos zur Laufzeit — kein Vtable-Lookup, keine Boxing-Allocation, keine Indirection. Der Trade-off: größere Binaries und längere Compile-Zeiten. Dieser Artikel zeigt, was Monomorphisierung im Detail ist, welche Folgen sie hat, und wann du sie bewusst umgehen willst.

Was Monomorphisierung macht

Wenn du eine generische Funktion schreibst, sieht es so aus, als gäbe es eine Funktion, die mit vielen Typen funktioniert.

Rust Generic-Code
fn identitaet<T>(x: T) -> T { x }

fn main() {
    let a = identitaet(42);                // T = i32
    let b = identitaet("hallo");            // T = &str
    let c = identitaet(vec![1, 2, 3]);      // T = Vec<i32>
}

Aber der Compiler sieht es anders. Er macht aus der einen generischen Funktion drei verschiedene spezialisierte Funktionen, jede für einen konkreten Typ:

Rust Konzeptuell — was der Compiler generiert
fn identitaet_i32(x: i32) -> i32 { x }
fn identitaet_str(x: &str) -> &str { x }
fn identitaet_vec_i32(x: Vec<i32>) -> Vec<i32> { x }

// Im Main: Aufrufe gehen auf die spezialisierten Versionen
// identitaet(42)         → identitaet_i32(42)
// identitaet("hallo")    → identitaet_str("hallo")
// identitaet(vec![1,2,3]) → identitaet_vec_i32(vec![1,2,3])

Diese Spezialisierung passiert vollständig zur Compile-Zeit. Im fertigen Binary gibt es keine generische Funktion mehr — nur die konkreten Versionen. Aus der Sicht der Maschinen-Ebene gibt es keinen Unterschied zwischen Generic-Code und manuell für jeden Typ geschriebenem Code.

Diese Eigenschaft heißt Zero-Cost-Abstraktion: das Generic-Konstrukt kostet zur Laufzeit nichts. Eine generische Funktion ist exakt so schnell wie eine spezialisierte. Optimierer können sogar inline-en und weitere Optimierungen durchführen, weil sie den konkreten Typ kennen.

Der Vergleich zu Java/C#

In Java und C# gibt es Generics, aber sie werden anders implementiert.

Java verwendet Type-Erasure: zur Compile-Zeit sind Generics da (List<String>), aber im Bytecode wird der Type-Parameter „gelöscht" und durch Object (mit Casts) ersetzt. Eine List<String> und eine List<Integer> sind im Bytecode identisch. Folge: Type-Parameter sind nicht zur Laufzeit verfügbar (instanceof T geht nicht), und Wert-Typen brauchen Boxing.

C# hat Reified Generics: die Type-Information bleibt im IL/Metadata erhalten. Bei der Just-in-Time-Compilation werden spezialisierte Versionen erzeugt, ähnlich zu Rusts Monomorphisierung. Aber: die Spezialisierung läuft zur Laufzeit, beim ersten Aufruf des konkreten Typs.

Rust kombiniert das Beste aus beiden: vollständige Spezialisierung wie C#, aber zur Compile-Zeit. Damit gibt es keinen Runtime-Overhead, kein Boxing, kein JIT-Aufwärmen. Die Kosten zahlt man bei der Compilation.

Konsequenzen: Binary-Größe

Monomorphisierung hat einen Preis: jede konkrete Variante deines Generic-Codes wird separat in die Binary kompiliert.

Rust Konsequenzen-Beispiel
// Eine generische Funktion mit komplexer Logik:
fn sortiere_und_filtere<T: Ord + Clone>(items: &[T]) -> Vec<T> {
    let mut sorted: Vec<T> = items.to_vec();
    sorted.sort();
    sorted.into_iter().filter(|_| true).collect()
}

// Wenn diese Funktion mit 10 verschiedenen Typen aufgerufen wird,
// landen 10 spezialisierte Versionen im Binary.

Bei einer kleinen Funktion ist das egal. Bei einer großen Funktion, die mit vielen verschiedenen Typen verwendet wird, summiert sich das. In serde (dem populärsten Serialisierungs-Crate) kann die Monomorphisierung zu massiver Code-Aufblähung führen — eine Anwendung mit dutzenden serialize-baren Typen hat dutzende spezialisierte Funktionen.

In der Praxis ist das selten ein echtes Problem. Moderne Build-Systeme nutzen Tricks wie Link-Time Optimization (LTO), um redundanten Code zu eliminieren. Aber bei sehr großen Codebases (Compiler, Game-Engines) kann es sich auswirken. Tools wie cargo bloat zeigen, welche Funktionen wieviel Binary-Größe belegen.

Konsequenzen: Compile-Zeit

Der zweite Trade-off ist die Compile-Zeit. Jede konkrete Variante muss vom Compiler analysiert, optimiert und in Maschinencode übersetzt werden.

Bei generischer Code mit vielen konkreten Verwendungen kann das schmerzhaft sein. Die längsten Compile-Zeiten in der Rust-Welt kommen oft von Crates mit umfangreichen Macro-Generierungen plus Generic-Spezialisierungen — serde, diesel, axum sind klassische Beispiele.

Gegenmaßnahmen, die produktive Rust-Projekte einsetzen:

Inkrementelle Compilation ist standardmäßig aktiv. Nur geänderte Crates werden neu kompiliert; der Cache speichert die Monomorphisierungs-Ergebnisse.

cargo check statt cargo build während der Entwicklung. Es macht keine Code-Generierung, prüft nur die Typen — viel schneller.

mold oder lld als Linker statt des Default-Linkers. Bei großen Projekten spart das beim finalen Linken erheblich.

Separate Trait-Implementierungen statt Generic-Funktionen, wenn die Spezialisierung über sehr viele Typen läuft.

Der Static-vs-Dynamic-Dispatch-Vergleich

Monomorphisierung ist statischer Dispatch. Die Alternative ist dynamischer Dispatch über dyn Trait.

Rust Static vs. Dynamic
use std::fmt::Display;

// Static Dispatch — Monomorphisierung
fn drucke_static<T: Display>(x: T) {
    println!("{x}");
}

// Dynamic Dispatch — Vtable
fn drucke_dynamic(x: &dyn Display) {
    println!("{x}");
}

fn main() {
    // Static: Compiler generiert spezialisierte Versionen
    drucke_static(42);          // → spezialisierte Version für i32
    drucke_static("hallo");      // → spezialisierte Version für &str

    // Dynamic: nur eine Funktion, Vtable-Lookup zur Laufzeit
    drucke_dynamic(&42);
    drucke_dynamic(&"hallo");
}

Die zwei Dispatch-Formen haben unterschiedliche Charakteristiken:

Static Dispatch (Generic, Monomorphisierung):

  • Compile-Zeit-Spezialisierung
  • Inline-fähig, maximale Performance
  • Größere Binary
  • Längere Compile-Zeit
  • Typ-Parameter zur Compile-Zeit fest

Dynamic Dispatch (dyn Trait, Vtable):

  • Eine kompilierte Version, Lookup zur Laufzeit
  • Indirection-Cost (Vtable-Pointer dereferenzieren)
  • Kleinere Binary
  • Schnellere Compile-Zeit
  • Typ erst zur Laufzeit bekannt
  • Heterogene Sammlungen möglich (Vec<Box<dyn Trait>>)

In der Praxis: Static ist der Default, weil Performance meist wichtiger ist als Binary-Größe. Dynamic ist sinnvoll, wenn du wirklich verschiedene Typen in einer Sammlung brauchst (Plugin-Systeme, GUI-Frameworks) oder wenn die Code-Größe wirklich ein Problem ist.

Was Monomorphisierung möglich macht

Die Spezialisierung erlaubt einige Optimierungen, die mit Dynamic Dispatch nicht möglich wären.

Inlining

Rust Inline-Optimierung
#[inline]
fn doppelt<T: std::ops::Add<Output = T> + Copy>(x: T) -> T {
    x + x
}

fn main() {
    let n = doppelt(5);          // Vom Compiler oft inline-d zu: 5 + 5
    assert_eq!(n, 10);
}

Bei einer monomorphisierten Funktion kennt der Optimierer den konkreten Typ und kann den Funktions-Body inline-en — der Aufruf wird durch den Code der Funktion ersetzt, ohne Funktions-Aufruf-Overhead. Bei sehr kleinen Funktionen ist das ein massiver Performance-Gewinn.

Mit Dynamic Dispatch wäre das nicht möglich: der Vtable-Lookup ist eine echte Indirection, die der Compiler nicht weg-optimieren kann.

Konstant-Folding

Rust Const-Folding
fn berechne<T: std::ops::Mul<Output = T> + Copy>(x: T, faktor: T) -> T {
    x * faktor
}

fn main() {
    // Mit Monomorphisierung kann der Compiler konstante Argumente
    // direkt ausrechnen:
    let n = berechne(5, 2);     // Compiler kann zu `let n = 10` werden
    assert_eq!(n, 10);
}

Konstant-Folding (das Voraus-Berechnen konstanter Ausdrücke) funktioniert gut mit monomorphisiertem Code, weil der Compiler den konkreten Typ und die konkrete Operation kennt.

Trait-Method-Inline

Bei einem Trait-Method-Call auf einem generischen Typ-Parameter weiß der Compiler nach Monomorphisierung, welche konkrete Implementation aufgerufen wird. Er kann sie direkt einbauen, statt einen Method-Lookup zu machen.

Wann Monomorphisierung problematisch ist

Es gibt Situationen, in denen die Standard-Spezialisierung suboptimal ist.

Viele verschiedene konkrete Typen

Wenn eine generische Funktion mit dutzenden verschiedenen Typen verwendet wird, entstehen dutzende spezialisierte Varianten. Bei einer großen Funktion summiert sich das zu erheblicher Binary-Aufblähung.

Gegenmaßnahme: Funktion in zwei Teile splitten — einen kleinen generischen Vor-Verarbeiter, der die Daten in einen non-generischen Typ konvertiert, und einen großen non-generischen Kern, der nur einmal kompiliert wird.

Rust Split-Pattern
// Statt: alle Logik generisch
// fn verarbeite<T: Into<String>>(x: T) {
//     let s: String = x.into();
//     // 100 Zeilen Logik mit s
// }

// Besser: Generic-Wrapper plus non-generischer Kern
fn verarbeite<T: Into<String>>(x: T) {
    verarbeite_intern(x.into())
}

fn verarbeite_intern(s: String) {
    // 100 Zeilen Logik mit s — nur EINMAL kompiliert
    let _ = s;
}

Der äußere Wrapper ist klein und wird pro Typ spezialisiert; der Kern ist groß, aber non-generisch und damit nur einmal im Binary. So bekommt man die Bequemlichkeit der Generic-API ohne die Binary-Aufblähung.

Trait-Implementation-Sprawl

Wenn ein Trait für viele verschiedene Typen implementiert wird und der Code in den Default-Methoden komplex ist, multipliziert sich das.

Gegenmaßnahme: Statische Funktionen aufrufen, die nicht generisch sind. Die Trait-Methode dispatcht nur, die echte Arbeit macht eine non-generische Funktion.

Praxis: Monomorphisierung im Alltag

Iterator-Pipelines

Rust Iterator-Performance
fn main() {
    let zahlen: Vec<i32> = (0..1000)
        .filter(|n| n % 2 == 0)
        .map(|n| n * 2)
        .filter(|n| n < &100)
        .collect();

    // Compiler monomorphisiert die ganze Pipeline:
    // - filter mit konkretem i32-Predicate
    // - map mit konkreter i32-Transformation
    // - filter erneut mit konkretem i32-Predicate
    // - collect zu Vec<i32>
    // Resultat: hochoptimierter Maschinencode, oft inline-d zu einer Schleife

    assert_eq!(zahlen, vec![0, 4, 8, 12, 16, 20, 24, 28, 32, 36, 40, 44, 48,
                             52, 56, 60, 64, 68, 72, 76, 80, 84, 88, 92, 96]);
}

Rust's Iterator-Pipelines sind Paradebeispiele für die Vorteile von Monomorphisierung. Eine filter().map().filter().collect()-Kette wird vom Compiler zu einer einzigen, hochoptimierten Schleife reduziert — kein Vtable, keine Heap-Allocation für Zwischenwerte, keine Indirection. Performance auf dem Niveau einer hand-geschriebenen for-Schleife.

Generic-Container

Rust Generic-Container
use std::collections::HashMap;

fn main() {
    // HashMap<String, i32> ist eine spezialisierte Variante
    let mut m1: HashMap<String, i32> = HashMap::new();
    m1.insert("a".to_string(), 1);

    // HashMap<i32, Vec<f64>> ist eine andere Spezialisierung
    let mut m2: HashMap<i32, Vec<f64>> = HashMap::new();
    m2.insert(1, vec![1.0, 2.0]);

    // Beide nutzen den gleichen Source-Code, generieren aber
    // verschiedenen Maschinencode (Bucket-Größen, Hash-Funktion-Inlining etc.)
}

Jede konkrete HashMap<K, V>-Variante ist eine eigene spezialisierte Implementation. Das ermöglicht extrem optimierten Code — die Bucket-Operationen sind auf den konkreten K/V-Typ zugeschnitten. Der Trade-off: bei vielen verschiedenen Map-Typen im Code mehr Binary.

Wann du dich darum kümmern musst

In normalen Anwendungen brauchst du dich um Monomorphisierung nicht zu kümmern. Der Compiler macht das automatisch, die Performance ist hervorragend, die Binary-Größe ist akzeptabel.

Du solltest sie bewusst im Blick haben, wenn:

  • Du eine Library schreibst, die mit vielen verschiedenen konkreten Typen verwendet wird (serde, ORMs, etc.).
  • Deine Binary-Größe ein Problem ist (Embedded, WASM, Distribution-Beschränkungen).
  • Deine Compile-Zeit ein Problem ist (große Code-Basis, langsame Iteration-Zyklen).

In diesen Fällen sind die Split-Patterns (Generic-Wrapper + non-generischer Kern) oder bewusster Wechsel zu Dynamic Dispatch valide Optimierungs-Werkzeuge.

Besonderheiten

Monomorphisierung = Compile-Zeit-Spezialisierung.

Für jede konkrete Typ-Kombination einer generischen Funktion erzeugt der Compiler eine eigene Version. Im Binary gibt es keine Generic-Funktion mehr, nur konkrete Spezialisierungen.

Zero-Cost-Abstraktion.

Generic-Code ist zur Laufzeit exakt so schnell wie hand-geschriebener Code. Keine Vtable, kein Boxing, keine Indirection. Die Kosten zahlt man bei der Compilation.

Trade-off: größere Binaries.

Jede konkrete Variante steht im Binary. Bei vielen Typen × großen generischen Funktionen summiert sich das. Tools wie cargo bloat zeigen, welche Funktionen wieviel Platz brauchen.

Trade-off: längere Compile-Zeiten.

Jede Spezialisierung muss kompiliert werden. Bei Crates wie serde, diesel kann das schmerzhaft sein. Inkrementelle Compilation hilft; cargo check ist deutlich schneller als cargo build.

Anders als Java-Type-Erasure.

Java löscht Type-Parameter zur Laufzeit, was Boxing erzwingt. C# hat Reified Generics, aber zur Runtime. Rust ist die einzige große Sprache mit vollständiger Compile-Zeit-Spezialisierung.

Static vs. Dynamic Dispatch — bewusste Wahl.

Generic-Code (Static) ist Standard und schneller. dyn Trait (Dynamic) hat eine Indirection, ist aber bei heterogenen Sammlungen unverzichtbar. Wähle nach Use-Case.

Inlining ist nur mit Monomorphisierung möglich.

Bei Static Dispatch kennt der Compiler den konkreten Typ und kann die Funktion in den Aufruf einbauen. Mit Dynamic Dispatch nicht. Daher die massiven Performance-Unterschiede bei kleinen Funktionen.

Split-Pattern bei Binary-Bloat.

Generic-Wrapper plus non-generischer Kern. Die Wrapper-Funktion ist klein und wird pro Typ spezialisiert; der Kern ist groß, aber nur einmal kompiliert. Hilft bei großen Generic-Funktionen mit vielen Typen.

Weiterführende Ressourcen

Externe Quellen

/ Weiter

Zurück zu Generics

Zur Übersicht