Rust-Generics und Traits

Generika sind ein unverzichtbarer Mechanismus in Programmiersprachen.

C++ verwendet "Templates", um Generika zu implementieren, während C keinen generischen Mechanismus hat, was es für C schwierig macht, Projekte mit komplexen Typen zu erstellen.

Der generische Mechanismus ist ein Mechanismus, der von Programmiersprachen verwendet wird, um Typabstraktion auszudrücken, und wird im Allgemeinen für Klassen mit festgelegter Funktionalität, aber nicht festgelegten Datentypen verwendet, wie z. B. verkettete Listen und Maps.

Generics in Funktionen definieren

Dies ist eine Methode zur Auswahlsortierung von Ganzzahlen:

Beispiel

fn max(array: &[i32]) -> i32 {
    let mut max_index = 0;
    let mut i = 1;
    while i < array.len() {
        if array[i] > array[max_index] {
            max_index = i;
        }
        i += 1;
    }
    array[max_index]
}

fn main() {
    let a = [2, 4, 6, 3, 1];
    println!("max = {}", max(&a));
}

Ausgabe:

max = 6

Dies ist ein einfaches Programm zum Ermitteln des Maximalwerts, das Daten des numerischen Typs i32 verarbeiten kann, aber keine Daten des Typs f64. Durch die Verwendung von Generics können wir diese Funktion für verschiedene Typen nutzbar machen. In der Realität können jedoch nicht alle Datentypen hinsichtlich ihrer Größe verglichen werden, daher ist der nächste Codeabschnitt nicht zum Ausführen gedacht, sondern um die Syntax von Funktions-Generics zu beschreiben:

Beispiel

fn max<T>(array: &[T]) -> T {
    let mut max_index = 0;
    let mut i = 1;
    while i < array.len() {
        if array[i] > array[max_index] {
            max_index = i;
        }
        i += 1;
    }
    array[max_index]
}

Generics in Structs und Enums

Die Option- und Result-Enums, die wir zuvor kennengelernt haben, sind generisch.

Sowohl Structs als auch Enums in Rust können den generischen Mechanismus implementieren.

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

Dies ist ein Struct für Punktkoordinaten, wobei T den numerischen Typ darstellt, der die Punktkoordinaten beschreibt. Wir können es so verwenden:

let p1 = Point {x: 1, y: 2};
let p2 = Point {x: 1.0, y: 2.0};

Bei der Verwendung wird kein Typ deklariert; hier wird der automatische Typinferenzmechanismus verwendet, aber Typkonflikte sind nicht erlaubt, wie folgt:

let p = Point {x: 1, y: 2.0};

Wenn x an 1 gebunden ist, ist T bereits auf i32 gesetzt, daher ist der Typ f64 nicht erlaubt. Wenn wir möchten, dass x und y durch unterschiedliche Datentypen dargestellt werden, können wir zwei generische Bezeichner verwenden:

struct Point<T1, T2> {
    x: T1,
    y: T2
}

In Enums werden Generics in Formen wie Option und Result ausgedrückt:

enum Option<T> {
    Some(T),
    None,
}

enum Result<T, E> {
    Ok(T),
    Err(E),
}

Sowohl Structs als auch Enums können Methoden definieren, daher sollten Methoden auch den generischen Mechanismus implementieren; andernfalls können generische Typen nicht effektiv durch Methoden bearbeitet werden.

Beispiel

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

impl<T> Point<T> {
    fn x(&self) -> &T {
        &self.x
    }
}

fn main() {
    let p = Point { x: 1, y: 2 };
    println!("p.x = {}", p.x());
}

Ausgabe:

p.x = 1

Beachte, dass das Schlüsselwort impl von<T><T> gefolgt werden muss, weil sich das T danach an ihm orientiert.

impl Point<f64> {
    fn x(&self) -> f64 {
        self.x
    }
}
Die Generics des impl-Blocks selbst verhindern nicht, dass seine inneren Methoden generische Fähigkeiten haben:
impl<T, U> Point<T, U> {
    fn mixup<V, W>(self, other: Point<V, W>) -> Point<T, W> {
        Point {
            x: self.x,
            y: other.y,
        }
    }
}

Die Methode mixup führt das x eines Punktes Point<T, U> mit dem y eines Punktes Point<V, W> zu einem neuen Punkt vom Typ Point<T, W> zusammen.


Traits

Das Konzept der Traits ist eng verwandt mit Interfaces in Java, aber die beiden sind nicht genau dasselbe. Die Gemeinsamkeit zwischen Traits und Interfaces besteht darin, dass sie beide Verhaltensspezifikationen sind, mit denen identifiziert wird, welche Klassen welche Methoden haben.

Traits werden in Rust mit dem Schlüsselwort trait dargestellt:

trait Descriptive {
    fn describe(&self) -> String;
}

Descriptive legt fest, dass Implementierer die Methodedescribe(&self) -> Stringhaben müssen.

Wir verwenden es, um ein struct zu implementieren:

Beispiel

struct Person {
    name: String,
    age: u8
}

impl Descriptive for Person {
    fn describe(&self) -> String {
        format!("{} {}", self.name, self.age)
    }
}

Das Format ist:

impl <特性名> for <所实现的类型名>

In Rust kann derselbe Typ mehrere Traits implementieren, und jeder impl-Block kann nur ein Trait implementieren.

Default-Traits

Das ist der Unterschied zwischen Traits und Interfaces: Interfaces können nur Methoden spezifizieren, aber keine Methoden definieren, während Traits Methoden als Default-Methoden definieren können. Da sie "default" sind, können Objekte Methoden entweder neu definieren oder die Default-Methoden verwenden, ohne sie neu zu definieren:

Beispiel

trait Descriptive {
    fn describe(&self) -> String {
        String::from("[Object]")
    }
}

struct Person {
    name: String,
    age: u8
}

impl Descriptive for Person {
    fn describe(&self) -> String {
        format!("{} {}", self.name, self.age)
    }
}

fn main() {
    let cali = Person {
        name: String::from("Cali"),
        age: 24
    };
    println!("{}", cali.describe());
}

Ausgabe:

Cali 24

Wenn wir den Inhalt im impl Descriptive for Person-Block entfernen, dann wird die Ausgabe sein:

[Object]

Traits als Parameter

In vielen Fällen müssen wir eine Funktion als Parameter übergeben, wie Callback-Funktionen, das Festlegen von Button-Events usw. In Java müssen Funktionen als Instanzen von Klassen übergeben werden, die ein Interface implementieren; in Rust kann dies durch Übergabe von Trait-Parametern erreicht werden:

fn output(object: impl Descriptive) {
    println!("{}", object.describe());
}

Jedes Objekt, das das Trait Descriptive implementiert, kann als Parameter an diese Funktion übergeben werden. Die Funktion muss nicht wissen, ob das übergebene Objekt andere Eigenschaften oder Methoden hat; sie muss nur wissen, dass es die vom Trait Descriptive festgelegten Methoden haben muss. Natürlich kann diese Funktion auch keine anderen Eigenschaften oder Methoden verwenden.

Trait-Parameter können auch mit dieser äquivalenten Syntax implementiert werden:

fn output<T: Descriptive>(object: T) {
    println!("{}", object.describe());
}

Dies ist syntaktischer Zucker in einem Stil, der Generics ähnelt. Dieser syntaktische Zucker ist sehr nützlich, wenn mehrere Parametertypen alle Traits sind:

fn output_two<T: Descriptive>(arg1: T, arg2: T) {
    println!("{}", arg1.describe());
    println!("{}", arg2.describe());
}

Wenn Traits als Typdarstellungen verwendet werden und mehrere Traits beteiligt sind, können Sie++-Symbol verwenden, um sie darzustellen, zum Beispiel:

fn notify(item: impl Summary + Display)
fn notify<T: Summary + Display>(item: T)

Hinweis:Er wird nur bei der Darstellung von Typen verwendet; er bedeutet nicht, dass er in impl-Blöcken verwendet werden kann.

Komplexe Implementierungsbeziehungen können mit dem Schlüsselwort where vereinfacht werden, zum Beispiel:

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

kann vereinfacht werden zu:

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

Nachdem diese Syntax verstanden wurde, kann das Beispiel "get maximum value" im Kapitel über Generics wirklich implementiert werden:

Beispiel

trait Comparable {
    fn compare(&self, object: &Self) -> i8;
}

fn max<T: Comparable>(array: &[T]) -> &T {
    let mut max_index = 0;
    let mut i = 1;
    while i < array.len() {
        if array[i].compare(&array[max_index]) > 0 {
            max_index = i;
        }
        i += 1;
    }
    &array[max_index]
}

impl Comparable for f64 {
    fn compare(&self, object: &f64) -> i8 {
        if &self > &object { 1 }
        else if &self == &object { 0 }
        else { -1 }
    }
}

fn main() {
    let arr = [1.0, 3.0, 5.0, 4.0, 2.0];
    println!("maximum of arr is {}", max(&arr));
}

Ausgabe:

maximum of arr is 5

Tipp:Da es notwendig ist, zu deklarieren, dass der zweite Parameter der Vergleichsfunktion derselbe sein muss wie der Typ, der das Trait implementiert, repräsentiert das Schlüsselwort Self (auf die Großschreibung achten) den aktuellen Typ selbst (nicht die Instanz).

Traits als Rückgabewerte

Das Format für Traits als Rückgabewerte ist wie folgt:

Beispiel

fn person() -> impl Descriptive {
    Person {
        name: String::from("Cali"),
        age: 24
    }
}

Es gibt jedoch einen Punkt: Wenn ein Trait als Rückgabewert verwendet wird, werden nur Objekte, die das Trait implementieren, als Rückgabewerte akzeptiert, und alle möglichen Rückgabetypen in derselben Funktion müssen exakt gleich sein. Wenn beispielsweise sowohl Struct A als auch Struct B das Trait Trait implementieren, ist die folgende Funktion fehlerhaft:

Beispiel

fn some_function(bool bl) -> impl Descriptive {
    if bl {
        return A {};
    } else {
        return B {};
    }
}

Bedingte Methodenimplementierung

Die impl-Funktionalität ist sehr mächtig; wir können sie verwenden, um Methoden für einen Typ zu implementieren. Bei generischen Typen müssen wir jedoch manchmal unterscheiden, welche Methoden der generische Typ, zu dem er gehört, bereits implementiert hat, um zu entscheiden, welche Methoden er als Nächstes implementieren soll:

struct A<T> {}

impl<T: B + C> A<T> {
    fn d(&self) {}
}

Dieser Code erklärt, dass der Typ A<T> diesen impl-Block nur dann effektiv implementieren kann, wenn T bereits die Traits B und C implementiert hat.

Andere Erweiterungen