Trait-Objekte in Rust
Auf der Seite Traits hast du Funktionen geschrieben, die „jeden Typ, der Speak implementiert“ annehmen. Auf der Seite Generics hast du gesehen, dass Rust für jeden Typ eine eigene Kopie einer generischen Funktion erzeugt. Beides funktioniert super, solange der Typ schon beim Kompilieren feststeht. Was aber, wenn du eine Liste mit vielen verschiedenen Typen haben willst?
Genau dafür gibt es Trait-Objekte. Stell dir eine Musiklehrerin vor, die einen Raum voller Schüler hat. Einer spielt Klavier, eine Geige, einer Schlagzeug. Die Lehrerin muss nicht wissen, wer welches Instrument hat – sie sagt einfach „Spielt!“, und alle spielen auf ihre Weise. Mit einem Trait-Objekt kann dein Code dasselbe sagen: „Ich kenne den genauen Typ nicht, aber ich weiß, dass er das hier kann.“
Das Problem: ein Vec, verschiedene Typen
Abschnitt betitelt „Das Problem: ein Vec, verschiedene Typen“Angenommen, du hast zwei Formen und willst sie in einem Vec speichern:
struct Circle { radius: f64 }struct Square { side: f64 }
fn main() { let shapes = vec![Circle { radius: 1.0 }, Square { side: 2.0 }];}Das kompiliert nicht:
error[E0308]: mismatched types --> src/main.rs:5:47 |5 | let shapes = vec![Circle { radius: 1.0 }, Square { side: 2.0 }]; | ^^^^^^^^^^^^^^^^^^^^ expected `Circle`, found `Square`Der Compiler sagt: Er hat einen Circle erwartet, aber einen Square gefunden. Ein Vec kann nämlich nur einen Typ enthalten. Jedes Element muss im Speicher gleich groß sein, damit Rust sie direkt hintereinander ablegen kann. Circle und Square sind verschiedene Typen, also passen sie nicht in denselben einfachen Vec.
Box<dyn Trait>: eine Liste verschiedener Formen
Abschnitt betitelt „Box<dyn Trait>: eine Liste verschiedener Formen“Die Lösung hat zwei Teile. Erstens bekommen beide Formen einen gemeinsamen Trait. Zweitens packst du jede Form in eine Box und gibst als Typ Box<dyn Shape> an:
trait Shape { fn name(&self) -> String; fn area(&self) -> f64;}
struct Circle { radius: f64 }struct Square { side: f64 }
impl Shape for Circle { fn name(&self) -> String { String::from("Kreis") } fn area(&self) -> f64 { 3.14159 * self.radius * self.radius }}
impl Shape for Square { fn name(&self) -> String { String::from("Quadrat") } fn area(&self) -> f64 { self.side * self.side }}
fn main() { let shapes: Vec<Box<dyn Shape>> = vec![ Box::new(Circle { radius: 2.0 }), Box::new(Square { side: 3.0 }), Box::new(Circle { radius: 1.0 }), ];
for shape in &shapes { println!("{} hat die Fläche {:.2}", shape.name(), shape.area()); }}Ausgabe:
Kreis hat die Fläche 12.57Quadrat hat die Fläche 9.00Kreis hat die Fläche 3.14Schauen wir uns Box<dyn Shape> Stück für Stück an:
dyn Shapebedeutet „irgendein Typ, derShapeimplementiert – welcher genau, stellt sich erst zur Laufzeit heraus“.dynsteht für dynamisch.Boxlegt den Wert auf dem Heap ab und speichert einen Zeiger darauf (Boxkennst du schon von der Seite Smart Pointer). JedeBoxist gleich groß, egal was drinsteckt. Deshalb ist derVecjetzt zufrieden.
Der Wert in der Box (plus ein versteckter Zeiger, der sagt „das hier ist ein Circle“) ist das, was man ein Trait-Objekt nennt.
&dyn Trait: ausleihen statt einpacken
Abschnitt betitelt „&dyn Trait: ausleihen statt einpacken“Du brauchst nicht immer eine Box. Wenn die Werte schon woanders liegen, reicht auch eine Referenz. &dyn Shape bedeutet „eine geliehene Referenz auf irgendeinen Typ, der Shape implementiert“:
trait Shape { fn area(&self) -> f64;}
struct Circle { radius: f64 }struct Square { side: f64 }
impl Shape for Circle { fn area(&self) -> f64 { 3.14159 * self.radius * self.radius }}
impl Shape for Square { fn area(&self) -> f64 { self.side * self.side }}
fn print_area(shape: &dyn Shape) { println!("Fläche: {:.1}", shape.area());}
fn main() { let circle = Circle { radius: 1.0 }; let square = Square { side: 4.0 };
print_area(&circle); print_area(&square);
let both: Vec<&dyn Shape> = vec![&circle, &square]; println!("Wir haben {} Formen", both.len());}Ausgabe:
Fläche: 3.1Fläche: 16.0Wir haben 2 FormenEine einfache Faustregel: Nimm Box<dyn Trait>, wenn die Liste ihre Werte besitzen soll, und &dyn Trait, wenn du sie nur eine Weile ausleihen musst.
Statischer vs. dynamischer Dispatch
Abschnitt betitelt „Statischer vs. dynamischer Dispatch“„Dispatch“ heißt einfach „entscheiden, welche Methode aufgerufen wird“. Rust kann das auf zwei Arten tun.
Statischer Dispatch passiert bei Generics und impl Trait. Der Compiler kennt den genauen Typ und erzeugt deshalb für jeden Typ eine eigene Kopie der Funktion (wie auf der Seite Generics). Der Aufruf geht direkt zur richtigen Methode. Das ist sehr schnell.
Dynamischer Dispatch passiert bei dyn Trait. Hier kennt der Compiler den genauen Typ nicht. Deshalb trägt jedes Trait-Objekt eine kleine versteckte Tabelle mit sich, die vtable. Darin steht: „Für diesen Wert liegt area hier.“ Zur Laufzeit schlägt Rust die Methode in dieser Tabelle nach und ruft sie dann auf. Dieses Nachschlagen kostet ein winziges bisschen extra.
// Statischer Dispatch: Rust erzeugt pro Typ eine Kopie dieser Funktionfn static_area(shape: &impl Shape) -> f64 { shape.area()}
// Dynamischer Dispatch: eine Funktion, die Methode wird zur Laufzeit nachgeschlagenfn dynamic_area(shape: &dyn Shape) -> f64 { shape.area()}Beide Funktionen liefern für dieselbe Form dasselbe Ergebnis. Der einzige Unterschied ist, wie Rust die passende area-Methode findet. Welche solltest du also nehmen?
Nimm Generics / impl Trait, wenn … |
Nimm dyn Trait, wenn … |
|---|---|
| jeder Aufruf mit einem bekannten Typ arbeitet | du verschiedene Typen in einer Sammlung mischen willst |
| du möglichst schnellen Code willst | der Typ erst zur Laufzeit feststeht (zum Beispiel durch eine Benutzereingabe) |
| dich ein etwas größeres Programm nicht stört | du kleineren Code und schnellere Kompilierzeiten willst |
Ein guter Standard für Anfänger: Fang mit Generics oder impl Trait an. Greif zu dyn, wenn du auf das Problem „verschiedene Typen in einer Liste“ stößt. Mach dir über den Geschwindigkeitsunterschied nicht zu viele Gedanken – in den meisten Programmen merkst du ihn nie.
Box<dyn Trait> aus einer Funktion zurückgeben
Abschnitt betitelt „Box<dyn Trait> aus einer Funktion zurückgeben“Manchmal muss eine Funktion zur Laufzeit entscheiden, welchen Typ sie zurückgibt. Du könntest -> impl Shape probieren, aber das klappt nur, wenn die Funktion immer denselben Typ zurückgibt. Wenn verschiedene Zweige verschiedene Typen liefern, gib stattdessen eine Box<dyn Shape> zurück:
trait Shape { fn describe(&self) -> String;}
struct Circle { radius: f64 }struct Square { side: f64 }
impl Shape for Circle { fn describe(&self) -> String { format!("einen Kreis mit Radius {}", self.radius) }}
impl Shape for Square { fn describe(&self) -> String { format!("ein Quadrat mit Seitenlänge {}", self.side) }}
fn make_shape(kind: &str, size: f64) -> Box<dyn Shape> { if kind == "circle" { Box::new(Circle { radius: size }) } else { Box::new(Square { side: size }) }}
fn main() { let a = make_shape("circle", 1.5); let b = make_shape("square", 2.0); println!("Ich habe {} gebaut", a.describe()); println!("Ich habe {} gebaut", b.describe());}Ausgabe:
Ich habe einen Kreis mit Radius 1.5 gebautIch habe ein Quadrat mit Seitenlänge 2 gebautDas ist ein typisches Muster für „Factory“-Funktionen, Plugin-Systeme und konfigurationsgesteuerten Code – also überall, wo der genaue Typ von Daten abhängt, die du erst zur Laufzeit siehst.
Ein Praxisbeispiel: Benachrichtigungen verschicken
Abschnitt betitelt „Ein Praxisbeispiel: Benachrichtigungen verschicken“Jetzt etwas, das näher an einer echten App ist. Dein Programm soll einen Benutzer benachrichtigen, aber jeder Benutzer hat andere Kanäle gewählt: E-Mail, SMS oder beides. Jeder Kanal ist ein eigener Typ, und alle teilen sich den Trait Notifier:
trait Notifier { fn send(&self, message: &str) -> String;}
struct Email { address: String }struct Sms { phone: String }
impl Notifier for Email { fn send(&self, message: &str) -> String { format!("E-Mail an {}: {}", self.address, message) }}
impl Notifier for Sms { fn send(&self, message: &str) -> String { format!("SMS an {}: {}", self.phone, message) }}
fn notify_all(channels: &[Box<dyn Notifier>], message: &str) { for channel in channels { println!("{}", channel.send(message)); }}
fn main() { let channels: Vec<Box<dyn Notifier>> = vec![ Box::new(Sms { phone: String::from("+1-555-0100") }), ];
notify_all(&channels, "Deine Bestellung ist unterwegs!");}Ausgabe:
E-Mail an [email protected]: Deine Bestellung ist unterwegs!SMS an +1-555-0100: Deine Bestellung ist unterwegs!Das Schöne daran: Wenn du später Push-Benachrichtigungen hinzufügst, schreibst du ein neues Struct Push und ein impl Notifier for Push. Die Funktion notify_all bleibt komplett unverändert.
Die Grenzen: dyn-Kompatibilität
Abschnitt betitelt „Die Grenzen: dyn-Kompatibilität“Nicht jeder Trait kann zu einem Trait-Objekt werden. Ein Trait muss dyn-kompatibel (dyn compatible) sein. In älteren Büchern und Blogartikeln heißt das noch Object Safety. Die vollständigen Regeln sind lang, aber zwei davon decken die meisten Fälle aus der Praxis ab:
- Eine Methode darf nicht
Selfzurückgeben. (Hinter einemdynweiß Rust nicht, wasSelfist, und damit auch nicht, wie groß der Rückgabewert ist.) - Eine Methode darf keine eigenen generischen Typparameter haben, wie in
fn process<T>(&self, item: T).
Das passiert, wenn du gegen die erste Regel verstößt:
trait Shape { fn area(&self) -> f64; fn duplicate(&self) -> Self;}// ... später:let shapes: Vec<Box<dyn Shape>> = vec![Box::new(Circle { radius: 1.0 })];error[E0038]: the trait `Shape` is not dyn compatible |14 | let shapes: Vec<Box<dyn Shape>> = vec![Box::new(Circle { radius: 1.0 })]; | ^^^^^ `Shape` is not dyn compatible | 1 | trait Shape { | ----- this trait is not dyn compatible... 2 | fn area(&self) -> f64; 3 | fn duplicate(&self) -> Self; | ^^^^ ...because method `duplicate` references the `Self` type in its return type = help: consider moving `duplicate` to another traitDie Fehlermeldung ist ehrlich und hilfreich: Sie sagt dir, dass Shape nicht dyn-kompatibel ist, weil die Methode duplicate in ihrem Rückgabetyp Self verwendet. Du hast zwei Möglichkeiten. Entweder verschiebst du diese Methode in einen anderen Trait, oder du ergänzt bei der Methode where Self: Sized. Damit sagst du Rust: „Diese Methode gibt es nur bei echten, bekannten Typen, nicht bei Trait-Objekten“:
trait Shape { fn area(&self) -> f64; fn duplicate(&self) -> Self where Self: Sized;}Aus demselben Grund lassen sich manche Standard-Traits wie Clone nicht als dyn Clone verwenden – clone() gibt Self zurück.
Häufige Fehler
Abschnitt betitelt „Häufige Fehler“Die Box vergessen. Es ist verlockend, Vec<dyn Shape> zu schreiben:
let shapes: Vec<dyn Shape> = Vec::new();error[E0277]: the size for values of type `dyn Shape` cannot be known at compilation time --> src/main.rs:3:17 |3 | let shapes: Vec<dyn Shape> = Vec::new(); | ^^^^^^^^^^^^^^ doesn't have a size known at compile-time | = help: the trait `Sized` is not implemented for `dyn Shape`Der Compiler meldet, dass die Größe von dyn Shape beim Kompilieren nicht bekannt ist. Ein dyn Shape kann ja beliebig groß sein – ein kleines Square oder ein großes Struct mit vielen Feldern. Rust braucht aber eine feste Größe. Deshalb muss ein Trait-Objekt immer hinter einem Zeiger stehen: Box<dyn Shape>, &dyn Shape oder Rc<dyn Shape>.
Überall dyn verwenden. Wenn eine Funktion immer nur einen Typ auf einmal bekommt, sind impl Trait oder Generics einfacher und schneller (mehr zu impl Trait findest du auf der Seite Fortgeschrittene Traits). Heb dir dyn für die Fälle auf, in denen du wirklich Typen mischen musst.
Übungen
Abschnitt betitelt „Übungen“1. Ein Zoo in einem Vec
Abschnitt betitelt „1. Ein Zoo in einem Vec“Definiere einen Trait Animal mit fn name(&self) -> String und fn sound(&self) -> String. Implementiere ihn für Dog, Cow und Duck, steck je ein Tier in einen Vec<Box<dyn Animal>> und gib in einer Schleife aus, was jedes Tier sagt. Tipp: Die Structs dürfen leer sein (struct Dog;), und jedes kommt mit Box::new(...) in den Vektor.
Lösung anzeigen
trait Animal { fn name(&self) -> String; fn sound(&self) -> String;}
struct Dog;struct Cow;struct Duck;
impl Animal for Dog { fn name(&self) -> String { String::from("Hund") } fn sound(&self) -> String { String::from("wuff") }}
impl Animal for Cow { fn name(&self) -> String { String::from("Kuh") } fn sound(&self) -> String { String::from("muh") }}
impl Animal for Duck { fn name(&self) -> String { String::from("Ente") } fn sound(&self) -> String { String::from("quak") }}
fn main() { let zoo: Vec<Box<dyn Animal>> = vec![Box::new(Dog), Box::new(Cow), Box::new(Duck)];
for animal in &zoo { println!("{} sagt {}", animal.name(), animal.sound()); }}Ausgabe:
Hund sagt wuffKuh sagt muhEnte sagt quak2. Den Begrüßer zur Laufzeit wählen
Abschnitt betitelt „2. Den Begrüßer zur Laufzeit wählen“Definiere einen Trait Greeter mit fn hello(&self, name: &str) -> String und zwei Typen: English und Spanish. Schreib fn make_greeter(lang: &str) -> Box<dyn Greeter>, die für "es" einen Spanish zurückgibt und für alles andere einen English. Begrüße "Mia" für jeden Code in ["en", "es", "fr"]. Tipp: Beide Zweige des if müssen denselben Typ zurückgeben. Pack deshalb jeden in Box::new(...).
Lösung anzeigen
trait Greeter { fn hello(&self, name: &str) -> String;}
struct English;struct Spanish;
impl Greeter for English { fn hello(&self, name: &str) -> String { format!("Hello, {name}!") }}
impl Greeter for Spanish { fn hello(&self, name: &str) -> String { format!("¡Hola, {name}!") }}
fn make_greeter(lang: &str) -> Box<dyn Greeter> { if lang == "es" { Box::new(Spanish) } else { Box::new(English) }}
fn main() { for lang in ["en", "es", "fr"] { let greeter = make_greeter(lang); println!("{lang}: {}", greeter.hello("Mia")); }}Ausgabe:
en: Hello, Mia!es: ¡Hola, Mia!fr: Hello, Mia!Die Grüße bleiben hier absichtlich auf Englisch und Spanisch – genau darum geht es ja: Je nach Sprachcode wählt das Programm zur Laufzeit einen anderen Typ.
Zusammenfassung
Abschnitt betitelt „Zusammenfassung“- Ein
Veckann nur einen Typ enthalten. Um Typen zu mischen, nimmst du ein Trait-Objekt wieBox<dyn Trait>. Box<dyn Trait>besitzt den Wert,&dyn Traitleiht ihn aus. Ein Trait-Objekt steht immer hinter einem Zeiger.- Generics und
impl Traitnutzen statischen Dispatch (schnell, beim Kompilieren entschieden).dyn Traitnutzt dynamischen Dispatch (Nachschlagen in der vtable zur Laufzeit). - Gib
Box<dyn Trait>zurück, wenn eine Funktion zur Laufzeit zwischen verschiedenen Typen wählt. - Nur dyn-kompatible Traits können Trait-Objekte sein – keine Methoden, die
Selfzurückgeben, und keine generischen Methoden (außer sie habenwhere Self: Sized).
Kurzer Check
Abschnitt betitelt „Kurzer Check“Kurzer Check
1. Warum kannst du einen Circle und einen Square nicht in denselben einfachen Vec packen?
2. Was sagt dyn in Box<dyn Shape> zu Rust?
3. Was davon nutzt statischen Dispatch?
4. Warum ist Vec<dyn Shape> (ohne Box) ein Fehler?
5. Welche Trait-Methode macht einen Trait nicht dyn-kompatibel?
Punkte: 0 / 5
Weiterlesen
Abschnitt betitelt „Weiterlesen“- Verwendung von Trait-Objekten zur Abstraktion über gemeinsames Verhalten – Das Rust-Buch: das offizielle Kapitel, das mit
Box<dyn Draw>eine Liste von GUI-Komponenten aufbaut. - Returning Traits with dyn – Rust by Example (auf Englisch): ein kurzes, ausführbares Beispiel für eine Funktion, die
Box<dyn Trait>zurückgibt. - Trait objects – The Rust Reference (auf Englisch): die genauen Regeln für
dyn Trait-Typen, einschließlich Lifetimes und zusätzlicher Marker-Traits wieSend. - Dyn compatibility – The Rust Reference (auf Englisch): die vollständige Liste der Regeln, die ein Trait erfüllen muss, damit du ihn mit
dynverwenden kannst. - Das Schlüsselwort
dyn– std-Dokumentation (auf Englisch): eine kurze Erklärung vondyn, Vtables und den Vor- und Nachteilen im Vergleich zu Generics.