Trait-Objekte für heterogene Werte

RustRustBeginner
Jetzt üben

This tutorial is from open-source community. Access the source code

💡 Dieser Artikel wurde von AI-Assistenten übersetzt. Um die englische Version anzuzeigen, können Sie hier klicken

Einführung

Willkommen zu Using Trait Objects That Allow for Values of Different Types. Dieser Lab ist Teil des Rust Buchs. Du kannst deine Rust-Fähigkeiten in LabEx üben.

In diesem Lab werden wir untersuchen, wie man Trait-Objekte verwendet, um in einer Bibliothek, insbesondere im Kontext eines grafischen Benutzeroberflächen (GUI)-Tools, Werte unterschiedlicher Typen zuzulassen.


Skills Graph

%%%%{init: {'theme':'neutral'}}%%%% flowchart RL rust(("Rust")) -.-> rust/BasicConceptsGroup(["Basic Concepts"]) rust(("Rust")) -.-> rust/FunctionsandClosuresGroup(["Functions and Closures"]) rust(("Rust")) -.-> rust/DataTypesGroup(["Data Types"]) rust(("Rust")) -.-> rust/ControlStructuresGroup(["Control Structures"]) rust(("Rust")) -.-> rust/DataStructuresandEnumsGroup(["Data Structures and Enums"]) rust(("Rust")) -.-> rust/AdvancedTopicsGroup(["Advanced Topics"]) rust/BasicConceptsGroup -.-> rust/variable_declarations("Variable Declarations") rust/DataTypesGroup -.-> rust/integer_types("Integer Types") rust/DataTypesGroup -.-> rust/string_type("String Type") rust/ControlStructuresGroup -.-> rust/for_loop("for Loop") rust/FunctionsandClosuresGroup -.-> rust/function_syntax("Function Syntax") rust/FunctionsandClosuresGroup -.-> rust/expressions_statements("Expressions and Statements") rust/DataStructuresandEnumsGroup -.-> rust/method_syntax("Method Syntax") rust/AdvancedTopicsGroup -.-> rust/traits("Traits") rust/AdvancedTopicsGroup -.-> rust/operator_overloading("Traits for Operator Overloading") subgraph Lab Skills rust/variable_declarations -.-> lab-100442{{"Trait-Objekte für heterogene Werte"}} rust/integer_types -.-> lab-100442{{"Trait-Objekte für heterogene Werte"}} rust/string_type -.-> lab-100442{{"Trait-Objekte für heterogene Werte"}} rust/for_loop -.-> lab-100442{{"Trait-Objekte für heterogene Werte"}} rust/function_syntax -.-> lab-100442{{"Trait-Objekte für heterogene Werte"}} rust/expressions_statements -.-> lab-100442{{"Trait-Objekte für heterogene Werte"}} rust/method_syntax -.-> lab-100442{{"Trait-Objekte für heterogene Werte"}} rust/traits -.-> lab-100442{{"Trait-Objekte für heterogene Werte"}} rust/operator_overloading -.-> lab-100442{{"Trait-Objekte für heterogene Werte"}} end

Verwenden von Trait-Objekten, die Werte unterschiedlicher Typen zulassen

Im 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 Lösung gefunden, indem wir eine SpreadsheetCell-Enumeration definiert haben, die Varianten für die Speicherung von ganzen Zahlen, Gleitkommazahlen und Text hatte. Dies bedeutete, dass wir verschiedene Datentypen in jeder Zelle speichern konnten und trotzdem einen Vektor hatten, der eine Zeile von Zellen repräsentierte. Dies ist eine ausgezeichnete Lösung, wenn unsere austauschbaren Elemente eine feste Menge von Typen sind, die wir kennen, wenn unser Code kompiliert wird.

Manchmal möchten wir jedoch, dass der Benutzer unserer Bibliothek die Menge der in einer bestimmten Situation gültigen Typen erweitern kann. Um zu zeigen, wie wir dies erreichen könnten, werden wir ein Beispiel für ein grafisches Benutzeroberflächen (GUI)-Tool erstellen, das durch eine Liste von Elementen iteriert und für jedes Element eine draw-Methode aufruft, um es auf dem Bildschirm zu zeichnen - eine häufige Technik für GUI-Tools. Wir werden eine Bibliothekskiste namens gui erstellen, die die Struktur einer GUI-Bibliothek enthält. Diese Kiste könnte einige Typen für die Benutzer enthalten, wie Button oder TextField. Darüber hinaus werden die Benutzer von gui gerne eigene Typen erstellen, die gezeichnet werden können: Beispielsweise könnte ein Programmierer ein Image hinzufügen und ein anderer ein SelectBox.

Wir werden für dieses Beispiel keine vollwertige GUI-Bibliothek implementieren, sondern zeigen, wie die Teile zusammenpassen würden. Zu dem Zeitpunkt, zu dem wir die Bibliothek schreiben, können wir nicht alle Typen kennen und definieren, die andere Programmierer möglicherweise erstellen möchten. Wir wissen jedoch, dass gui viele Werte unterschiedlicher Typen verfolgen muss und für jeden dieser unterschiedlich typisierten Werte eine draw-Methode aufrufen muss. Es muss nicht genau wissen, was passieren wird, wenn wir die draw-Methode aufrufen, sondern nur, dass der Wert diese Methode zur Verfügung hat, die wir aufrufen können.

Um dies in einer Sprache mit Vererbung zu tun, könnten wir eine Klasse namens Component definieren, die eine Methode namens draw hat. Die anderen Klassen, wie Button, Image und SelectBox, würden von Component erben und somit die draw-Methode erben. Sie könnten jede die draw-Methode überschreiben, um ihr benutzerdefiniertes Verhalten zu definieren, aber das Framework könnte alle Typen so behandeln, als wären sie Component-Instanzen, und draw auf ihnen aufrufen. Da Rust jedoch keine Vererbung hat, brauchen wir eine andere Möglichkeit, die gui-Bibliothek zu strukturieren, um Benutzern die Möglichkeit zu geben, sie mit neuen Typen zu erweitern.

Definieren eines Traits für gemeinsames Verhalten

Um das Verhalten zu implementieren, das wir für gui wünschen, werden wir einen Trait namens Draw definieren, der eine Methode namens draw haben wird. Dann können wir einen Vektor definieren, der ein Trait-Objekt annimmt. Ein Trait-Objekt weist sowohl auf eine Instanz eines Typs, der unseren angegebenen Trait implementiert, als auch auf eine Tabelle, die zur Laufzeit verwendet wird, um Trait-Methoden auf diesem Typ aufzurufen. Wir erstellen ein Trait-Objekt, indem wir einen gewissen Zeiger wie eine &-Referenz oder einen Box<T>-Smart-Pointer angeben, dann das dyn-Schlüsselwort und anschließend den relevanten Trait angeben. (Wir werden im Abschnitt "Dynamically Sized Types and the Sized Trait" über den Grund sprechen, warum Trait-Objekte einen Zeiger verwenden müssen.) Wir können Trait-Objekte anstelle eines generischen oder konkreten Typs verwenden. Überall, wo wir ein Trait-Objekt verwenden, wird das Typsystem von Rust zur Compile-Zeit sicherstellen, dass jeder Wert, der in diesem Kontext verwendet wird, den Trait des Trait-Objekts implementiert. Folglich müssen wir zu Compile-Zeit nicht alle möglichen Typen kennen.

Wir haben erwähnt, dass wir in Rust von der Bezeichnung "Objekte" für Structs und Enums absehen, um sie von den Objekten anderer Sprachen zu unterscheiden. In einem Struct oder Enum sind die Daten in den Struct-Feldern und das Verhalten in impl-Blöcken getrennt, während in anderen Sprachen das die Daten und das Verhalten in einem kombinierten Konzept oft als Objekt bezeichnet wird. Trait-Objekte sind jedoch insofern ähnlicher wie Objekte in anderen Sprachen, als dass sie Daten und Verhalten kombinieren. Trait-Objekte unterscheiden sich jedoch von traditionellen Objekten darin, dass wir keinem Trait-Objekt Daten hinzufügen können. Trait-Objekte sind nicht so allgemein nützlich wie Objekte in anderen Sprachen: Ihr spezielles Ziel ist es, die Abstraktion über gemeinsames Verhalten zu ermöglichen.

Listing 17-3 zeigt, wie man einen Trait namens Draw mit einer Methode namens draw definiert.

Dateiname: src/lib.rs

pub trait Draw {
    fn draw(&self);
}

Listing 17-3: Definition des Draw-Traits

Diese Syntax sollte uns aus unseren Diskussionen über die Definition von Traits im Kapitel 10 bekannt sein. Als nächstes kommt eine neue Syntax: Listing 17-4 definiert eine Struct namens Screen, die einen Vektor namens components enthält. Dieser Vektor ist vom Typ Box<dyn Draw>, was ein Trait-Objekt ist; es ist ein Ersatz für jeden Typ innerhalb eines Box, der den Draw-Trait implementiert.

Dateiname: src/lib.rs

pub struct Screen {
    pub components: Vec<Box<dyn Draw>>,
}

Listing 17-4: Definition der Screen-Struct mit einem components-Feld, das einen Vektor von Trait-Objekten enthält, die den Draw-Trait implementieren

Auf der Screen-Struct werden wir eine Methode namens run definieren, die die draw-Methode auf jedem ihrer components aufruft, wie in Listing 17-5 gezeigt.

Dateiname: src/lib.rs

impl Screen {
    pub fn run(&self) {
        for component in self.components.iter() {
            component.draw();
        }
    }
}

Listing 17-5: Eine run-Methode auf Screen, die die draw-Methode auf jedem Komponenten aufruft

Dies funktioniert anders als die Definition eines Structs, der einen generischen Typparameter mit Trait-Bounds verwendet. Ein generischer Typparameter kann nur zu einem konkreten Typ ersetzt werden, während Trait-Objekte es ermöglichen, mehrere konkrete Typen zur Laufzeit für das Trait-Objekt einzufügen. Beispielsweise hätten wir die Screen-Struct wie in Listing 17-6 mit einem generischen Typ und einem Trait-Bound definieren können.

Dateiname: src/lib.rs

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 17-6: Eine alternative Implementierung der Screen-Struct und ihrer run-Methode mit Generics und Trait-Bounds

Dies beschränkt uns auf eine Screen-Instanz, die eine Liste von Komponenten hat, die alle vom Typ Button oder alle vom Typ TextField sind. Wenn Sie nur homogene Sammlungen haben, ist die Verwendung von Generics und Trait-Bounds bevorzugt, da die Definitionen zur Compile-Zeit monomorphisiert werden, um die konkreten Typen zu verwenden.

Andererseits kann eine Screen-Instanz mit der Methode, die Trait-Objekte verwendet, 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 Laufzeitleistung sprechen.

Implementieren des Traits

Jetzt werden wir einige Typen hinzufügen, die den Draw-Trait implementieren. Wir werden den Button-Typ bereitstellen. Wiederholen wir, dass die tatsächliche Implementierung einer GUI-Bibliothek außerhalb des Rahmens dieses Buches liegt, sodass die draw-Methode keinen nützlichen Implementierungsrumpf haben wird. Um uns vorzustellen, wie die Implementierung aussehen könnte, könnte eine Button-Struct Felder für width, height und label haben, wie in Listing 17-7 gezeigt.

Dateiname: src/lib.rs

pub struct Button {
    pub width: u32,
    pub height: u32,
    pub label: String,
}

impl Draw for Button {
    fn draw(&self) {
        // code to actually draw a button
    }
}

Listing 17-7: Eine Button-Struct, die den Draw-Trait implementiert

Die Felder width, height und label auf Button werden sich von den Feldern auf anderen Komponenten unterscheiden; beispielsweise könnte ein TextField-Typ diese gleichen Felder plus ein placeholder-Feld haben. Jeder der Typen, die wir auf dem Bildschirm zeichnen möchten, wird den Draw-Trait implementieren, aber wird in der draw-Methode unterschiedlicher Code verwenden, um zu definieren, wie diesen bestimmten Typ zu zeichnen ist, wie dies hier bei Button der Fall ist (ohne den tatsächlichen GUI-Code, wie erwähnt). Der Button-Typ könnte beispielsweise einen zusätzlichen impl-Block enthalten, der Methoden enthält, die mit dem was passiert, wenn ein Benutzer auf die Schaltfläche klickt, zusammenhängen. Solche Methoden werden nicht auf Typen wie TextField anwendbar sein.

Wenn jemand, der unsere Bibliothek verwendet, eine SelectBox-Struct implementieren möchte, die Felder für width, height und options hat, würden sie auch den Draw-Trait auf dem SelectBox-Typ implementieren, wie in Listing 17-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 to actually draw a select box
    }
}

Listing 17-8: Ein weiterer Kasten, der gui verwendet und den Draw-Trait auf einer SelectBox-Struct implementiert

Der Benutzer unserer Bibliothek kann jetzt seine main-Funktion schreiben, um eine Screen-Instanz zu erstellen. Zu der Screen-Instanz können sie eine SelectBox und einen Button hinzufügen, indem sie jedes in eine Box<T> stecken, um ein Trait-Objekt zu werden. Sie können dann die run-Methode auf der Screen-Instanz aufrufen, was draw auf jeder der Komponenten aufrufen wird. Listing 17-9 zeigt diese Implementierung.

Dateiname: src/main.rs

use gui::{Button, Screen};

fn main() {
    let screen = Screen {
        components: vec![
            Box::new(SelectBox {
                width: 75,
                height: 10,
                options: vec![
                    String::from("Yes"),
                    String::from("Maybe"),
                    String::from("No"),
                ],
            }),
            Box::new(Button {
                width: 50,
                height: 10,
                label: String::from("OK"),
            }),
        ],
    };

    screen.run();
}

Listing 17-9: Verwenden von Trait-Objekten, um Werte unterschiedlicher Typen zu speichern, die denselben Trait implementieren

Als wir die Bibliothek geschrieben haben, wussten wir nicht, dass jemand den SelectBox-Typ hinzufügen könnte, aber unsere Screen-Implementierung war in der Lage, auf dem neuen Typ zu operieren und ihn zu zeichnen, weil SelectBox den Draw-Trait implementiert, was bedeutet, dass es die draw-Methode implementiert.

Dieser Begriff - nur auf die Nachrichten zu achten, auf die ein Wert reagiert, anstatt auf den konkreten Typ des Werts - ähnelt dem Begriff des Duck-Typings in dynamisch typisierten Sprachen: Wenn es wie eine Ente läuft und wie eine Ente quakt, dann muss es eine Ente sein! In der Implementierung von run auf Screen in Listing 17-5 muss run nicht wissen, was der konkrete Typ jeder Komponente ist. Es überprüft nicht, ob eine Komponente eine Instanz eines Button oder eines SelectBox ist, es ruft einfach die draw-Methode auf der Komponente auf. Indem wir Box<dyn Draw> als Typ der Werte im components-Vektor angeben, haben wir definiert, dass Screen Werte benötigt, auf denen wir die draw-Methode aufrufen können.

Der Vorteil der Verwendung von Trait-Objekten und des Rust-Typsystems, um Code zu schreiben, der ähnlich wie Code mit Duck-Typing ist, besteht darin, dass wir nie überprü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, aber wir sie trotzdem aufrufen. Rust wird unseren Code nicht kompilieren, wenn die Werte die Traits nicht implementieren, die die Trait-Objekte benötigen.

Zum Beispiel zeigt Listing 17-10, was passiert, wenn wir versuchen, eine Screen mit einer String als Komponente zu erstellen.

Dateiname: src/main.rs

use gui::Screen;

fn main() {
    let screen = Screen {
        components: vec![Box::new(String::from("Hi"))],
    };

    screen.run();
}

Listing 17-10: Versuch, einen Typ zu verwenden, der den Trait des Trait-Objekts nicht implementiert

Wir erhalten diesen Fehler, weil String den Draw-Trait nicht implementiert:

error[E0277]: the trait bound `String: Draw` is not satisfied
 --> src/main.rs:5:26
  |
5 |         components: vec![Box::new(String::from("Hi"))],
  |                          ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the trait `Draw` is
not implemented for `String`
  |
  = note: required for the cast to the object type `dyn Draw`

Dieser Fehler lässt uns wissen, dass wir entweder etwas an Screen übergeben, das wir nicht übergeben möchten und daher einen anderen Typ übergeben sollten, oder dass wir Draw auf String implementieren sollten, damit Screen in der Lage ist, draw auf ihm aufzurufen.

Trait-Objekte führen dynamische Verteilung durch

Denken Sie sich in "Performance of Code Using Generics" unsere Diskussion über den Prozess der Monomorphisierung, den der Compiler durchführt, wenn wir Trait-Bounds auf Generics verwenden: Der Compiler generiert nongenerische Implementierungen von Funktionen und Methoden für jeden konkreten Typ, den wir anstelle eines generischen Typparameters verwenden. Der Code, der aus der Monomorphisierung resultiert, führt eine statische Verteilung durch, das heißt, der Compiler weiß zu Compile-Zeit, welche Methode Sie aufrufen. Dies steht im Gegensatz zur dynamischen Verteilung, bei der der Compiler zu Compile-Zeit nicht wissen kann, welche Methode Sie aufrufen. Bei dynamischer Verteilung emittiert der Compiler Code, der zur Laufzeit herausfinden wird, welche Methode aufgerufen werden soll.

Wenn wir Trait-Objekte verwenden, muss Rust dynamische Verteilung verwenden. Der Compiler kennt nicht alle Typen, die mit dem Code verwendet werden können, der Trait-Objekte verwendet, daher weiß er nicht, welche Methode auf welchem Typ implementiert werden soll, um aufzurufen. Stattdessen verwendet Rust zur Laufzeit die Pointer innerhalb des Trait-Objekts, um zu wissen, welche Methode aufzurufen. Dieser Lookup verursacht einen Laufzeitaufwand, der nicht bei statischer Verteilung auftritt. Die dynamische Verteilung verhindert auch, dass der Compiler die Möglichkeit hat, den Code einer Methode einzuschließen, was wiederum einige Optimierungen verhindert. Wir haben jedoch in unserem in Listing 17-5 geschriebenen Code zusätzliche Flexibilität erhalten und in Listing 17-9 unterstützen können, daher ist dies ein Kompromiss, den man berücksichtigen muss.

Zusammenfassung

Herzlichen Glückwunsch! Sie haben das Labor "Using Trait Objects That Allow for Values of Different Types" abgeschlossen. Sie können in LabEx weitere Labs ausprobieren, um Ihre Fähigkeiten zu verbessern.