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

Die zweite Ausgabe der neuen Rust-Kolumne von Stefan Baumgartner und Rainer Stropek stellt eine Stärke von Rust vor: Traits sind mehr als klassische Interfaces.

In Pocket speichern vorlesen Druckansicht
Ferris Talk – Neuigkeiten zu Rust. Eine Heise-Kolumne von Rainer Stropek und Stefan Baumgartner für Rustaceans
Lesezeit: 12 Min.
Von
  • Stefan Baumgartner
Inhaltsverzeichnis

In unserer ersten Ausgabe haben wir ausgiebig über Iteratoren und ihre Finessen in Rust gesprochen. Dabei haben wir ein kleines Detail vorausgesetzt, das bei Rust-Neueinsteigerinnen und -einsteigern noch Fragen aufwerfen mag: Traits. Dieses fundamentale Sprachkonstrukt wollen wir in der aktuellen Kolumne näher beleuchten.

Ferris Talks – die Kolumne für Rustaceans
Ferris Talk – Neuigkeiten zu Rust. Die neue Kolumne für Rustaceans

Vielfach heißt es, dass sich die Welt im Bereich der Softwareentwicklung schneller ändert als in anderen Bereichen. Auch wenn das stimmen mag, sind fundamentale Umbrüche mit langfristiger Wirkung auch in der Informatik selten. Nicht jedes JavaScript-Framework stellt die Art, Software zu entwickeln, komplett auf den Kopf. Rust aber gehört zu den seltenen Änderungen, die nachhaltig, langfristig und unserer Einschätzung nach positiv wirken werden.

In dieser Kolumne möchten die beiden Rust-Experten Rainer Stropek und Stefan Baumgartner abwechselnd regelmäßig über Neuerungen und Hintergründe im Bereich Rust berichten. Sie soll Teams, die Rust schon verwenden, helfen, auf dem Laufenden zu bleiben. Einsteigerinnen und Einsteiger sollen durch die Kolumne tiefere Einblicke in die Funktionsweise von Rust erhalten.

Kolumnenautoren vom Rust Meetup Linz

Der Kolumnen-Titel nimmt Bezug auf Ferris, das krabbenförmige inoffizielle Maskottchen der Rust-Gemeinde. Die Ferris Talks schreiben Stropek und Baumgartner ab sofort monatlich und im Wechsel – mehr zu den Autoren steht am Ende des Artikels. Die beiden Kolumnisten sind überzeugte Rustaceans und organisieren das Rust Meetup Linz, die Fachtreffen werden in Videoform festgehalten und sind auf der Rust-Linz-Playliste bei YouTube abrufbar. Wer die beiden beruflich treffen möchte, kann sie unter anderem als Vortragende und als Workshop-Trainer bei der Rust-Konferenz 2021 von Heise erleben.

Aaron Turon, einer der langjährigen Rust- und Mozilla-Veteranen, hat vor ein paar Jahren Traits als eine der tragenden Säulen im Sprachdesign von Rust beschrieben. Tatsächlich eröffnen sie eine erstaunliche Flexibilität, die man so eigentlich nur von dynamischen Hochsprachen gewohnt ist. Dabei folgen sie immer dem Rust-Mantra, dass Flexibilität keine Laufzeit kostet.

Grundsätzlich sind die Traits Rusts Version von Interfaces, die man auch aus anderen Programmiersprachen kennt. Sie bieten eine gemeinsame Beschreibung, mit der sich das Verhalten über Typen hinweg definieren lässt. Auch in der grundlegenden Anwendung unterscheiden sie sich kaum von den klassischen Schnittstellenbeschreibungen.

Das folgende Beispiel zeigt zur Veranschaulichung zwei eigens festgelegte Typen für eine imaginäre Berechnungssoftware. Ein Project hat einen Fixpreis, MaintenanceHours hingegen werden nach Stunden und Rate bezahlt.

struct Project { 
    price: f32
}

struct MaintenanceHours {
    hours: f32,
    rate: f32
}

Ein gemeinsamer Trait Billable soll dafür sorgen, dass sich eine Funktion zur Berechnung des Preises definieren lässt, die über alle Typen hinweg implementierbar ist.

trait Billable {
    fn bill(&self) -> f32;
}

Die Schnittstelle verrät, dass sie eine Methode bill implementiert, die auf der Struktur liegt. Das steckt in der Referenz auf self, mit der sich die Funktion wie bei regulären Objekten in klassischen objektorientierten Programmiersprachen verwenden lässt. Die Strukturen liegen großteils am Stack, die dazugehörigen Implementierungsblöcke werden zur Übersetzungszeit entweder ausgewertet oder in leichtgewichtigen Maschinencode übersetzt.

Implementierungen für Billable können wie folgt aussehen:

impl Billable for MaintenanceHours {
    fn bill(&self) -> f32 {
        self.hours * self.rate
    }
}

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

Traits erlauben zudem, Standardimplementierungen vorzugeben, die sich bei Bedarf durch konkrete Werte austauschen lassen können.

trait Billable {
    fn bill(&self) -> f32 {
        0
    }
}

Etwas Ähnliches tauchte schon in der ersten Ausgabe der Ferris Talks in Form des Iterator Trait auf. Bei ihm muss man nur eine Methode implementieren, die angibt, wie man zum nächsten Element kommt. Der Rest der klassischen Iterator-Funktionsweise baut auf next auf und kann deswegen standardmäßig vorimplementiert werden.

Bis zu diesem Punkt dürfte alles bekannt vorkommen.

betterCode() Rust – Dein Einstieg und Deep Dive in Rust
Heise-Konferenz zu Rust, Einstieg und Deep Dive, 13. Oktober 2021 online, mit Rainer Stropek und Stefan Baumgartner

Heise richtet am 13. Oktober 2021 eine Online-Konferenz zu Rust für Einsteiger und Experten aus. Die beiden Ferris-Talk-Kolumnisten sind dort mit Vorträgen und Workshops präsent:

Rainer Stropek erklärt das Speichermanagement in Rust und erläutert die Rust-Konzepte Ownership, References, Borrowing und Lifetimes. Er zeigt die Konzepte anhand praktischer Code-Beispiele. Wer teilnimmt, erlangt ein Problembewusstsein der Sicherheitsrelevanz von Speicherverwaltung, baut Verständnis auf für die Rust-Kernkonzepte und erkennt Unterschiede zwischen Rust und anderen gängigen Programmiersprachen.

Stefan Baumgartner widmet sich Serverless Rust und führt vor, wie man Serverless-Workloads mit Rust ausführt. Wer teilnimmt, gewinnt ein tieferes Verständnis von AWS Lambda und Azure Functions, lernt die passenden Rust-Crates für Serverless-Workloads und begreift den unmittelbaren Nutzen von Rust dafür.

Flankierende Workshops von Stropek & Baumgartner (9-17 Uhr)

Auch bei der statischen Bindung sind vertraute Muster erkennbar. Die Funktionen müssen erfahren, dass sie mit Billable umgehen können. Das geschieht über sogenannte Trait Bounds, also Abgrenzungen, die mitteilen, welche Eigenschaften innerhalb dieser Funktion nötig sind.

fn print_bill(billable: &impl Billable) {
    println!("{}", billable.bill());
}

Die Funktion ist zu lesen als: Gib mir eine Referenz auf etwas, das Billable implementiert! Kurz noch eine Bemerkung zum Schlüsselwort impl: Das ist ein gutes Beispiel für die schöne Art von Rust, die es erlaubt, Vorhaben explizit anzugeben. Es reicht nicht, den Interface-Namen in die Typ-Annotation zu geben, wie man es von anderen Programmiersprachen gewohnt ist. Es gilt explizit zu sagen, dass man hier einen Trait erwartet.

Über solche expliziten Angaben werden Entwicklerinnen und Entwickler in Rust oft genug stolpern. Sei es bei der Fehlerbehandlung, bei der Typkonvertierung oder bei einem überraschend gängigen Kopiervorgang im Speicher.

Eine alternative Schreibweise für Trait Bounds geht über die Generic-Syntax. Wer oft in TypeScript programmiert, erkennt sicherlich Ähnlichkeiten in der Wortwahl (Bounds) und in der Schreibweise:

fn print_bill<T: Billable>(billable: T) {
    println!("{}", billable.bill());
}

Die Schreibweise ermöglicht es auch, mehrere Trait Bounds zu definieren, falls man doch Funktionalität braucht, die sich nicht in einem Trait beschreiben lässt. Die neue print_bill benötigt an dieser Stelle Methoden aus dem Billable Trait, sowie die Standardausgabe über Display. Ein implementierter Display Trait macht die Strukturen mit dem println!-Makro kompatibel:

fn print_bill<T: Billable + std::fmt::Display>(billable: &T) {
    println!("{}", billable.bill());
    println!("{}", billable); // Dank des Display Traits
}

Doch wie lässt sich der Display Trait implementieren?

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.

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.

Wo Traits sich auf den ersten Blick wie Interfaces aus klassischen, objektorientierten Programmiersprachen anfühlen, zeigen sie erst bei genauer Betrachtung ihre unglaubliche Stärke. Sie dienen als direkte Verbindung zwischen der Standardbibliothek von Rust und einer idiomatischen Schreibweise, ohne in die Untiefen von C++ Operator Overloading zu rutschen.

Außerdem ermöglichen sie die Kompatibilität fremder Typen zur eigenen Software, und sie ermöglichen es, die Standardbibliothek mit neuen Fähigkeiten zu erweitern. Wer zum Beispiel seine Iteratoren einem Level-up unterziehen will, soll sich mal die an Python angelehnten itertools ansehen.

Letztlich erfüllen sie vor allem ihr Versprechen, Zero-Cost Abstractions für eine produktive und ergonomische Sprache zu sein.


Ferris Talk – Neuigkeiten zu Rust. Die Kolumnisten:
Stefan Baumgartner, Dynatrace.at, Rust Meetup Linz, Autor der Ferris Talks, der Kolumne über die Programmiersprache Rust bei Heise Developer

Stefan Baumgartner, Autor von Ferris Talk #2

lebt und arbeitet als Software-Architekt und Entwickler bei Dynatrace im österreichischen Linz mit Schwerpunkt auf Webentwicklung, Serverless und Cloud-basierte Architekturen.

Für den Smashing Magazine Verlag veröffentlichte er 2020 mit “TypeScript in 50 Lessons” sein zweites Buch, seine Onlinepräsenz fettblog.eu enthält Artikel, Tutorials und Guides zum Thema TypeScript, Rust, React, und Software Engineering im Allgemeinen.

Stefan organisiert Meetups und Konferenzen, wie Stahlstadt.js, die DevOne, ScriptConf, Rust Meetup Linz, und das legendäre Technologieplauscherl. Außerdem ist er regelmäßig Gastgeber im Working Draft, dem deutschsprachigen Podcast über Webtechnologien. Wenn noch ein wenig Freizeit bleibt, genießt er italienische Pasta, belgisches Bier und britischen Rock.

Fellow Rustacean und Co-Kolumnist
Rainer Stropek, timecockpit.com, Rust Meetup Linz, Autor der Ferris Talks, der Kolumne über die Programmiersprache Rust bei Heise Developer

Rainer Stropek

ist Softwareentwickler, Trainer, Autor und Vortragender im Microsoft-Umfeld und seit über 25 Jahren als Unternehmer in der IT-Industrie tätig.

Er gründete und führte in dieser Zeit mehrere IT-Dienstleistungsunternehmen und entwickelt neben seiner Tätigkeit als Trainer und Berater in seiner Firma software architects mit seinem Team die preisgekrönte Software time cockpit.

Rainer hat Abschlüsse der höheren technischen Schule für Informatik Leonding (AT) sowie der University of Derby (UK). Er ist Autor mehrerer Fachbücher und Artikel in Magazinen im Umfeld von Microsoft .NET und C#, Azure, Go und Rust. Seine technischen Schwerpunkte sind Cloud Computing, die Entwicklung verteilter Systeme sowie Datenbanksysteme.

Rainer tritt regelmäßig als Speaker und Trainer auf namhaften Konferenzen in Europa und den USA auf. 2010 wurde Rainer von Microsoft zu einem der ersten MVPs (Most Valuable Professionals) für die Azure-Plattform ernannt. Seit 2015 ist er Microsoft Regional Director. 2016 hat er zudem den MVP Award für Visual Studio und Developer Technologies erhalten.

(sih)