Rust bietet zwei Welten für Trait-Polymorphismus, die auf den ersten Blick ähnlich aussehen, sich aber fundamental unterscheiden: Static Dispatch über Generics (fn foo<T: Trait>) und Dynamic Dispatch über Trait-Objekte (fn foo(x: &dyn Trait)). Wer beide kennt, kann gezielt entscheiden — keine Welt ist universell besser, jede hat klare Stärken. Dieser Artikel klärt die Mechanismen, die Performance-Charakteristik, und gibt konkrete Heuristiken: wann was wählen.

Die zwei Welten in einer Tabelle

AspektStatic Dispatch (Generic)Dynamic Dispatch (dyn)
Dispatch-ZeitCompile-ZeitLaufzeit
MechanismusMonomorphizationVtable-Lookup
Code-GrößeGrößer (mehrere Varianten)Kleiner (eine Variante)
Aufruf-PerformanceInlining möglichIndirekter Sprung
Compile-ZeitLängerKürzer
Heterogene SammlungenNeinJa
Object-Safety nötigNeinJa
API-ErgonomieKomplexer (Type-Parameter)Einfacher (normale Werte)

Static Dispatch — Monomorphization

Bei fn foo<T: Trait> erzeugt der Compiler für jeden konkreten Typ T, mit dem die Funktion aufgerufen wird, eine eigene Kopie der Funktion — spezialisiert auf diesen Typ.

Rust Generic-Source
use std::fmt::Display;

fn drucke<T: Display>(x: T) {
    println!("{x}");
}

fn main() {
    drucke(42_i32);
    drucke("hello");
    drucke(3.14);
}
Rust Was der Compiler intern erzeugt
// Drei spezialisierte Versionen, in etwa so:
fn drucke_i32(x: i32) {
    println!("{x}");      // mit i32::fmt direkt
}

fn drucke_str(x: &str) {
    println!("{x}");      // mit str::fmt direkt
}

fn drucke_f64(x: f64) {
    println!("{x}");      // mit f64::fmt direkt
}

Das ist Monomorphization: aus einer generischen Source-Funktion werden mehrere konkrete Maschinencode-Funktionen. Bei jedem Aufruf weiß der Compiler genau, welche aufzurufen ist — direkter Sprung, kein Vtable-Lookup. Außerdem kann der Compiler inlinen: bei kleinen Funktionen wird der Body direkt an die Aufruf-Stelle eingesetzt, der Funktions-Overhead fällt weg.

Vorteile:

  • Maximale Performance — kein indirekter Aufruf, Inlining möglich
  • Maximale Spezialisierung — der Compiler kann pro Typ optimieren

Nachteile:

  • Code-Größe wächst — jede Variante ist eigener Maschinencode
  • Compile-Zeit länger — der Compiler muss jede Variante kompilieren
  • Keine heterogenen SammlungenVec<T> hat nur einen Typ T

Dynamic Dispatch — Vtable

Bei fn foo(x: &dyn Trait) erzeugt der Compiler eine einzige Funktion, die zur Laufzeit über eine Vtable auf die richtige Implementation zugreift.

Rust dyn-Source
use std::fmt::Display;

fn drucke(x: &dyn Display) {
    println!("{x}");
}

fn main() {
    drucke(&42_i32);
    drucke(&"hello");
    drucke(&3.14);
}

Hier gibt es nur eine Maschinencode-Funktion. Bei jedem Aufruf:

  1. x ist ein Fat Pointer (data + vtable)
  2. Beim Aufruf von println!("{x}") schaut die Runtime in die Vtable
  3. Holt den Funktions-Pointer für Display::fmt
  4. Ruft diesen Pointer auf

Das ist ein indirekter Aufruf: der Branch-Predictor der CPU muss raten, wohin der Sprung geht. Bei wiederholten gleichen Typen ist die Vorhersage gut; bei zufälliger Verteilung schlechter. Inlining ist hier nicht möglich — der Compiler weiß ja nicht, welche Implementation aufgerufen wird.

Vorteile:

  • Kompakter Code — eine Funktion statt mehrerer
  • Schnellere Compile-Zeit — keine Monomorphization-Explosion
  • Heterogene Sammlungen möglichVec<Box<dyn Trait>>

Nachteile:

  • Indirekter Aufruf — wenige Nanosekunden Overhead pro Aufruf
  • Kein Inlining — der Aufruf bleibt ein Call, kein Body-Inline
  • Object-Safety nötig — der Trait muss bestimmte Regeln erfüllen

Performance — die nüchterne Realität

Der Performance-Unterschied wird oft überschätzt. In typischen Anwendungen ist der Unterschied kleiner als 5%, oft nicht messbar. Wo er messbar wird:

  • Hot Loops mit Millionen Iterationen — der indirekte Aufruf summiert sich
  • Sehr kleine Funktionen — wenn die Funktion selbst nur wenige Instruktionen hat, ist der Aufruf-Overhead anteilig groß
  • Number-Crunching — Inlining macht hier riesige Unterschiede

Wo der Unterschied nicht messbar ist:

  • I/O-bound Code (Disk, Netzwerk) — die Wartezeit dominiert
  • Web-Server-Handler — der Request-Overhead dominiert
  • GUI-Event-Handling — die Renderzeit dominiert

Praktisch heißt das: bei normalen APIs ist die Wahl eine Design-Entscheidung, keine Performance-Frage. Wenn du nicht in einem Hot-Loop bist, wähle was besser ergonomisch passt.

Rust Microbenchmark-Sketch
// Pseudo-Code für Benchmark
// Static:
fn process_static<T: Process>(items: &[T]) {
    for item in items { item.process(); }
}

// Dynamic:
fn process_dynamic(items: &[Box<dyn Process>]) {
    for item in items { item.process(); }
}

// Typischer Unterschied bei kleinen process()-Methoden:
//   Static:  ~10 ns/iter
//   Dynamic: ~12 ns/iter
// Bei großen Methoden (>1 µs): praktisch identisch

Entscheidungs-Heuristik

Wann wählst du was? Hier eine praxistaugliche Faustregel.

Wähle Generic / Static Dispatch wenn:

  • Du nur einen konkreten Typ pro Aufruf-Stelle hast (die Compile-Zeit-Polymorphie reicht)
  • Du maximale Performance in einem Hot-Loop brauchst
  • Du Trait-Methoden mit Self oder Generic-Parametern brauchst
  • Du eine Library schreibst und Aufruf-Stellen Inlining nutzen können sollen
Rust Generic ist die richtige Wahl
// Sortier-Funktion: pro Aufruf-Stelle ein Item-Typ
fn sort_by_key<T, F, K>(slice: &mut [T], f: F)
where
    F: Fn(&T) -> K,
    K: Ord,
{
    slice.sort_by_key(f);
}

Wähle dyn / Dynamic Dispatch wenn:

  • Du heterogene Sammlungen brauchst (Vec<Box<dyn Trait>>)
  • Du eine Plugin-Architektur baust (Implementierungen zur Laufzeit gewählt)
  • Du Compile-Zeit verkürzen willst (große generic Funktionen blähen Builds auf)
  • Du dynamische Konfiguration brauchst (Strategy aus Config-Datei)
  • Die Funktion ist selten aufgerufen und Performance-Overhead irrelevant
Rust dyn ist die richtige Wahl
// Plugin-Registry: heterogene Plugins zur Laufzeit
pub struct Registry {
    plugins: Vec<Box<dyn Plugin>>,
}

pub trait Plugin {
    fn execute(&self);
}

Hybrid: beide kombinieren

Manchmal ist die beste Lösung beide gleichzeitig: eine generic API mit interner dyn-Storage, oder umgekehrt.

Rust Hybrid-Pattern
pub trait Filter { fn apply(&self, s: &str) -> String; }

pub struct Pipeline {
    // Interner Storage als dyn — heterogen
    filters: Vec<Box<dyn Filter>>,
}

impl Pipeline {
    // Generic-API: bequem für Konsumenten
    pub fn add<F: Filter + 'static>(mut self, f: F) -> Self {
        self.filters.push(Box::new(f));
        self
    }
}

Konsumenten nutzen .add(MyFilter) ohne Box::new — die Funktion ist generic. Intern landet alles als Box<dyn Filter>, damit verschiedene Filter-Typen koexistieren können.

Code-Größe — der unsichtbare Faktor

Bei großen Codebases ist die Code-Größe ein realer Faktor. Eine generische Funktion, die mit 50 verschiedenen Typen aufgerufen wird, wird 50× im Binary stehen. Bei massivem Generic-Einsatz kann das Binary mehrere MB anschwellen.

Rust Code-Bloat-Risiko
// Wenn diese Funktion mit 30 verschiedenen Iterator-Typen aufgerufen wird,
// generiert der Compiler 30 verschiedene Maschinencode-Versionen
fn statistik<I: Iterator<Item = i32>>(iter: I) -> (i32, i32) {
    let mut min = i32::MAX;
    let mut max = i32::MIN;
    for x in iter {
        min = min.min(x);
        max = max.max(x);
    }
    (min, max)
}

// Workaround: dyn-Variante als gemeinsame Implementation
fn statistik_intern(iter: &mut dyn Iterator<Item = i32>) -> (i32, i32) {
    let mut min = i32::MAX;
    let mut max = i32::MIN;
    for x in iter {
        min = min.min(x);
        max = max.max(x);
    }
    (min, max)
}

// Generic-Wrapper delegiert an dyn-Implementation
pub fn statistik_pub<I: Iterator<Item = i32>>(mut iter: I) -> (i32, i32) {
    statistik_intern(&mut iter)
}

Dieses Pattern (öffentliche generic API, interne dyn-Implementation) ist eine bekannte Optimierung für monomorphization bloat. Die Stdlib nutzt es an einigen Stellen.

Praxis: typische Entscheidungen

Numerische Berechnung — Generic

Rust Performance-kritisch
use std::ops::{Add, Mul};

// Generic: Compiler optimiert maximal für jeden Numeric-Typ
pub fn dot_product<T>(a: &[T], b: &[T]) -> T
where T: Add<Output = T> + Mul<Output = T> + Copy + Default
{
    a.iter().zip(b.iter())
        .map(|(x, y)| *x * *y)
        .fold(T::default(), |acc, v| acc + v)
}

Mathematik mit Type-Parametern: Generic. Der Compiler kann pro Typ (i32, f64, ...) optimal vektorisieren und inlinen.

Plugin-System — dyn

Rust Plugin-Architektur
pub trait Renderer {
    fn render(&self, scene: &str) -> Vec<u8>;
}

pub struct App {
    renderer: Box<dyn Renderer>,
}

impl App {
    pub fn neu(renderer: Box<dyn Renderer>) -> Self {
        Self { renderer }
    }

    pub fn render_scene(&self, scene: &str) -> Vec<u8> {
        self.renderer.render(scene)
    }
}

Renderer wird zur Konstruktor-Zeit gewählt (CLI-Argument, Config). Hier ist dyn ideal — der App-Code soll nicht für jeden Renderer-Typ separat kompiliert werden.

Iterator-Pipeline — Generic

Rust Iterator-Adapter
// Stdlib-Stil: alles generic für maximale Performance
pub fn process<I, F>(iter: I, transform: F) -> Vec<String>
where
    I: Iterator<Item = i32>,
    F: Fn(i32) -> String,
{
    iter.map(transform).collect()
}

Iterator-Adapter sind klassisch Generic. Inlining ist hier wichtig — die kleinen Closure-Calls würden mit dyn extrem teuer.

Heterogene Event-Liste — dyn

Rust Event-Bus
pub trait EventHandler {
    fn handle(&self, event: &str);
}

pub struct EventBus {
    handlers: Vec<Box<dyn EventHandler>>,
}

impl EventBus {
    pub fn publish(&self, event: &str) {
        for h in &self.handlers {
            h.handle(event);
        }
    }
}

Event-Bus mit verschiedenen Handler-Typen: heterogene Sammlung, also dyn.

Builder mit Generic — Builder-Pattern

Rust Builder-Pattern
// Builder ist meist Generic, weil pro Aufruf-Stelle ein konkreter Typ
pub struct QueryBuilder<T> {
    filter: T,
}

impl<T: Fn(&i32) -> bool> QueryBuilder<T> {
    pub fn neu(filter: T) -> Self {
        Self { filter }
    }

    pub fn matches(&self, x: &i32) -> bool {
        (self.filter)(x)
    }
}

Builder mit Closure-Parameter: Generic ist hier richtig. Wenn der Builder verschiedene Closure-Typen halten müsste, wäre Box<dyn Fn> die Alternative.

CLI-Output-Strategie — dyn

Rust Output-Strategy
pub trait Output {
    fn write(&mut self, line: &str);
}

struct StdoutOutput;
impl Output for StdoutOutput {
    fn write(&mut self, line: &str) { println!("{line}"); }
}

struct FileOutput { /* ... */ }
impl Output for FileOutput {
    fn write(&mut self, _line: &str) { /* ... */ }
}

// CLI-Argument bestimmt zur Laufzeit den Output:
fn select_output(target: &str) -> Box<dyn Output> {
    if target == "file" {
        Box::new(FileOutput { /* ... */ })
    } else {
        Box::new(StdoutOutput)
    }
}

Output wird zur Laufzeit gewählt — dyn ist klar die richtige Wahl.

Interessantes

Static Dispatch — Monomorphization zur Compile-Zeit.

Der Compiler erzeugt für jeden Typ eine eigene Kopie der generischen Funktion. Maximale Performance, größerer Binary, längere Compile-Zeit.

Dynamic Dispatch — Vtable zur Laufzeit.

Eine einzige Funktion, Methoden-Wahl über indirekte Pointer. Kleinerer Binary, schnellere Compile-Zeit, geringer Aufruf-Overhead.

Performance-Unterschied — meist irrelevant.

In typischen APIs unter 5% — oft nicht messbar. Nur in Hot-Loops mit kleinen Methoden wird der Unterschied real. Bei I/O- oder Render-bound Code verschwindet er vollständig.

Heterogene Sammlungen — nur mit dyn.

Vec<T> zwingt alle Elemente auf einen Typ T. Vec<Box<dyn Trait>> darf verschiedene konkrete Typen halten. Das ist der zentrale Anwendungsfall für dyn.

Object-Safety — Bedingung für dyn.

Traits mit generic Methoden, Self-Rückgabe oder Self-Wert-Parametern können nicht als dyn Trait verwendet werden. Workaround: where Self: Sized für problematische Methoden.

Hybrid-Pattern — Generic-API mit dyn-Storage.

Öffentliche generic Methoden für Bequemlichkeit, interne dyn-Speicherung für Flexibilität. Verbindet Ergonomie mit Heterogenität.

Monomorphization-Bloat — bei riesigen Codebases relevant.

Wenn eine generic Funktion mit 50 Typen aufgerufen wird, gibt es 50 Maschinencode-Versionen. Bei sehr großen Projekten kann das Binary anschwellen — dann lohnt sich ein interner dyn-Wrapper.

Faustregel: starte mit Generic, wechsle zu dyn bei Bedarf.

Generic ist der Default — maximale Performance, idiomatisch. Wechsle zu dyn, wenn du Heterogenität, Plugin-Architektur, oder Compile-Zeit-Reduktion brauchst.

Weiterführende Ressourcen

Externe Quellen

/ Weiter

Zurück zu Traits

Zur Übersicht