Traits: Defining Shared Behavior

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 Traits: Defining Shared Behavior. Dieser Lab ist ein Teil des Rust Buchs. Du kannst deine Rust-Fähigkeiten in LabEx üben.

In diesem Lab untersuchen wir Traits als Möglichkeit, gemeinsames Verhalten in einem Typ zu definieren und Trait-Bounds für generische Typen anzugeben.

Traits: Defining Shared Behavior

Ein Trait definiert die Funktionalität, die ein bestimmter Typ hat und mit anderen Typen teilen kann. Wir können Traits verwenden, um gemeinsames Verhalten auf abstrakte Weise zu definieren. Wir können Trait-Bounds verwenden, um anzugeben, dass ein generischer Typ jeder beliebige Typ sein kann, der bestimmtes Verhalten aufweist.

Hinweis: Traits ähneln einem Feature, das in anderen Sprachen oft als Interfaces bezeichnet wird, obwohl es einige Unterschiede gibt.

Defining a Trait

Ein Typverhalten besteht aus den Methoden, die wir auf diesem Typ aufrufen können. Verschiedene Typen teilen das gleiche Verhalten, wenn wir auf allen diesen Typen die gleichen Methoden aufrufen können. Trait-Definitionen sind eine Möglichkeit, Methodensignaturen zusammenzufassen, um eine Menge an Verhaltensweisen zu definieren, die erforderlich sind, um einen bestimmten Zweck zu erreichen.

Nehmen wir beispielsweise an, dass wir mehrere Structs haben, die verschiedene Arten und Mengen von Text enthalten: ein NewsArticle-Struct, der eine Nachrichtengeschichte in einem bestimmten Ort speichert, und ein Tweet, der maximal 280 Zeichen haben kann, zusammen mit Metadaten, die angeben, ob es ein neuer Tweet, ein Retweet oder eine Antwort auf einen anderen Tweet war.

Wir möchten eine Medienaggregator-Bibliothekskiste namens aggregator erstellen, die Zusammenfassungen von Daten anzeigen kann, die in einer NewsArticle- oder Tweet-Instanz gespeichert sein könnten. Dazu benötigen wir eine Zusammenfassung von jedem Typ und werden diese Zusammenfassung durch Aufruf einer summarize-Methode auf einer Instanz anfordern. Listing 10-12 zeigt die Definition eines öffentlichen Summary-Traits, das dieses Verhalten ausdrückt.

Dateiname: src/lib.rs

pub trait Summary {
    fn summarize(&self) -> String;
}

Listing 10-12: Ein Summary-Trait, das aus dem Verhalten besteht, das von einer summarize-Methode bereitgestellt wird

Hier deklarieren wir ein Trait mit dem Schlüsselwort trait und dann dem Namen des Traits, was in diesem Fall Summary ist. Wir deklarieren das Trait auch als pub, damit Kisten, die von dieser Kiste abhängen, auch dieses Trait verwenden können, wie wir in ein paar Beispielen sehen werden. Innerhalb der geschweiften Klammern deklarieren wir die Methodensignaturen, die das Verhalten der Typen beschreiben, die dieses Trait implementieren, was in diesem Fall fn summarize(&self) -> String ist.

Nach der Methodensignatur geben wir statt einer Implementierung innerhalb von geschweiften Klammern ein Semikolon ein. Jeder Typ, der dieses Trait implementiert, muss sein eigenes benutzerdefiniertes Verhalten für den Methodenkörper angeben. Der Compiler wird sicherstellen, dass jeder Typ, der das Summary-Trait hat, die Methode summarize mit genau dieser Signatur definiert hat.

Ein Trait kann mehrere Methoden in seinem Körper haben: Die Methodensignaturen werden pro Zeile aufgelistet und jede Zeile endet mit einem Semikolon.

Implementing a Trait on a Type

Jetzt, nachdem wir die gewünschten Signaturen der Methoden des Summary-Traits definiert haben, können wir es auf die Typen in unserem Medienaggregator implementieren. Listing 10-13 zeigt eine Implementierung des Summary-Traits auf der NewsArticle-Struktur, die die Überschrift, den Autor und den Ort verwendet, um den Rückgabewert von summarize zu erstellen. Für die Tweet-Struktur definieren wir summarize als Benutzernamen gefolgt vom gesamten Text des Tweets, unter der Annahme, dass der Tweetinhalt bereits auf 280 Zeichen begrenzt ist.

Dateiname: src/lib.rs

pub struct NewsArticle {
    pub headline: String,
    pub location: String,
    pub author: String,
    pub content: String,
}

impl Summary for NewsArticle {
    fn summarize(&self) -> String {
        format!(
            "{}, by {} ({})",
            self.headline,
            self.author,
            self.location
        )
    }
}

pub struct Tweet {
    pub username: String,
    pub content: String,
    pub reply: bool,
    pub retweet: bool,
}

impl Summary for Tweet {
    fn summarize(&self) -> String {
        format!("{}: {}", self.username, self.content)
    }
}

Listing 10-13: Implementieren des Summary-Traits auf den Typen NewsArticle und Tweet

Das Implementieren eines Traits auf einem Typ ähnelt der Implementierung von regulären Methoden. Der Unterschied besteht darin, dass wir nach impl den Namen des Traits schreiben, das wir implementieren möchten, dann das for-Schlüsselwort verwenden und anschließend den Namen des Typs angeben, für den wir das Trait implementieren möchten. Innerhalb des impl-Blocks schreiben wir die Methodensignaturen, die die Trait-Definition definiert hat. Anstatt nach jeder Signatur ein Semikolon hinzuzufügen, verwenden wir geschweifte Klammern und füllen den Methodenkörper mit dem spezifischen Verhalten aus, das wir für die Methoden des Traits für den bestimmten Typ haben möchten.

Jetzt, nachdem die Bibliothek das Summary-Trait auf NewsArticle und Tweet implementiert hat, können die Benutzer der Kiste die Trait-Methoden auf Instanzen von NewsArticle und Tweet auf die gleiche Weise aufrufen, wie wir reguläre Methoden aufrufen. Der einzige Unterschied besteht darin, dass der Benutzer das Trait sowie die Typen in den Geltungsbereich bringen muss. Hier ist ein Beispiel dafür, wie eine binäre Kiste unsere aggregator-Bibliothekskiste verwenden könnte:

use aggregator::{Summary, Tweet};

fn main() {
    let tweet = Tweet {
        username: String::from("horse_ebooks"),
        content: String::from(
            "of course, as you probably already know, people",
        ),
        reply: false,
        retweet: false,
    };

    println!("1 new tweet: {}", tweet.summarize());
}

Dieser Code druckt 1 new tweet: horse_ebooks: of course, as you probably already know, people.

Andere Kisten, die von der aggregator-Kiste abhängen, können auch das Summary-Trait in den Geltungsbereich bringen, um Summary auf ihren eigenen Typen zu implementieren. Eine Einschränkung, die zu beachten ist, besteht darin, dass wir ein Trait nur auf einem Typ implementieren können, wenn entweder das Trait oder der Typ oder beide unserem Kasten lokal sind. Beispielsweise können wir Standardbibliotheks-Traits wie Display auf einem benutzerdefinierten Typ wie Tweet als Teil der Funktionalität unserer aggregator-Kiste implementieren, da der Typ Tweet unserem aggregator-Kasten lokal ist. Wir können auch Summary auf Vec<T> in unserer aggregator-Kiste implementieren, da das Trait Summary unserem aggregator-Kasten lokal ist.

Wir können jedoch externe Traits auf externe Typen nicht implementieren. Beispielsweise können wir das Display-Trait auf Vec<T> innerhalb unserer aggregator-Kiste nicht implementieren, da Display und Vec<T> beide in der Standardbibliothek definiert sind und unserem aggregator-Kasten nicht lokal sind. Diese Einschränkung ist Teil einer Eigenschaft namens Kohärenz, genauer gesagt der Waisenregel, die so benannt ist, weil der Elterntyp nicht vorhanden ist. Diese Regel gewährleistet, dass der Code anderer Leute Ihren Code nicht brechen kann und umgekehrt. Ohne die Regel könnten zwei Kisten das gleiche Trait für den gleichen Typ implementieren, und Rust würde nicht wissen, welche Implementierung zu verwenden.

Default Implementations

Manchmal ist es nützlich, standardmäßiges Verhalten für einige oder alle Methoden eines Traits zu haben, anstatt für jede Methode auf jedem Typ Implementierungen erforderlich zu machen. Dann, wenn wir das Trait auf einem bestimmten Typ implementieren, können wir das standardmäßige Verhalten jeder Methode beibehalten oder überschreiben.

In Listing 10-14 geben wir einen Standardstring für die summarize-Methode des Summary-Traits an, anstatt nur die Methodensignatur zu definieren, wie wir es in Listing 10-12 getan haben.

Dateiname: src/lib.rs

pub trait Summary {
    fn summarize(&self) -> String {
        String::from("(Read more...)")
    }
}

Listing 10-14: Definieren eines Summary-Traits mit einer Standardimplementierung der summarize-Methode

Um die Standardimplementierung zu verwenden, um Instanzen von NewsArticle zu zusammenfassen, geben wir einen leeren impl-Block mit impl Summary for NewsArticle {} an.

Auch wenn wir die summarize-Methode auf NewsArticle nicht mehr direkt definieren, haben wir eine Standardimplementierung bereitgestellt und angegeben, dass NewsArticle das Summary-Trait implementiert. Als Ergebnis können wir immer noch die summarize-Methode auf einer Instanz von NewsArticle aufrufen, wie folgt:

let article = NewsArticle {
    headline: String::from(
        "Penguins win the Stanley Cup Championship!"
    ),
    location: String::from("Pittsburgh, PA, USA"),
    author: String::from("Iceburgh"),
    content: String::from(
        "The Pittsburgh Penguins once again are the best \
         hockey team in the NHL.",
    ),
};

println!("New article available! {}", article.summarize());

Dieser Code druckt New article available! (Read more...).

Das Erstellen einer Standardimplementierung erfordert nicht, dass wir etwas an der Implementierung von Summary auf Tweet in Listing 10-13 ändern. Der Grund ist, dass die Syntax zum Überschreiben einer Standardimplementierung die gleiche ist wie die Syntax zur Implementierung einer Trait-Methode, die keine Standardimplementierung hat.

Standardimplementierungen können andere Methoden in demselben Trait aufrufen, auch wenn diese anderen Methoden keine Standardimplementierung haben. Auf diese Weise kann ein Trait viel nützliche Funktionalität bereitstellen und nur die Implementierenden dazu zwingen, einen kleinen Teil davon anzugeben. Beispielsweise könnten wir das Summary-Trait definieren, um eine summarize_author-Methode zu haben, deren Implementierung erforderlich ist, und dann eine summarize-Methode definieren, die eine Standardimplementierung hat, die die summarize_author-Methode aufruft:

pub trait Summary {
    fn summarize_author(&self) -> String;

    fn summarize(&self) -> String {
        format!(
            "(Read more from {}...)",
            self.summarize_author()
        )
    }
}

Um diese Version von Summary zu verwenden, müssen wir nur summarize_author definieren, wenn wir das Trait auf einem Typ implementieren:

impl Summary for Tweet {
    fn summarize_author(&self) -> String {
        format!("@{}", self.username)
    }
}

Nachdem wir summarize_author definiert haben, können wir summarize auf Instanzen der Tweet-Struktur aufrufen, und die Standardimplementierung von summarize wird die von uns bereitgestellte Definition von summarize_author aufrufen. Da wir summarize_author implementiert haben, hat uns das Summary-Trait das Verhalten der summarize-Methode ohne dass wir mehr Code schreiben mussten. So sieht das aus:

let tweet = Tweet {
    username: String::from("horse_ebooks"),
    content: String::from(
        "of course, as you probably already know, people",
    ),
    reply: false,
    retweet: false,
};

println!("1 new tweet: {}", tweet.summarize());

Dieser Code druckt 1 new tweet: (Read more from @horse_ebooks...).

Beachten Sie, dass es nicht möglich ist, die Standardimplementierung aus einer Überschreibungsimplementierung derselben Methode aufzurufen.

Traits as Parameters

Jetzt, nachdem Sie wissen, wie Sie Traits definieren und implementieren, können wir untersuchen, wie Sie Traits verwenden, um Funktionen zu definieren, die viele verschiedene Typen akzeptieren. Wir werden das Summary-Trait, das wir in Listing 10-13 auf den Typen NewsArticle und Tweet implementiert haben, verwenden, um eine notify-Funktion zu definieren, die die summarize-Methode auf ihrem item-Parameter aufruft, der vom Typ ist, der das Summary-Trait implementiert. Dazu verwenden wir die impl Trait-Syntax wie folgt:

pub fn notify(item: &impl Summary) {
    println!("Breaking news! {}", item.summarize());
}

Anstatt einen konkreten Typ für den item-Parameter anzugeben, geben wir das impl-Schlüsselwort und den Traitnamen an. Dieser Parameter akzeptiert jeden Typ, der das angegebene Trait implementiert. Im Körper von notify können wir beliebige Methoden auf item aufrufen, die aus dem Summary-Trait stammen, wie summarize. Wir können notify aufrufen und jedes NewsArticle- oder Tweet-Objekt übergeben. Code, der die Funktion mit einem anderen Typ wie String oder i32 aufruft, wird nicht kompilieren, da diese Typen nicht Summary implementieren.

Trait Bound Syntax

Die impl Trait-Syntax funktioniert für einfache Fälle, ist aber tatsächlich syntaktischer Zucker für eine längere Form, die als Trait Bound bekannt ist; es sieht so aus:

pub fn notify<T: Summary>(item: &T) {
    println!("Breaking news! {}", item.summarize());
}

Diese längere Form ist der Beispiel in der vorherigen Section äquivalent, ist aber ausführlicher. Wir setzen Trait Bounds mit der Deklaration des generischen Typparameters nach einem Doppelpunkt und innerhalb von spitzen Klammern.

Die impl Trait-Syntax ist praktisch und führt in einfachen Fällen zu kürzerem Code, während die vollständige Trait Bound-Syntax in anderen Fällen mehr Komplexität ausdrücken kann. Beispielsweise können wir zwei Parameter haben, die Summary implementieren. Dies sieht mit der impl Trait-Syntax so aus:

pub fn notify(item1: &impl Summary, item2: &impl Summary) {

Das Verwenden von impl Trait ist geeignet, wenn wir möchten, dass diese Funktion item1 und item2 verschiedene Typen haben lässt (solange beide Typen Summary implementieren). Wenn wir jedoch beide Parameter zwingen, den gleichen Typ zu haben, müssen wir einen Trait Bound verwenden, wie folgt:

pub fn notify<T: Summary>(item1: &T, item2: &T) {

Der generische Typ T, der als Typ der item1- und item2-Parameter angegeben wird, beschränkt die Funktion so, dass der konkrete Typ des Werts, der als Argument für item1 und item2 übergeben wird, der gleiche sein muss.

Specifying Multiple Trait Bounds with the + Syntax

Wir können auch mehrere Trait Bounds angeben. Nehmen wir an, dass wir möchten, dass notify sowohl die Anzeigeformatierung als auch summarize auf item verwenden: Wir geben in der notify-Definition an, dass item sowohl Display als auch Summary implementieren muss. Wir können dies mit der +-Syntax tun:

pub fn notify(item: &(impl Summary + Display)) {

Die +-Syntax ist auch gültig mit Trait Bounds für generische Typen:

pub fn notify<T: Summary + Display>(item: &T) {

Mit den zwei angegebenen Trait Bounds kann der Körper von notify summarize aufrufen und {} verwenden, um item zu formatieren.

Clearer Trait Bounds with where Clauses

Das Verwenden zu vieler Trait Bounds hat Nachteile. Jeder Generic-Typ hat seine eigenen Trait Bounds, sodass Funktionen mit mehreren generischen Typparametern zwischen dem Funktionsnamen und seiner Parameterliste viel Trait-Bound-Information enthalten können, was die Funktionssignatur schwer lesbar macht. Aus diesem Grund hat Rust eine alternative Syntax zum Angeben von Trait Bounds innerhalb einer where-Klausel nach der Funktionssignatur. Anstatt also das folgende zu schreiben:

fn some_function<T: Display + Clone, U: Clone + Debug>(t: &T, u: &U) -> i32 {

können wir eine where-Klausel verwenden, wie folgt:

fn some_function<T, U>(t: &T, u: &U) -> i32
where
    T: Display + Clone,
    U: Clone + Debug,
{

Die Signatur dieser Funktion ist weniger verwirrt: Der Funktionsname, die Parameterliste und der Rückgabetyp sind dicht beieinander, ähnlich wie bei einer Funktion ohne viele Trait Bounds.

Returning Types That Implement Traits

Wir können auch die impl Trait-Syntax an der Rückgabetstelle verwenden, um einen Wert eines Typs zurückzugeben, der ein Trait implementiert, wie hier gezeigt:

fn returns_summarizable() -> impl Summary {
    Tweet {
        username: String::from("horse_ebooks"),
        content: String::from(
            "of course, as you probably already know, people",
        ),
        reply: false,
        retweet: false,
    }
}

Indem wir impl Summary für den Rückgabetyp verwenden, geben wir an, dass die returns_summarizable-Funktion einen Typ zurückgibt, der das Summary-Trait implementiert, ohne den konkreten Typ zu nennen. In diesem Fall gibt returns_summarizable ein Tweet zurück, aber der Code, der diese Funktion aufruft, muss das nicht wissen.

Die Möglichkeit, einen Rückgabetyp nur durch das Trait, das er implementiert, anzugeben, ist besonders nützlich im Zusammenhang mit Closures und Iterators, die wir im Kapitel 13 behandeln. Closures und Iterators erzeugen Typen, von denen nur der Compiler weiß, oder Typen, die sehr lang zu spezifizieren sind. Die impl Trait-Syntax ermöglicht es Ihnen, präzise anzugeben, dass eine Funktion einen Typ zurückgibt, der das Iterator-Trait implementiert, ohne einen sehr langen Typ schreiben zu müssen.

Wir können impl Trait jedoch nur verwenden, wenn wir einen einzelnen Typ zurückgeben. Beispielsweise würde dieser Code, der entweder ein NewsArticle oder ein Tweet zurückgibt, mit dem Rückgabetyp impl Summary nicht funktionieren:

fn returns_summarizable(switch: bool) -> impl Summary {
    if switch {
        NewsArticle {
            headline: String::from(
                "Penguins win the Stanley Cup Championship!",
            ),
            location: String::from("Pittsburgh, PA, USA"),
            author: String::from("Iceburgh"),
            content: String::from(
                "The Pittsburgh Penguins once again are the best \
                 hockey team in the NHL.",
            ),
        }
    } else {
        Tweet {
            username: String::from("horse_ebooks"),
            content: String::from(
                "of course, as you probably already know, people",
            ),
            reply: false,
            retweet: false,
        }
    }
}

Das Zurückgeben von entweder einem NewsArticle oder einem Tweet ist aufgrund von Einschränkungen bei der Implementierung der impl Trait-Syntax im Compiler nicht möglich. Wir werden im Abschnitt "Using Trait Objects That Allow for Values of Different Types" behandeln, wie man eine Funktion mit diesem Verhalten schreibt.

Using Trait Bounds to Conditionally Implement Methods

Indem wir einen Trait Bound mit einem impl-Block verwenden, der generische Typparameter verwendet, können wir Methoden bedingt für Typen implementieren, die das angegebene Trait implementieren. Beispielsweise implementiert der Typ Pair<T> in Listing 10-15 immer die new-Funktion, um eine neue Instanz von Pair<T> zurückzugeben (denken Sie an "Defining Methods", dass Self ein Typalias für den Typ des impl-Blocks ist, was in diesem Fall Pair<T> ist). Aber im nächsten impl-Block implementiert Pair<T> nur die cmp_display-Methode, wenn sein innerer Typ T das PartialOrd-Trait implementiert, das das Vergleichen ermöglicht, und das Display-Trait, das das Drucken ermöglicht.

Dateiname: src/lib.rs

use std::fmt::Display;

struct Pair<T> {
    x: T,
    y: T,
}

impl<T> Pair<T> {
    fn new(x: T, y: T) -> Self {
        Self { x, y }
    }
}

impl<T: Display + PartialOrd> Pair<T> {
    fn cmp_display(&self) {
        if self.x >= self.y {
            println!("The largest member is x = {}", self.x);
        } else {
            println!("The largest member is y = {}", self.y);
        }
    }
}

Listing 10-15: Bedingtes Implementieren von Methoden auf einem generischen Typ abhängig von Trait Bounds

Wir können auch bedingt ein Trait für jeden Typ implementieren, der ein anderes Trait implementiert. Implementierungen eines Traits für jeden Typ, der die Trait Bounds erfüllt, werden als blanket implementations bezeichnet und werden in der Rust-Standardbibliothek weit verbreitet verwendet. Beispielsweise implementiert die Standardbibliothek das ToString-Trait für jeden Typ, der das Display-Trait implementiert. Der impl-Block in der Standardbibliothek sieht ähnlich wie dieser Code aus:

impl<T: Display> ToString for T {
    --snip--
}

Aufgrund dieser blanket implementation in der Standardbibliothek können wir die to_string-Methode, die durch das ToString-Trait definiert ist, auf jedem Typ aufrufen, der das Display-Trait implementiert. Beispielsweise können wir ganze Zahlen in ihre entsprechenden String-Werte umwandeln, wie dies hier geht, weil ganze Zahlen Display implementieren:

let s = 3.to_string();

Blanket Implementierungen erscheinen in der Dokumentation für das Trait im Abschnitt "Implementors".

Traits und Trait Bounds ermöglichen es uns, Code zu schreiben, der generische Typparameter verwendet, um die Duplizierung zu reduzieren, aber auch an den Compiler anzugeben, dass wir möchten, dass der generische Typ ein bestimmtes Verhalten hat. Der Compiler kann dann die Trait Bound-Informationen verwenden, um zu überprüfen, dass alle konkreten Typen, die mit unserem Code verwendet werden, das richtige Verhalten bieten. In dynamisch typisierten Sprachen würden wir bei der Ausführung eine Fehlermeldung erhalten, wenn wir eine Methode auf einem Typ aufrufen, der die Methode nicht definiert. Aber Rust verschiebt diese Fehler in die Kompilierzeit, sodass wir gezwungen sind, die Probleme zu beheben, bevor unser Code überhaupt ausgeführt werden kann. Darüber hinaus müssen wir keinen Code schreiben, der das Verhalten zur Laufzeit überprüft, weil wir es bereits zur Kompilierzeit überprüft haben. Dadurch wird die Leistung verbessert, ohne dass wir die Flexibilität der Generics aufgeben müssen.

Zusammenfassung

Herzlichen Glückwunsch! Sie haben das Lab "Traits: Defining Shared Behavior" abgeschlossen. Sie können in LabEx weitere Labs ausprobieren, um Ihre Fähigkeiten zu verbessern.