Traits

Ein Trait ist ein Sprachkonstrukt in Rust, welches dem Kompiler sagt welche Funktionalität ein Typ implementiert.

Kannst du dich noch an das Keyword impl erinnern, mit dem man Methoden zu einem Typ implementiert?

# #![allow(unused_variables)]
#fn main() {
struct Circle {
    x: f64,
    y: f64,
    radius: f64,
}

impl Circle {
    fn area(&self) -> f64 {
        std::f64::consts::PI * (self.radius * self.radius)
    }
}

#}

Traits sind ähnlich, nur dass wir hier nur die Signaturen der Methoden angeben und dann erst später implementieren:

# #![allow(unused_variables)]
#fn main() {
struct Circle {
    x: f64,
    y: f64,
    radius: f64,
}

trait HasArea {
    fn area(&self) -> f64;
}

impl HasArea for Circle {
    fn area(&self) -> f64 {
        std::f64::consts::PI * (self.radius * self.radius)
    }
}

#}

Wie man hier erkennt, sieht der trait Block fast genau so aus wie der impl Block, aber wir definieren den Körper der Funktionen nicht, nur deren Signatur. Wenn wir dann mit impl einen Trait implementieren schreiben wir imple Trait for Item anstatt nur impl Item.

Trait-Schranken für generische Funktionen

Traits sind sehr nützlich, denn sie erlauben es uns bestimmte Zusagen über das Verhalten von Typen zu machen. Generische Funktionen können somit Voraussetzungen für Typen die sie annehmen einfordern. Nehmen wir mal folgendes Beispiel an:

fn print_area<T>(shape: T) {
    println!("This shape has an area of {}", shape.area());
}

Rust beschwert sich jetzt:

error: no method named `area` found for type `T` in the current scope

Weil T jeder Typ sein könnte, können wir nicht sicher sein dass area auch wirklich eine implementierte Methode ist. Aber wir können eine "Trait-Schranke" zu unserem Generischen T hinzufügen, um das sicher zu stellen:

# #![allow(unused_variables)]
#fn main() {
# trait HasArea {
#     fn area(&self) -> f64;
# }
fn print_area<T: HasArea>(shape: T) {
    println!("This shape has an area of {}", shape.area());
}

#}

Die Syntax <T: HasArea> bedeutet "jeder Typ der das Trait HasArea implementiert". Weil Traits Funktionssignaturen definieren können wir sicher sein, dass jeder Typ der HasArea implementiert auch die Methode .area() haben wird.

Hier ist ein erweitertes Beispiel wie das geht:

trait HasArea {
    fn area(&self) -> f64;
}

struct Circle {
    x: f64,
    y: f64,
    radius: f64,
}

impl HasArea for Circle {
    fn area(&self) -> f64 {
        std::f64::consts::PI * (self.radius * self.radius)
    }
}

struct Square {
    x: f64,
    y: f64,
    side: f64,
}

impl HasArea for Square {
    fn area(&self) -> f64 {
        self.side * self.side
    }
}

fn print_area<T: HasArea>(shape: T) {
    println!("This shape has an area of {}", shape.area());
}

fn main() {
    let c = Circle {
        x: 0.0f64,
        y: 0.0f64,
        radius: 1.0f64,
    };

    let s = Square {
        x: 0.0f64,
        y: 0.0f64,
        side: 1.0f64,
    };

    print_area(c);
    print_area(s);
}

Das gibt aus

This shape has an area of 3.141593
This shape has an area of 1

Wie du siehst ist print_area jetzt generisch, aber stellt außerdem Sicher, dass es die korrekten Typen annimmt. Wenn wir falsche Typen übergeben:

print_area(5);

Bekommen wir einen Kompilerfehler:

error: the trait `HasArea` is not implemented for the type `_` [E0277]

Trait-Schranken für generische Structs

Deine generischen Structs können auch von Trait-Schranken profitieren. Alles was du machen musst ist die Schranke an deinen Typparameter anhängen. Hier ist ein neues Rectangle<T> und seine Methode is_square():

struct Rectangle<T> {
    x: T,
    y: T,
    width: T,
    height: T,
}

impl<T: PartialEq> Rectangle<T> {
    fn is_square(&self) -> bool {
        self.width == self.height
    }
}

fn main() {
    let mut r = Rectangle {
        x: 0,
        y: 0,
        width: 47,
        height: 47,
    };

    assert!(r.is_square());

    r.height = 42;
    assert!(!r.is_square());
}

is_square() muss checken das die Seiten gleich sind, also müssen die Seiten einen Typen haben der core::cmp::PartialEq implementiert:

impl<T: PartialEq> Rectangle<T> { ... }

Hier haben wir also ein Struct Rectangle definiert, das alle Typen als Höhe und Breite akzeptiert die sich auf Gleichheit vergleichen lassen. Geht das auch mit HasArea Structs, wie Square und Circle? Ja, aber sie benötigen Multiplikation, dafür müssen wir wissen wie man mittels Operatoren-Traits Operatoren überlädt.

Regeln für Trait Implementierung

Bis lang haben wir nur Traits für Structs implementiert, aber das geht auch für andere Typen. Theoretisch könnten wir auch HasArea für i32 implementieren:

# #![allow(unused_variables)]
#fn main() {
trait HasArea {
    fn area(&self) -> f64;
}

impl HasArea for i32 {
    fn area(&self) -> f64 {
        println!("this is silly");

        *self as f64
    }
}

5.area();

#}

Es wird allerdings allgemein als schlechter Stil angesehen für primitive Typen solche Methoden zu implementieren, auch wenn es prinzipiell möglich ist.

Es gibt allerdings zwei Einschränkungen was die Implementierung von Traits angeht. Die erste ist, dass Traits nur gelten, wenn sie im aktuellen Geltungsbereich sichtbar sind. An einem Beispiel: die Standardbibliothek enthält das Trait Write, welches extra Funktionalität zu File hinzufügt. Standardmäßig haben Files diese Methoden aber nicht:

let mut f = std::fs::File::open("foo.txt").ok().expect("Couldn’t open foo.txt");
let buf = b"whatever"; // byte string literal. buf: &[u8; 8]
let result = f.write(buf);
# result.unwrap(); // ignore the error

Hier kommt folgender Fehler:

error: type `std::fs::File` does not implement any method in scope named `write`
let result = f.write(buf);
               ^~~~~~~~~~

Wir müssen also mittels use das Trait Write einbinden:

use std::io::Write;

let mut f = std::fs::File::open("foo.txt").ok().expect("Couldn’t open foo.txt");
let buf = b"whatever";
let result = f.write(buf);
# result.unwrap(); // ignore the error

Jetzt kompiliert es ohne Fehler.

Das heißt, dass selbst wenn jemand etwas "so schlimmes" macht wie Methoden zu i32 hinzufügen, dann hat das nicht zwangsläufig Auswirkungen auf andere.

Eine weitere Einschränkungen ist, dass entweder der Trait oder der Typ für den du den Trait mit impl implementierst, Mindestens eins von beiden, von dir stammen muss. Es ist nicht erlaubt externe Traits für externe Typen zu implementieren.

Wir könnten also HasArea für i32 implementieren, da HasArea von uns stammt. Aber wenn wir versuchen würden ToString, einen Traits aus der Rust Standardbibliothek, für i32 zu implementieren, würde uns rustc das nicht erlauben.

Eine Sache noch über Traits: generische Funktionen mit Trait-Schranken müssen "monomorphization" (mono: eine, morph: Form )verwenden, also statisch dispatchen. Was heißt das? Das erfährst du im Kapitel zu [Trait Objekten](Trait Objekte.html).

Mehrere Trait-Schranken

Du weißt jetzt, dass man generische Typparameter mit Traits beschränken kann:

# #![allow(unused_variables)]
#fn main() {
fn foo<T: Clone>(x: T) {
    x.clone();
}

#}

Wenn du mehr als eine Beschränkung brachst nutze +:

# #![allow(unused_variables)]
#fn main() {
use std::fmt::Debug;

fn foo<T: Clone + Debug>(x: T) {
    x.clone();
    println!("{:?}", x);
}

#}

T muss nun sowohl Clone, als auch Debug implementieren.

Das where Keyword

Funktionen mit nur wenigen generischen Typen und nur wenigen Traits geht noch einigermaßen, aber sobald die Anzahl wächst, wird die Syntax zunehmend seltsamer:

# #![allow(unused_variables)]
#fn main() {
fn foo<T: Clone, K: Clone + Debug>(x: T, y: K) {
    x.clone();
    y.clone();
    println!("{:?}", y);
}

#}

Der Name der Funktion ist ganz links und die Parameter die sie annimmt ist ganz ganz rechts. Die Schranken sind hier etwas störend.

Rust hat dafür eine syntaktische Lösung: where:

use std::fmt::Debug;

fn foo<T: Clone, K: Clone + Debug>(x: T, y: K) {
    x.clone();
    y.clone();
    println!("{:?}", y);
}

fn bar<T, K>(x: T, y: K) where T: Clone, K: Clone + Debug {
    x.clone();
    y.clone();
    println!("{:?}", y);
}

fn main() {
    foo("Hello", "world");
    bar("Hello", "world");
}

foo() benutzt die erste Syntax und bar() benutzt where. Alles was du machen musst ist die Schranken an den Parametern weglassen und dann ein where nach der Parameterliste anfügen. Bei längeren Listen kannst du auch Leerzeichen benutzen:

# #![allow(unused_variables)]
#fn main() {
use std::fmt::Debug;

fn bar<T, K>(x: T, y: K)
    where T: Clone,
          K: Clone + Debug {

    x.clone();
    y.clone();
    println!("{:?}", y);
}

#}

Das ist eine relative flexible Methode um komplexe Situationen übersichtlicher zu machen. Davon abgesehen ist where aber auch mächtiger also die einfachere Syntax:

# #![allow(unused_variables)]
#fn main() {
trait ConvertTo<Output> {
    fn convert(&self) -> Output;
}

impl ConvertTo<i64> for i32 {
    fn convert(&self) -> i64 { *self as i64 }
}

// can be called with T == i32
fn normal<T: ConvertTo<i64>>(x: &T) -> i64 {
    x.convert()
}

// can be called with T == i64
fn inverse<T>() -> T
        // this is using ConvertTo as if it were "ConvertTo<i64>"
        where i32: ConvertTo<T> {
    42.convert()
}

#}

Das hier verdeutlicht das zusätzliche Feature von where: es erlaubt Schranken, bei denen die linke Seite ein beliebiger Typ ist (z.b. i32), nicht einfach ein Typparameter wie T.

Default Methoden

Wenn du bereits weißt wie eine typische Implementation einer Methode auszusehen hat, kannst du die konkrete Implementation schon vorgeben:

# #![allow(unused_variables)]
#fn main() {
trait Foo {
    fn is_valid(&self) -> bool;

    fn is_invalid(&self) -> bool { !self.is_valid() }
}

#}

Typen die Foo implementieren, müssen is_valid() implementieren, aber nicht is_invalid(). Hier wird das Standardverhalten verwendet. Es lässt sich allerdings trotzdem noch überschreiben:

# #![allow(unused_variables)]
#fn main() {
# trait Foo {
#     fn is_valid(&self) -> bool;
#
#     fn is_invalid(&self) -> bool { !self.is_valid() }
# }
struct UseDefault;

impl Foo for UseDefault {
    fn is_valid(&self) -> bool {
        println!("Called UseDefault.is_valid.");
        true
    }
}

struct OverrideDefault;

impl Foo for OverrideDefault {
    fn is_valid(&self) -> bool {
        println!("Called OverrideDefault.is_valid.");
        true
    }

    fn is_invalid(&self) -> bool {
        println!("Called OverrideDefault.is_invalid!");
        true // this implementation is a self-contradiction!
    }
}

let default = UseDefault;
assert!(!default.is_invalid()); // prints "Called UseDefault.is_valid."

let over = OverrideDefault;
assert!(over.is_invalid()); // prints "Called OverrideDefault.is_invalid!"

#}

Vererbung

Manchmal setzt die Implementierung eines Traits die Implementierung eines anderen voraus:

# #![allow(unused_variables)]
#fn main() {
trait Foo {
    fn foo(&self);
}

trait FooBar : Foo {
    fn foobar(&self);
}

#}

Typen die FooBar implementieren müssen also auch Foo implementieren:

# #![allow(unused_variables)]
#fn main() {
# trait Foo {
#     fn foo(&self);
# }
# trait FooBar : Foo {
#     fn foobar(&self);
# }
struct Baz;

impl Foo for Baz {
    fn foo(&self) { println!("foo"); }
}

impl FooBar for Baz {
    fn foobar(&self) { println!("foobar"); }
}

#}

Aber wenn wir das mal vergessen, wird der Compiler uns das schon vorwerfen:

error: the trait `main::Foo` is not implemented for the type `main::Baz` [E0277]

Ableiten

Das Implementieren von Traits wie Debug und Default kann mitunter recht eintönig und nervig werden. Aus diesem Grund lässt uns Rust mittels Attributen bestimmte Traits automatisch zu implementieren:

#[derive(Debug)]
struct Foo;

fn main() {
    println!("{:?}", Foo);
}

Das ist jedoch momentan auf bestimmte Traits beschränkt:

  • Clone
  • Copy
  • Debug
  • Default
  • Eq
  • Hash
  • Ord
  • PartialEq
  • PartialOrd