Verwendung von Trait-Objekten zur Abstraktion über gemeinsames Verhalten
In Kapitel 8 haben wir erwähnt, dass eine Einschränkung von Vektoren darin
besteht, dass sie nur Elemente eines einzigen Typs speichern können. Wir haben
in Listing 8-9 eine Abhilfe geschaffen, indem wir die Aufzählung (enum)
SpreadsheetCell definiert haben, die Varianten zur Aufnahme von Ganzzahlen,
Fließkommazahlen und Text enthielt. Das bedeutete, dass wir in jeder Zelle
verschiedene Datentypen speichern konnten und trotzdem einen Vektor hatten, der
eine Reihe von Zellen darstellte. Dies ist eine perfekte Lösung, wenn unsere
austauschbaren Elemente ein fester Satz von Typen sind, die wir kennen, wenn
unser Code kompiliert wird.
Manchmal möchten wir jedoch, dass unsere Bibliotheksbenutzer in der Lage sind,
die möglichen Typen, die in einer bestimmten Situation erlaubt sind, zu
erweitern. Um zu zeigen, wie wir dies erreichen können, werden wir ein Beispiel
für ein GUI-Werkzeug (Graphical User Interface) erstellen, das über eine Liste
von Elementen iteriert, wobei auf jedem Element eine Methode draw aufgerufen
wird, um es auf den Bildschirm zu zeichnen – eine übliche Technik bei
GUI-Werkzeugen. Wir werden eine Bibliotheks-Crate namens gui erstellen, die
die Struktur einer GUI-Bibliothek enthält. Diese Crate könnte einige Typen
enthalten, die Leute benutzen können, z.B. Button und TextField. Darüber
hinaus werden gui-Benutzer ihre eigenen Typen erstellen wollen, die gezeichnet
werden können: Zum Beispiel könnte ein Programmierer ein Image und ein anderer
eine SelectBox hinzufügen.
Zum Zeitpunkt des Schreibens der Bibliothek können wir nicht alle Typen kennen
und definieren, die andere Programmierer vielleicht erstellen möchten. Aber wir
wissen, dass gui den Überblick über viele Werte unterschiedlicher Typen
behalten muss, und es muss für jeden dieser unterschiedlich typisierten Werte
eine Methode draw aufrufen. Es muss nicht genau wissen, was passieren wird,
wenn wir die Methode draw aufrufen, sondern nur, dass der Typ diese Methode
für uns zum Aufruf bereithält.
Um dies in einer Sprache mit Vererbung zu tun, könnten wir eine Klasse namens
Component definieren, die eine Methode namens draw enthält. Die anderen
Klassen, z.B. Button, Image und SelectBox, würden von Component erben
und somit die Methode draw erben. Sie könnten jeweils die Methode draw
überschreiben, um ihr eigenes Verhalten zu definieren, aber das
Programmiergerüst (framework) könnte alle Typen so behandeln, als wären sie
Component-Instanzen, und draw aufrufen. Aber da Rust keine Vererbung hat,
brauchen wir einen anderen Weg, die gui-Bibliothek zu strukturieren, damit
die Benutzer neue Typen erstellen können, die mit der Bibliothek kompatibel
sind.
Definieren eines Traits für allgemeines Verhalten
Um das Verhalten zu implementieren, das wir in gui haben wollen, werden wir
ein Trait namens Draw definieren, das eine Methode namens draw haben wird.
Dann können wir einen Vektor definieren, der ein Trait-Objekt annimmt. Ein
Trait-Objekt verweist sowohl auf eine Instanz eines Typs, der das von uns
spezifizierte Trait implementiert, als auch eine Tabelle, in der Trait-Methoden
dieses Typs zur Laufzeit nachgeschlagen werden können. Wir erstellen ein
Trait-Objekt, indem wir eine Art Zeiger angeben, z.B. eine Referenz & oder
einen intelligenten Zeiger Box<T>, dann das Schlüsselwort dyn und dann das
relevante Trait. (Wir werden über den Grund, warum Trait-Objekte einen Zeiger
verwenden müssen, in „Dynamisch große Typen und das Trait
Sized“ in Kapitel 20 sprechen.) Wir können Trait-Objekte
an Stelle eines generischen oder konkreten Typs verwenden. Wo immer wir ein
Trait-Objekt verwenden, stellt Rusts Typsystem zur Kompilierzeit sicher, dass
jeder in diesem Kontext verwendete Wert das vom Trait-Objekts verlangte Trait
implementiert. Folglich müssen wir zur Kompilierzeit nicht alle möglichen Typen
kennen.
Wir haben erwähnt, dass wir in Rust davon absehen, Strukturen (structs) und
Aufzählungen „Objekte“ zu nennen, um sie von den Objekten anderer Sprachen zu
unterscheiden. In einer Struktur oder Aufzählung sind die Daten in den
Struktur-Feldern vom Verhalten in impl-Blöcken getrennt, während in anderen
Sprachen die Daten und das Verhalten, die in einem Konzept zusammengefasst sind,
oft als ein Objekt bezeichnet werden. Trait-Objekte unterscheiden sich von
Objekten in anderen Sprachen dadurch, dass wir einem Trait-Objekt keine Daten
hinzufügen können. Trait-Objekte sind nicht so allgemein einsetzbar wie Objekte
in anderen Sprachen: Ihr spezifischer Zweck besteht darin, Abstraktion über
allgemeines Verhalten zu ermöglichen.
In Listing 18-3 wird gezeigt, wie ein Trait Draw mit einer Methode draw
definiert werden kann.
Dateiname: src/lib.rs
#![allow(unused)]
fn main() {
pub trait Draw {
fn draw(&self);
}
}
Listing 18-3: Definition des Traits Draw
Diese Syntax sollte uns aus unseren Diskussionen über die Definition von Traits
in Kapitel 10 bekannt vorkommen. Als nächstes kommt eine neue Syntax: Listing
18-4 definiert eine Struktur namens Screen, die einen Vektor namens
components enthält. Dieser Vektor ist vom Typ Box<dyn Draw>, der ein
Trait-Objekt ist; er ist ein Stellvertreter für jeden Typ innerhalb einer Box,
der das Trait Draw implementiert.
Dateiname: src/lib.rs
#![allow(unused)]
fn main() {
pub trait Draw {
fn draw(&self);
}
pub struct Screen {
pub components: Vec<Box<dyn Draw>>,
}
}
Listing 18-4: Definition der Struktur Screen mit einem
Feld components, das einen Vektor von Trait-Objekten enthält, die das Trait
Draw implementieren
Auf der Struktur Screen definieren wir eine Methode namens run, die die
Methode draw auf jeder ihrer components aufruft, wie in Listing 18-5
gezeigt.
Dateiname: src/lib.rs
#![allow(unused)]
fn main() {
pub trait Draw {
fn draw(&self);
}
pub struct Screen {
pub components: Vec<Box<dyn Draw>>,
}
impl Screen {
pub fn run(&self) {
for component in self.components.iter() {
component.draw();
}
}
}
}
Listing 18-5: Eine Methode run auf Screen, die die
Methode draw jeder Komponente aufruft
Dies funktioniert anders als die Definition einer Struktur, die einen
generischen Typparameter mit Trait Bounds verwendet. Ein generischer
Typparameter kann jeweils nur durch einen konkreten Typ ersetzt werden, während
Trait-Objekte die Möglichkeit bieten, zur Laufzeit mehrere konkrete Typen für
das Trait-Objekt einzusetzen. Beispielsweise hätten wir die Struktur Screen
mit einem generischen Typ und einer Trait Bound wie in Listing 18-6 definieren
können.
Dateiname: src/lib.rs
#![allow(unused)]
fn main() {
pub trait Draw {
fn draw(&self);
}
pub struct Screen<T: Draw> {
pub components: Vec<T>,
}
impl<T> Screen<T>
where
T: Draw,
{
pub fn run(&self) {
for component in self.components.iter() {
component.draw();
}
}
}
}
Listing 18-6: Eine alternative Implementierung der
Struktur Screen und ihrer Methode run unter Verwendung generischer Typen und
Trait Bounds
Dies schränkt uns auf eine Screen-Instanz ein, die eine Liste von Komponenten
hat, die alle vom Typ Button oder alle vom Typ TextField sind. Wenn du immer
nur homogene Kollektionen haben wirst, ist das Verwenden von generischen Typen
und Trait Bounds vorzuziehen, da die Definitionen zur Kompilierszeit
monomorphisiert werden, um die konkreten Typen zu verwenden.
Andererseits kann bei der Methode mit Trait-Objekten eine Screen-Instanz einen
Vec<T> enthalten, der sowohl eine Box<Button> als auch eine Box<TextField>
enthält. Schauen wir uns an, wie dies funktioniert, und dann werden wir über die
Auswirkungen auf die Laufzeitperformanz sprechen.
Implementieren des Traits
Nun fügen wir einige Typen hinzu, die das Trait Draw implementieren. Wir
werden den Typ Button zur Verfügung stellen. Auch hier liegt die eigentliche
Implementierung einer GUI-Bibliothek jenseits des Rahmens dieses Buches, sodass
die Methode draw keine nützliche Implementierung in ihrem Rumpf haben wird. Um
sich vorzustellen, wie die Implementierung aussehen könnte, könnte eine Struktur
Button Felder für width, height und label haben, wie in Listing 18-7
gezeigt.
Dateiname: src/lib.rs
#![allow(unused)]
fn main() {
pub trait Draw {
fn draw(&self);
}
pub struct Screen {
pub components: Vec<Box<dyn Draw>>,
}
impl Screen {
pub fn run(&self) {
for component in self.components.iter() {
component.draw();
}
}
}
pub struct Button {
pub width: u32,
pub height: u32,
pub label: String,
}
impl Draw for Button {
fn draw(&self) {
// Code zum tatsächlichen Zeichnen einer Schaltfläche
}
}
}
Listing 18-7: Eine Struktur Button, die das Trait
Draw implementiert
Die Felder width, height und label in Button unterscheiden sich von den
Feldern anderer Komponenten; beispielsweise könnte ein Typ TextField diese
Felder und zusätzlich ein placeholder haben. Jeder der Typen, die wir auf dem
Bildschirm zeichnen wollen, wird das Trait Draw implementieren, aber
unterschiedlichen Code in der Methode draw verwenden, um zu definieren, wie
dieser bestimmte Typ gezeichnet werden soll, wie es hier bei Button der Fall
ist (ohne wie erwähnt den eigentlichen GUI-Code). Der Typ Button könnte zum
Beispiel einen zusätzlichen impl-Block haben, der Methoden enthält, die sich
darauf beziehen, was passiert, wenn ein Benutzer auf die Schaltfläche klickt.
Diese Art von Methoden trifft nicht auf Typen wie TextField zu.
Wenn sich jemand, der unsere Bibliothek benutzt, dazu entschließt, eine Struktur
SelectBox zu implementieren, die die Felder width, height und options
enthält, würde er ebenfalls das Trait Draw für den Typ SelectBox
implementieren, wie in Listing 18-8 gezeigt.
Dateiname: src/main.rs
use gui::Draw;
struct SelectBox {
width: u32,
height: u32,
options: Vec<String>,
}
impl Draw for SelectBox {
fn draw(&self) {
// Code zum tatsächlichen Zeichnen eines Auswahlfeldes
}
}
fn main() {}
Listing 18-8: Eine andere Crate, die gui verwendet und
das Trait Draw auf einer Struktur SelectBox implementiert
Der Benutzer unserer Bibliothek kann nun seine Funktion main schreiben, um
eine Screen-Instanz zu erzeugen. Der Screen-Instanz kann er eine SelectBox
und einen Button hinzufügen, indem er sie in eine Box<T> legt, um ein
Trait-Objekt zu werden. Er kann dann die Methode run auf der Screen-Instanz
aufrufen, die dann draw auf jeder der Komponenten aufruft. Listing 18-9 zeigt
diese Umsetzung.
Dateiname: src/main.rs
use gui::Draw;
struct SelectBox {
width: u32,
height: u32,
options: Vec<String>,
}
impl Draw for SelectBox {
fn draw(&self) {
// Code zum tatsächlichen Zeichnen eines Auswahlfeldes
}
}
use gui::{Button, Screen};
fn main() {
let screen = Screen {
components: vec![
Box::new(SelectBox {
width: 75,
height: 10,
options: vec![
String::from("Ja"),
String::from("Vielleicht"),
String::from("Nein"),
],
}),
Box::new(Button {
width: 50,
height: 10,
label: String::from("OK"),
}),
],
};
screen.run();
}
Listing 18-9: Verwenden von Trait-Objekten zum Speichern von Werten verschiedener Typen, die das gleiche Trait implementieren
Als wir die Bibliothek schrieben, wussten wir nicht, dass jemand den Typ
SelectBox hinzufügen könnte, aber unsere Screen-Implementierung war in der
Lage, mit dem neuen Typ umzugehen und ihn zu zeichnen, weil SelectBox das
Trait Draw implementiert, was bedeutet, dass sie die Methode draw
implementiert.
Dieses Konzept – sich nur mit den Nachrichten zu befassen, auf die ein
Wert reagiert, und nicht mit dem konkreten Typ des Wertes – ähnelt dem
Konzept des Duck-Typing in dynamisch typisierten Sprachen: Wenn es wie eine
Ente läuft und wie eine Ente quakt, dann muss es eine Ente sein! Bei der
Implementierung von run auf Screen in Listing 18-5 braucht run nicht zu
wissen, was der konkrete Typ jeder Komponente ist. Es weiß nicht, ob eine
Komponente eine Instanz eines Buttons oder einer SelectBox ist, es ruft nur
die Methode draw auf der Komponente auf. Durch die Spezifikation von Box<dyn Draw> als Typ der Werte im Vektor components haben wir Screen so definiert,
dass wir Werte benötigen, auf denen wir die Methode draw aufrufen können.
Der Vorteil der Verwendung von Trait-Objekten und des Rust-Typsystems zum Schreiben von Code, der dem Code mit Duck-Typing ähnelt, besteht darin, dass wir nie prüfen müssen, ob ein Wert eine bestimmte Methode zur Laufzeit implementiert, oder uns Sorgen machen müssen, Fehler zu bekommen, wenn ein Wert eine Methode nicht implementiert, wir sie aber trotzdem aufrufen. Rust wird unseren Code nicht kompilieren, wenn die Werte nicht die Traits implementieren, die die Trait-Objekte benötigen.
Beispielsweise zeigt Listing 18-10, was passiert, wenn wir versuchen, einen
Screen mit einem String als Komponente zu erstellen.
Dateiname: src/main.rs
use gui::Screen;
fn main() {
let screen = Screen {
components: vec![Box::new(String::from("Hallo"))],
};
screen.run();
}
Listing 18-10: Versuch, einen Typ zu verwenden, der das Trait des Trait-Objekts nicht implementiert
Wir werden diesen Fehler erhalten, weil String das Trait Draw nicht
implementiert:
$ cargo run
Compiling gui v0.1.0 (file:///projects/gui)
error[E0277]: the trait bound `String: Draw` is not satisfied
--> src/main.rs:5:26
|
5 | components: vec![Box::new(String::from("Hallo"))],
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the trait `Draw` is not implemented for `String`
|
= help: the trait `Draw` is implemented for `Button`
= note: required for the cast from `Box<String>` to `Box<dyn Draw>`
For more information about this error, try `rustc --explain E0277`.
error: could not compile `gui` (bin "gui") due to 1 previous error
Dieser Fehler lässt uns wissen, dass wir entweder etwas an Screen übergeben,
das wir nicht übergeben wollten und einen anderen Typ übergeben sollten, oder
wir sollten Draw auf String implementieren, sodass Screen in der Lage
ist, Draw darauf aufzurufen.
Dynamischen Aufruf durchführen
Erinnere dich an „Code-Performanz beim Verwenden generischer Datentypen“ in Kapitel 10 an unsere Diskussion über den Monomorphisierungsprozess bei generischen Typen, den der Compiler durchführt: Der Compiler generiert nicht-generische Implementierungen von Funktionen und Methoden für jeden konkreten Typ, den wir anstelle eines generischen Typparameters verwenden. Der Code, der sich aus der Monomorphisierung ergibt, macht statische Aufrufe (static dispatch), d.h. der Compiler weiß, welche Methode du zur Kompilierzeit aufrufst. Dies steht im Gegensatz zum dynamischen Aufruf (dynamic dispatch), bei dem der Compiler zur Kompilierzeit nicht weiß, welche Methode du aufrufst. In Fällen von dynamischem Aufruf erzeugt der Compiler Code, der zur Laufzeit herausfindet, welche Methode aufzurufen ist.
Wenn wir Trait-Objekte verwenden, muss Rust dynamische Aufrufe verwenden. Der Compiler kennt nicht alle Typen, die mit dem Code verwendet werden könnten, der Trait-Objekte verwendet, sodass er nicht weiß, welche Methode auf welchem Typ implementiert ist, um sie aufzurufen. Stattdessen verwendet Rust zur Laufzeit die Zeiger innerhalb des Trait-Objekts, um zu wissen, welche Methode aufgerufen werden soll. Dieses Nachschlagen verursacht Laufzeitkosten, die beim statischen Aufruf nicht anfallen. Der dynamische Aufruf verhindert auch, dass der Compiler sich dafür entscheiden kann, den Code einer Methode inline zu verwenden, was wiederum einige Optimierungen verhindert. Und Rust hat einige Regeln, wo man dynamische Aufrufe verwenden kann und wo nicht. Diese Regeln gehen über den Rahmen dieser Diskussion hinaus, aber du kannst mehr über sie in der Dyn-Kompatibilitäts-Referenz lesen. Wir haben jedoch zusätzliche Flexibilität im Code erhalten, den wir in Listing 18-5 geschrieben haben und in Listing 18-9 unterstützen konnten, sodass es sich um einen Kompromiss handelt, den es zu berücksichtigen gilt.