Ferris Talk #2: Abstraktionen ohne Mehraufwand – Traits in Rust

Seite 3: Mehr als Abstraktion

Inhaltsverzeichnis

Traits stellen eine fundamentale Säule im Sprachdesign von Rust dar. Nicht nur, weil sich damit fast jegliches Verhalten von Eigen- und Fremdcode ausdrücken lässt, sondern vor allem, weil Entwicklerinnen und Entwickler damit auch idiomatisch, also mit den Spracheigenheiten von Rust, arbeiten können.

Zum Beispiel lassen sich die eigenen Datenstrukturen mit den Operatoren für Addition kompatibel machen. Dafür genügt es, den Add Trait aus der std::ops-Bibliothek zu implementieren.

impl std::ops::Add for Project {
    type Output = Self;

    fn add(self, other: Self) -> Self {
        Project {
            price: self.price + other.price
        }
    }
}

let x = Project {price: 1000.0};
let y = Project {price: 1000.0};
print_bill(x + y);

Warum allerdings nur die gleichen Typen kompatibel machen, wenn sich der Ansatz auch für Basisdatentypen wie Gleitkommazahlen eignet? Auf die gleiche Weise lässt sich jede beliebige Zahl einfach dazurechnen, und das Ergebnis ist ein neues Projekt mit dem aktualisierten Preis.

impl std::ops::Add<f32> for Project {
    type Output = Self;

    fn add(self, other: f32) -> Self {
        Project {
            price: self.price + other
        }
    }
}

let z = Project {price: 2000.0};
print_bill(z + 1000.0);

Hier ist der Moment, innezuhalten und die Struktur für Wartungsarbeiten wieder in den Blick zu fassen. Billable ist bereits für f32 implementiert. Es wäre deutlich angenehmer, ließe sich einfach jegliche Struktur, die Billable implementiert, automatisch zu Project addieren. Mit einem Trait Bound an der richtigen Stelle klappt das sogar.

impl<T: Billable> std::ops::Add<T> for Project {
    type Output = Self;

    fn add(self, other: T) -> Self {
        Project {
            price: self.price + other.bill()
        }
    }
}

Damit wäre der Großteil der grundlegenden Funktionsweise mit wenigen Zeilen Code vordefiniert, und künftige Billables können davon profitieren. Tatsächlich macht die Standardbibliothek von Rust auch stark davon Gebrauch. Eines meiner Lieblings-Beispiele sind die Typkonvertierungs-Traits From und Into.

Beide dienen zur Typkonvertierung, sind aber in der Anwendung ein wenig unterschiedlich. So lassen sich mit Hilfe des From-Trait Gleitkommazahlen in Projekte verwandeln:

impl From<f32> for Project {
    fn from(price: f32) -> Self {
        Self { price }
    }
}

let z = Project::from(3000.0);

Eine ähnliche Operation gibt es auch mit dem Into-Trait, die wohl in vielen Fällen ergonomischer wirkt.

let z: Project = 3000.0.into();

Welche Variante sich eher anbietet, kommt auf den Anwendungsfall an. Die zugrundeliegenden Operationen sind beinahe identisch. Das ist auch ein Grund dafür, dass die Rust-Standardbibliothek Into für alle Datentypen implementiert, die From implementieren. Entwicklerinnen und Entwickler müssen sich nur um From kümmern, der Rest kommt gratis mit – in einer Trait-Implementierung, die in ein paar wenige Zeilen passt, erstaunlich lesbar ist und bei einigen nun wahrscheinlich für einen Aha-Moment sorgt.

impl<T, U> Into<U> for T
where
    U: From<T>,
{
    fn into(self) -> U {
        U::from(self)
    }
}

Wer sich für solche Konvertierungen interessiert, dem sei auf GitHub ein Blick in die convert-Bibliothek angeraten. Das Modul steckt voller Komfort-Funktionalität in diesem Stil.

Ähnlich verhält es sich mit der to_string-Methode. Tatsächlich gibt es in Rust eine Möglichkeit, mit der sich jede Datenstruktur in einen String verwandeln lässt. Anders als in anderen Programmiersprachen spielt sie jedoch eine untergeordnete Rolle. Sie wird standardmäßig für alle Strukturen implementiert, die den Display Trait einbinden. Warum auch nicht? Die Tätigkeit ist sehr ähnlich: Beide versuchen die Datenstruktur in eine Zeichenkette zu verwandeln.

impl<T: fmt::Display + ?Sized> ToString for T {
    default fn to_string(&self) -> String {
        let mut buf = String::new();
        let mut formatter = core::fmt::Formatter::new(&mut buf);
        fmt::Display::fmt(self, &mut formatter)
            .expect("a Display implementation returned an error unexpectedly");
        buf
    }
}

Die hausgemachten Traits von Rust unterscheiden sich nicht in der Granularität. Manche Traits fokussieren sich auf eine konkrete Tätigkeit, ähnlich wie bei den Interfaces in Go. Andere bieten eine breite Palette an Standardfunktionen, die man mit dem Nachbessern einiger weniger Methoden voll ausschöpfen kann – unter anderem bei den eingangs erwähnten Iteratoren. Das Design des Trait passt sich dem Anwendungsfall an und ist weniger dogmatisch, als man es von anderen Programmiersprachen erwartet.