Was sind Traits? #
Traits sind Rusts Version von Interfaces. Ein Trait definiert eine Schnittstelle, eine Menge von Methoden, die ein Typ implementieren muss, außer es gibt eine Standardimplementierung. Wer schon mit Interfaces aus C#, Java oder Kotlin gearbeitet hat, kennt das Konzept.
trait Greet {
fn hello(&self) -> String;
}
struct German;
struct English;
impl Greet for German {
fn hello(&self) -> String {
String::from("Hallo!")
}
}
impl Greet for English {
fn hello(&self) -> String {
String::from("Hello!")
}
}Traits können auch eine Standardimplementierung mitbringen, die ein Typ überschreiben kann aber nicht muss:
trait Greet {
fn hello(&self) -> String {
String::from("Hi!")
}
}So weit, so bekannt.
Interessant wird es, wenn man Traits als Parameter verwendet, denn da beginnt die eigentliche Frage: Was passiert dann zur Compile-Zeit und welche Auswirkungen hat es auf die Laufzeit?
Monomorphisierung – Polymorphismus zur Compile-Zeit #
Wenn eine Funktion einen generischen Typ mit Trait-Bound erwartet, erzeugt der Rust-Compiler für jeden konkreten Typ, mit dem die Funktion aufgerufen wird, eine eigene spezialisierte Version. Das nennt sich Monomorphisierung.
fn greet<T: Greet>(g: &T) {
println!("{}", g.hello());
}Der Compiler sieht, dass greet mit German und English aufgerufen wird, und erzeugt intern je eine Funktion für Englisch und eine für Deutsch:
fn greet_german(g: &German) { println!("{}", g.hello()); }
fn greet_english(g: &English) { println!("{}", g.hello()); }Das ist gleichbedeutend mit dem impl Trait-Syntax, der etwas lesbarer ist:
fn greet(g: &impl Greet) {
println!("{}", g.hello());
}Vorteil:
Zur Laufzeit gibt es keinen Overhead. Der Compiler kennt den konkreten Typ, kann ihn inline optimieren und direkt den richtigen Funktionsaufruf einsetzen.
Nachteil:
Die Binärgröße wächst, denn für jeden Typ, mit dem die Funktion genutzt wird, landet eine eigene Kopie im kompilierten Code.
Dynamic Dispatch – Polymorphismus zur Laufzeit #
Manchmal ist der konkrete Typ erst zur Laufzeit bekannt, so zum Beispiel wenn verschiedene Typen in einer Liste gesammelt werden sollen.
Hier kommt dyn Trait ins Spiel.
fn greet(g: &dyn Greet) {
println!("{}", g.hello());
}Statt einer spezialisierten Funktion pro Typ erzeugt der Compiler ein Trait Object. Das ist ein sogenannter Fat Pointer, der aus zwei Teilen besteht, einem Zeiger auf die Daten und einem Zeiger auf eine vtable.
Die vtable wiederum ist eine Tabelle mit Funktionszeigern für alle Methoden des Traits, wo zur Laufzeit die richtige Implementierung nachgeschlagen und aufgerufen wird.
let greeters: Vec<Box<dyn Greet>> = vec![
Box::new(German),
Box::new(English),
];
for g in &greeters {
println!("{}", g.hello());
}Das wäre mit Monomorphisierung nicht möglich, da der Compiler den Typ jedes Eintrags zur Compile-Zeit kennen müsste.
Vorteil:
Flexibel, kleinere Binärgröße, heterogene Collections möglich.
Nachteil:
Laufzeit-Overhead durch den vtable-Lookup, keine Inlining-Optimierungen möglich.
Wann was? #
impl Trait / Generics |
dyn Trait |
|
|---|---|---|
| Auflösung | Compile-Zeit | Laufzeit |
| Performance | Höher | Etwas geringer |
| Binärgröße | Wächst pro Typ | Konstant |
| Heterogene Collections | ✗ | ✓ |
| Typinformation | Vollständig bekannt | Zur Compile-Zeit nicht bekannt |
Als Faustregel gilt:
impl Traitist der Standarddyn Traitkommt zum Einsatz wenn der Typ erst zur Laufzeit bekannt ist oder eine heterogene Collection benötigt wird.
Fazit #
Traits sind mehr als nur Interfaces. Die Entscheidung zwischen Monomorphisierung und Dynamic Dispatch ist eine Entscheidung zwischen Compile-Zeit-Garantien und Laufzeit-Flexibilität.
Im Vergleich zu Java oder C#, wo jeder Methodenaufruf über eine virtuelle Tabelle aufgelöst wird, gibt Rust einem die Wahl – und macht sie sichtbar.
Das ist typisch Rust. Explizit, kontrolliert und ohne versteckten Overhead, aber mit einer etwas steileren Lernkurve.
Vorschaubild #
Erstellt mit ChatGPT.