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

Seite 2: Die Regel der Kohärenz

Inhaltsverzeichnis

Spannend wird es, wenn man nicht nur das selbst definierte Verhalten auf den eigenen Strukturen implementiert, sondern auch die Traits anderer Bibliotheken. Nur so lassen sich die eigenen Abstraktionen und Datenstrukturen mit der großen Rust-Welt kompatibel gestalten.

use std::fmt; 

impl fmt::Display for MaintenanceHours {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{} hours à {} USD", self.hours, self.rate)
    }
}

Hier gilt es, Display aus der std::fmt-Bibliothek für die Wartungsstunden zu implementieren. Was die Formatierer machen und warum man ein Result erwartet, ist an der Stelle nicht so wichtig. Spannend ist, dass sich hier die eigenen Daten in eine Zeichenkette bringen lassen, die Rust anschließend für die Standardausgabe über println! verwenden kann.

Noch spannender ist es, wenn man den Spieß umdreht: Es ist nicht nur möglich, fremde Traits für eigene Typen zu implementieren, sondern auch, die eigenen Traits für fremde Typen einzusetzen. Und das funktioniert nicht nur für komplexe Datentypen, sondern sogar für die grundlegenden Primitives, die jede Programmiersprache besitzt.

Angenommen, eine beliebige Eingabe gibt den aktuellen Preis einer verrechenbaren Tätigkeit als Gleitkommazahl an. Im Beispiel soll der Preis trotzdem kompatibel mit der vorher festgelegten Billable-Fertigkeit sein. Nichts einfacher als das: Der Trait lässt sich für f32 implementieren.

impl Billable for f32 {
    fn bill(&self) -> f32 {
        self.clone()
    }
}

Damit eröffnen sich ungeahnte Möglichkeiten: Jede Datenstruktur, egal aus welchem Crate sie stammt, lässt sich auf diese Weise anpassen, damit sie mit der eigenen Software kompatibel ist.

Um sicherzustellen, dass sich die Trait-Implementierungen nicht in die Quere kommen, gibt es laut dem Rust-Veteran Niko Matsakis die Kohärenz-Regel: Man besitzt entweder den Trait oder den Typ. Das heißt:

  • Man kann die eigenen Traits für die eigenen Typen implementieren
  • Man kann fremde Traits für eigene Typen implementieren
  • Man kann eigene Traits für fremde Typen implementieren

Es ist allerdings nicht möglich, fremde Traits für fremde Typen zu implementieren. Eines der Elemente sollte schon euch gehören.

Empfohlener redaktioneller Inhalt

Mit Ihrer Zustimmmung wird hier ein externes YouTube-Video (Google Ireland Limited) geladen.

Ich bin damit einverstanden, dass mir externe Inhalte angezeigt werden. Damit können personenbezogene Daten an Drittplattformen (Google Ireland Limited) übermittelt werden. Mehr dazu in unserer Datenschutzerklärung.

Etwas, was wir nicht oft genug erwähnen können, ist, dass bislang alle Auflösungen von Fertigkeiten, Methoden, Datenstrukturen und Funktionen zur Übersetzungszeit stattfinden.

Nach dem Prinzip der Zero-Cost Abstractions verlagert Rust alle Komplexität in Richtung des Compilers, um dafür performante Software zu gewährleisten. Daraus ergeben sich allerdings ein paar Umstände und Voraussetzungen.

  1. Die Übersetzungszeit ist entsprechend lang und teilweise deutlich länger als bei anderen Programmiersprachen.
  2. Der Compiler muss zur Übersetzungszeit schon eine Menge wissen.

Nun gibt es in der echten Welt allerdings Situationen, die unvorhersehbar sind, und in denen man erst zur Laufzeit den wirklichen Tatbestand kennt.

Schauen wir uns das folgende Beispiel an. Wir haben einen Greeter Trait, der auf jedem Typ implementiert werden kann von dem wir wollen, dass er uns begrüßt.

trait Greeter {
    fn greet(&self) -> String;
}

struct Person {
    name: String,
}

impl Greeter for Person {
    fn greet(&self) -> String {
        format!("Hey {}!", self.name)
    }
}

struct Dog;

impl Greeter for Dog {
    fn greet(&self) -> String {
        "Who is a good boy?".to_string()
    }
}

Um eine Funktion zu schreiben, die auf Basis einer willkürlich ausgewählten Zahl entweder einen Menschen oder einen Hund zurückgibt, könnte man folgende Implementierung im Sinn haben:

// Das hier kompiliert nicht!
fn get_a_greeter(val: u8) -> impl Greeter {
    if val < 5 {
        Person {
            name: "unknown".to_string(),
        }
    } else {
        Dog {}
    }
}

Hier wirft uns Rust jedoch ein paar Fehlermeldungen entgegen. Immerhin, freundlicherweise auch mit guten Hinweisen versehen, was wir dagegen tun können. Der Compiler stößt auf das Problem, dass zur Übersetzungszeit nicht eindeutig klar ist, welches Speicherlayout vom Rückgabewert der Funktion get_a_greeter zu erwarten ist.

Das ist in Programmiersprachen, die zur Laufzeit den Speicher verwalten, kein Problem (in JavaScript könnte hier zum Beispiel alles zurückkommen). Rust, dessen Ziel es ist, nicht nur speicheroptimiert, sondern vor allem auch speichersicher zu sein, verbietet das Vorgehen und stellt Entwicklerinnen und Entwickler vor ungeahnte Herausforderungen.

Abhilfe schaffen die Trait Objects. Sie werden mit dem Schlüsselwort dyn explizit gekennzeichnet. Die Botschaft an Rust lautet, dass das genaue Speicher-Layout erst zur Laufzeit bekannt ist, und Rust doch bitte den Trait und dessen Methoden weiter durchreichen soll. Ein Trait-Object reicht allein nicht aus. Wie bei allen Elementen, deren genaue Größe erst zur Laufzeit bekannt wird, gilt es, mit einer Box einen Zeiger auf den Heap zu verwenden, um die Datenstruktur durchreichen zu können.

fn get_a_greeter(val: u8) -> Box<dyn Greeter> {
    if val < 5 {
        Box::new(Person {
            name: "unknown".to_string(),
        })
    } else {
        Box::new(Dog {})
    }
}

Während eine Box noch keinen Mehraufwand zur Laufzeit benötigt (vom Speicher abgesehen), legen Trait Objects alles an, was für eine dynamische Bindung notwendig ist (s. Abb. 1).

Trait Objects legen alles an, was für eine dynamische Bindung notwendig ist (Abb. 1).

(Bild: Stefan Baumgartner, Rust Error Handling)

Als Neuling findet man Trait Objects vor allem bei der Fehlerbehandlung wieder.