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
| Aspekt | Static Dispatch (Generic) | Dynamic Dispatch (dyn) |
|---|---|---|
| Dispatch-Zeit | Compile-Zeit | Laufzeit |
| Mechanismus | Monomorphization | Vtable-Lookup |
| Code-Größe | Größer (mehrere Varianten) | Kleiner (eine Variante) |
| Aufruf-Performance | Inlining möglich | Indirekter Sprung |
| Compile-Zeit | Länger | Kürzer |
| Heterogene Sammlungen | Nein | Ja |
| Object-Safety nötig | Nein | Ja |
| API-Ergonomie | Komplexer (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.
use std::fmt::Display;
fn drucke<T: Display>(x: T) {
println!("{x}");
}
fn main() {
drucke(42_i32);
drucke("hello");
drucke(3.14);
}// 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 Sammlungen —
Vec<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.
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:
xist ein Fat Pointer (data + vtable)- Beim Aufruf von
println!("{x}")schaut die Runtime in die Vtable - Holt den Funktions-Pointer für
Display::fmt - 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öglich —
Vec<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.
// 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 identischEntscheidungs-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
Selfoder Generic-Parametern brauchst - Du eine Library schreibst und Aufruf-Stellen Inlining nutzen können sollen
// 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
// 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.
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.
// 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
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
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
// 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
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
// 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
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
- The Rust Book – Performance of Code Using Generics
- The Rust Book – Trait Objects Performance
- Rust Performance Book – Generics
- Polymorphism in Rust – Mostly Adequate