Ferris Talk #7: Vom Ungetüm zur Goldrose – eine kleine Rust-Refactoring-Story

Die moderne Syntax von Rust wappnet nicht vor historisch gewachsenem Programmcode, der die Lesbarkeit zu verschlingen droht. Ein Refactoring schafft Abhilfe.

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

Die vergoldete Rose oder Gilded Rose ist eine Programmierübung (auch Kata genannt), die vor rund zehn Jahren entwickelt wurde und gerade beim Erlernen von Refactorings und objektorientierten Prinzipien beliebt ist. Im Original von Terry Hughes entwickelt, hat sie sich schnell verselbstständigt, und unter anderem griff die Softwareentwicklerin und technische Agile-Trainerin Emily Bache die Übung auf. Ihr GitHub-Repo bietet im Ordner "GildedRose Refactoring Kata" eine umfangreiche Sammlung an Beispielen in unterschiedlichen Programmiersprachen. Die Gilded Rose hielt auch Einzug in ihr Coder Dojo Handbook, viele Unkonferenzen und Workshops nutzen sie bis heute als Beispiel.

Ferris Talks – die Kolumne für Rustaceans

Mein erster Kontakt mit dem Gilded-Rose-Kata war vor etwa zwei Jahren, als ich auf den Vortrag "All the Little Things" der legendären Sandi Metz auf der RailsConf 2014 gestoßen bin. Ruby und objektorientierte Programmierung, beides könnte mir nicht ferner sein. Da aber Sandi Metz’ Vorträge meinem Programmiererinstinkt nahekommen, kann ich sie empfehlen.

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.

Das Kata ist auch für Rust interessant, weil Entwicklerinnen und Entwickler mit der Übung an einem Punkt ankommen, an dem sie zu Hausmitteln greifen müssen und nicht auf Klassen und Vererbung bauen können. Hier eröffnen sich zahlreiche Möglichkeiten, und jede kommt mit ihrem eigenen Trade-off.

Der Vollständigkeit halber sei gesagt, dass sich auch Nicolas Frankel in seinem Blog des Gilded-Rose-Beispiels in Rust annimmt und zu ähnlichen Ergebnissen kommt wie diese Kolumne. Das ist Zufall – liegt aber sicher daran, dass Gilded Rose ein ausgesprochen bekanntes Kata ist.

Der Kern der Gilded-Rose-Übung ist folgendes Ungetüm von Funktion.

pub fn update_quality(&mut self) {
    for item in &mut self.items {
        if item.name != "Aged Brie" && item.name != "Backstage passes to a TAFKAL80ETC concert"
        {
            if item.quality > 0 {
                if item.name != "Sulfuras, Hand of Ragnaros" {
                    item.quality = item.quality - 1;
                }
            }
        } else {
            if item.quality < 50 {
                item.quality = item.quality + 1;

                if item.name == "Backstage passes to a TAFKAL80ETC concert" {
                    if item.sell_in < 11 {
                        if item.quality < 50 {
                            item.quality = item.quality + 1;
                        }
                    }

                    if item.sell_in < 6 {
                        if item.quality < 50 {
                            item.quality = item.quality + 1;
                        }
                    }
                }
            }
        }

        if item.name != "Sulfuras, Hand of Ragnaros" {
            item.sell_in = item.sell_in - 1;
        }

        if item.sell_in < 0 {
            if item.name != "Aged Brie" {
                if item.name != "Backstage passes to a TAFKAL80ETC concert" {
                    if item.quality > 0 {
                        if item.name != "Sulfuras, Hand of Ragnaros" {
                            item.quality = item.quality - 1;
                        }
                    }
                } else {
                    item.quality = item.quality - item.quality;
                }
            } else {
                if item.quality < 50 {
                    item.quality = item.quality + 1;
                }
            }
        }
    }
}

Das Beispiel aktualisiert die Gegenstände eines Geschäftslokals in einem Rollenspiel. Manche altern, andere verlieren an Qualität. Es gibt allerdings auch Gegenstände, die mit der Zeit "besser werden". Der einschüchternde Block an if-else-Bedingungen soll diese Logik darstellen. Erschreckend, oder? Doch bevor die Qualität des Ausgangsszenarios der Leserschaft ein mitleidiges Lächeln entlockt, möchte ich meinen, dass jeder und jede irgendwann mal Programmcode wie diesen fabriziert hat. Das ist auch das Schöne an diesem Kata – sie zeigt historisch gewachsenen Code, der in einer ähnlichen Form auch schon über unsere eigenen Bildschirme gelaufen ist. Den gilt es nun zu bearbeiten.

Bei Refactoring-Übungen wird allerdings oft übersehen, ist dass es nicht nur um die Anwendung von Entwurfsmustern geht, sondern vor allem die Besonderheiten einer Programmiersprache sowohl in ihrer Struktur als auch in ihrer Syntax zu erschließen sind. Das ist gerade bei einer Sprache wie Rust wichtig, die von den ausgetretenen Pfaden der objektorientierten Programmierung abweicht und nicht die Strukturen bietet, um beispielsweise Polymorphismus im Stil von Sandi Metz umzusetzen. Dafür bietet Rust im Gegenzug Dinge, die keine andere Programmiersprache zu bieten hat.

Die Rede ist von idiomatischem Rust. Als idiomatisch gelten Syntax und Muster, die für eine Programmiersprache spezifisch sind und die deren Konventionen folgen. Mit idiomatischen Ausdrücken kommt man am einfachsten zum Ziel.

Mit idiomatischer Programmierung in Rust stecken wir uns die folgenden Ziele:

  1. Das Nutzen von Namen und Strukturen, die Rust-üblich sind und die sich "nach Rust anfühlen". Dazu gehören nicht nur die richtige Schreibweise und Einrückung, sondern auch Namenskonventionen, die durch die Standardbibliothek und das Ökosystem gegeben sind.
  2. Die Anwendung der Rust-eigenen Features wie Ausdrücke, match-Statements und der Einsatz der Standardbibliothek (wo es möglich ist).
  3. Das Schreiben von Programmcode, der einfach zu lesen und dem einfach zu folgen ist.
  4. Das Schreiben von Programmcode, darauf ausgelegt ist, mit anderen Teilen des Ökosystems zu funktionieren.
  5. Das Schreiben von Programmcode, der expressiv, vorhersehbar und flexibel ist.

Wer hier eine Überlappung mit den Zielen des Refactorings erkennt, liegt richtig. Auch dabei geht es darum, bestehenden Code so umstrukturieren, dass er eindeutiger die Intention und die Funktionsweise kommuniziert. Wo Refactoring der Vorgang ist, ist idiomatische Programmierung das Werkzeug.

In meinen Workshop-Folien zu "Idiomatic Rust" beschreibe ich ein Spektrum von Möglichkeiten, idiomatischen Programmcode zu schreiben. In diesem Spektrum kann man vier Pfeiler ausmachen:

    1. Syntax, Namenskonventionen, Semantik: Das umfasst die Anwendung von match-Ausdrücken, Enums, Ausdrücken statt Statements, richtigen Schleifen über Iteratoren und einiges mehr – kurzum: alles, wobei Clippy, das nach Karl Klammer aus Office 95 benannte Lint-Werkzeug, aufschreit und Alternativen anbietet. Clippy ist ein fantastisches Helferlein, um nicht nur klareren Code zu schreiben, sondern auch um mehr über die inneren Vorgänge der Standardbibliothek zu lernen.
    2. Structs, Enums und Typen: Mit Structs werden nicht nur primitive Datentypen kombiniert, um sie einfacher handzuhaben, man bekommt auch Typen, die sich mit Traits belegen lassen. Großartig sind Unit Structs (ohne Felder) oder Tuple Structs. Zum Beispiel enthält ein struct Kilometers(f64) nur ein Feld (den Kilometer-Wert als f64), aber durch die Erstellung eines eigenen Typs (Kilometers) lässt sich sicherstellen, dass die Operationen, die auf dem f64 Wert ausgeführt werden, der Domäne entsprechen.
    3. Traits und Konversionen: die Abstraktion wiederkehrender Muster in Traits und die Nutzung von Traits der Standardbibliothek. Letzteres ermöglicht Kompatibilität mit der Standardbibliothek und damit auch das Nutzen von Sprachfeatures wie zum Beispiel dem Fragezeichen-Operator bei der Fehlerbehandlung. In meinem Blog findet sich eine ausführliche Beschreibung, wie die Nutzung von Traits die idiomatische Fehlerbehandlung ermöglicht.
    4. Entwurfsmuster: Diese gibt es auch in Rust. Teilweise überschneiden sie sich mit den klassischen Entwurfsmustern der Gang of Four. Ferris Talk 1 hat die Iteratoren schon eingehend besprochen. Auch das Builder Pattern ist beliebt in der Welt von Rust. Da Rust einen anderen Zugang zu Objektorientierung hat, als man es von Sprachen wie Java oder C# gewohnt ist, lassen sich viele Muster nicht exakt umsetzen. Dafür wiederum gibt es andere, die stark auf dem Trait- und Typsystem beruhen.

Dieses Spektrum reicht von einfachen Änderungen auf der Syntaxebene bis hin zu teils ausgesprochen spezifischen Entwurfsmustern. Die Pfeiler bieten sich auch sehr gut für einzelne Schritte in einem Refactoring-Prozess an.

Der erste Schritt ist das Nutzen von Sprach-Features, die Rust mitbringt. Eine wichtige Aufgabe in der Gilded-Rose-Übung ist, das Gewirr an Bedingungen zu entzerren. Das gelingt nur, indem man sich die ursprüngliche Angabe (oder die hoffentlich zahlreich vorhandenen Tests) zu Gemüte führt und merkt, dass das wichtigste Entscheidungskriterium der Name des Gegenstands ist. Manche Gegenstände "altern besser als andere". Bei manchen tut sich wiederum gar nichts.

for item in &mut self.items {
    if item.name == "Aged Brie" {
        item.sell_in -= 1;
        if item.quality < 50 {
            item.quality += 1;
        }
    } else if item.name == "Backstage passes to a TAFKAL80ETC concert" {
        item.quality = match item.sell_in {
            1..=5 => item.quality + 3,
            6..=10 => item.quality + 2,
            0 => 0,
            _ => item.quality + 1,
        };
        item.sell_in -= 1;
    } else if item.name == "Sulfuras, Hand of Ragnaros" {
    } else {
        item.sell_in -= 1;
        item.quality -= if item.sell_in < 0 { 2 } else { 1 };
        if item.quality < 0 {
            item.quality = 0;
        }
    }
}

Dieses Codestück fällt deutlich kürzer aus und zeigt bereits ein paar Rust-Eigenheiten. Im Bereich der Backstage-Pässe wird die Qualität über einen match-Ausdruck bestimmt. Dieser nimmt die Verweildauer im Laden als Grundlage und ändert die Qualität entsprechend. Da Rust für den Typ i32 Abfragen auf Wertebereiche erlaubt, kann man diese Qualitätsänderung sehr übersichtlich in unterschiedliche Gruppen gliedern. Durch die Möglichkeit, match-Anweisungen als Ausdrücke zu verwenden, kommt es innerhalb von Rust wie im obigen Code-Beispiel automatisch zu einer Zuweisung der Auswertung des match-Ausdrucks. Das ist elegant und übersichtlich.

Wichtig ist der Fallback, der mit einem Unterstrich angegeben wird. Der Ausdruck stellt sicher, dass Unvorhergesehenes nicht unbehandelt bleibt. Der Rust-Compiler wirft Fehler, wenn nicht alle Möglichkeiten in Betracht gezogen wurden.

Für den Namen des Gegenstandes lässt sich auch eine ähnliche Abfrage durchführen. Man entfernt alle if/else-Blöcke und gruppiert nach Zeichenketten.

for item in &mut self.items {
    match item.name.as_str() {
        "Aged Brie" => {
            item.sell_in -= 1;
            if item.quality < 50 {
                item.quality += 1;
            }
        }
        "Backstage passes to a TAFKAL80ETC concert" => {
            item.quality = match item.sell_in {
                1..=5 => item.quality + 3,
                6..=10 => item.quality + 2,
                0 => 0,
                _ => item.quality + 1,
            };
            item.sell_in -= 1;
        }
        "Sulfuras, Hand of Ragnaros" => {}
        _ => {
            item.sell_in -= 1;
            item.quality -= if item.sell_in < 0 { 2 } else { 1 };
            if item.quality < 0 {
                item.quality = 0;
            }
        }
    }
}

An der Stelle ist der Unterstrich erneut hervorzuheben, der als Fallback alle Fälle beschreibt, in denen keine Ausnahmeregelung herrscht.

Nach den obigen Änderungen könnte man eigentlich mit dem Refactoring aufhören. Die Gruppierungen sind eindeutig, die Wertänderungen pro Gruppierung gut lesbar. Es ist verständlich, was passiert. Die Syntax von Rust und die Möglichkeit, über die match-Anweisung Gruppierungen zu erstellen, verbessert die Lesbarkeit enorm. Rust enthält Features, die vor zehn Jahren beim Erstellen des Katas noch kaum vorstellbar gewesen wären.

Die aktuelle Lösung enthält jedoch mögliche Fallen und Probleme. Dass sich die Auswahl der unterschiedlichen Gegenstände auf Zeichenketten verlässt und nicht mit Typen umgeht, kann problematisch werden, wenn die Zeichenkette an anderer Stelle wieder als Entscheidungsmerkmal genutzt wird. Tippfehler in Strings sind keine Seltenheit.

Deshalb bietet sich die Einführung eigener Structs und Enums an, die den zweiten Pfeiler des Refactoring-Spektrums darstellen. Enums bilden einen Union-Type ab, es kann also nur eine Variante auf einmal gültig sein.

Die Kategorien der Gilded Rose lassen sich durch eine Enum abbilden. Eine Item-Type-Enum beschreibt, ob es sich um einen Spezialgegenstand oder um etwas Gängiges handelt. Das Beispiel bleibt bei abgekürzten Varianten der tatsächlichen Namen. Auch die Einführung allgemeinerer Kategorienamen käme infrage.

enum ItemType {
    Normal,
    AgedBrie,
    Backstage,
    Sulfuras,
}

Die Struct, die über den ItemType Bescheid weiß, ist Item selbst. Ein weiteres Feld kind legt fest, um welchen ItemType es sich handelt.

pub struct Item {
    kind: ItemType,
    pub name: String,
    pub sell_in: i32,
    pub quality: i32,
}

Dieser Schritt bedeutet auch, dass die Auswahllogik und Berechnung der Qualität ebenfalls nach Item wandert. In GildedRose befindet sich nur noch ein Aufruf der update_quality-Methode von Item.

pub fn update_quality(&mut self) {
    match self.kind {
        ItemType::AgedBrie => {
            self.sell_in -= 1;
            if self.quality < 50 {
                self.quality += 1;
            }
        }
        ItemType::Backstage => {
            self.quality = match self.sell_in {
                1..=5 => self.quality + 3,
                6..=10 => self.quality + 2,
                0 => 0,
                _ => self.quality + 1,
            };
            self.sell_in -= 1;
        }
        ItemType::Sulfuras => {}
        ItemType::Normal => {
            self.sell_in -= 1;
            self.quality -= if self.sell_in < 0 { 2 } else { 1 };
            if self.quality < 0 {
                self.quality = 0;
            }
        }
    }
}

Nach genauerer Betrachtung erscheint es sinnvoll, die Logik der Qualitätsberechnung mit den Daten in Item zu kombinieren.

Für den regulären Verlauf des Katas wäre an dieser Stelle der nächste Schritt, die Auswahl der Qualitätsberechnung in eigene Unterobjekte abzuleiten, die ein bestimmtes Interface implementieren. Damit wird es egal welche und wie viele Varianten es gibt, solange sie einem entsprechenden Interface folgen. Sandi Metz beschreibt das in ihrem Vortrag ganz ausgezeichnet. Rust ist das mit Enums umständlich, da bei Enums alle Varianten vorab deklariert werden.

Alternativ kann man auch statt einer Enum mit einem eigenen QualityUpdate-Trait arbeiten und mehrere Structs diesen Trait implementieren lassen. Hier sind dann allerdings Trait Objects nötig, um eine dynamische Bindung zu lösen. Ferris Talk 2 behandelt Traits und Trait Objects eingehend.

Das Zuweisen der richtigen Enum-Variante steht noch aus. Da es beim Refactoring oft wichtig ist, die inneren Strukturen zu adaptieren, ohne die Schnittstellen nach außen zu verändern, ist das Vorhandensein der Konstruktor-Funktion new hilfreich. Sie kam schon im Eingangsbeispiel vor.

impl Item {
    pub fn new(name: impl Into<String>, sell_in: i32, quality: i32) -> Item {
        Item {
            kind: todo!(),
            name: name.into(),
            sell_in,
            quality,
        }
    }
    // …
}

Zum einen die new-Funktion an sich. Anders als in anderen Programmiersprachen gibt es keine Regeln für Konstruktoren oder Konstruktor-Funktionen, aber es gibt Konventionen. new kommt auch in der Standardbibliothek zum Einsatz und gilt als ungeschriebenes Gesetz für Konstruktoren, die Parameter annehmen.

Für Konstruktoren, die ohne Parameter auskommen, bietet es sich an, den Default Trait zu implementieren. Das geht für Structs wie Item mit einem derive-Makro:

#[derive(Default)]
pub struct Item {
    kind: ItemType,
    pub name: String,
    pub sell_in: i32,
    pub quality: i32,
}

Enums wie ItemType müssen sich für eine Default-Variante entscheiden. Hier muss man den Default Trait selbst implementieren.

impl Default for ItemType {
    fn default() -> Self {
        ItemType::Normal
    }
}

Nun lässt sich mit Item::default() ein neues, zugegeben langweiliges Item ohne besondere Eigenschaften erstellen.

Das zweite idiomatische Muster sind Into-Parameter. Die Konstruktorfunktion new verlangt nicht die Übergabe eines String, obwohl name als String deklariert ist. Die Konstruktorfunktion verlangt stattdessen einen Typ, der sich mittels Into-Trait in einen String konvertieren lässt.

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.

Das erhöht die Kompatibilität von Item enorm. So können auch String Slices wie (&str) und andere Typen übergeben werden. Solange sie sich in einen String wandeln lassen, ist für das Struct alles in Ordnung.

Nun gilt es noch, den neuen ItemType dem Namens-String zu erzeugen. Auch hier können die Konversions-Traits als Behelf dienen. From<String> ermöglicht, dass aus dem Namen eine Enum-Variante wird.

impl From<String> for ItemType {
    fn from(kind: String) -> Self {
        match kind.as_str() {
            "Aged Brie" => ItemType::AgedBrie,
            "Sulfuras, Hand of Ragnaros" => ItemType::Sulfuras,
            "Backstage passes to a TAFKAL80ETC concert" => ItemType::Backstage,
            _ => ItemType::Normal,
        }
    }
}

Das Schöne: impl From<String> für ItemType implementiert gleichzeitig auch Into<ItemType> für String. Die beiden Traits sind verwandt, und es gibt eine Default-Implementierung für alle From-Implementierungen in der Standardbibliothek.

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

Damit kommt Into ohne zusätzlichen Arbeitsschritt an Bord.

Wann immer From<String> auftaucht, sollten Entwicklerinnen und Entwickler sich Gedanken machen, ob sie nicht auch gleich FromStr implementieren. Damit entsteht die Möglichkeit, mit

let item_type: ItemType = “Aged Brie”.parse().unwrap()

Strings direkt zu parsen. An anderer Stelle kann das hilfreich sein. Die Implementierung ist übersichtlich, da sie sich direkt bei From<String> bedienen kann.

impl FromStr for ItemType {
    type Err = Infallible;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        Ok(String::from(s).into())
    }
}

Der assoziierte Typ für den Fehlerfall ist in dieser Implementierung Infallible. FromStr gibt ein Result zurück, und ein Result ist ein Enum mit zwei möglichen Varianten. Für FromStr lässt sich über einen assoziierten Typ festlegen, welcher konkrete Fehler auftreten kann. In dieser Implementierung heißt der Typ für den Fehlerfall Infallible. Das ist ein Typ aus der Standardbibliothek für Situationen, in denen ein Result zu erwarten ist, die Entwicklerin oder der Entwickler aber garantiert, dass der ausführende Code keinen Fehler verursacht. Da ein Default-Fall für ItemType vorliegt, kann kein String zu einem unvorhergesehenen Status führen.

Mit ein paar kleinen Handgriffen wurde aus dem Ungetüm eine gut strukturierte und ausbaufähige Anwendung, die dank Standard-Traits auch noch gut für ihre Anwenderinnen und Anwender nutzbar ist. Den vierten Schritt im kleinen Ratgeber zu idiomatischem Rust, die Anwendung von Entwurfsmustern, heben wir uns für ein anderes Mal auf. Möglichkeiten sind beispielsweise die Implementierung von FromStr für Item und die Nutzung eines Builder-Patterns, um Werte zu setzen.

Doch denke ich, dass wir mit dem ersten Schwung schon gut dastehen, und man muss es ja nicht um der Muster willen übertreiben. Eigentlich gilt es nur, zu warten, bis die kommenden "verzauberten" Gegenstände die goldene Rose in Verdammnis bringen wollen und wir mit den Erweiterungen in die Enge getrieben werden. Dann gibt es einfach den nächsten Refactoring-Zyklus. Denn Refactoring gehört zu unserer täglichen Arbeit.

Ferris Talk – Neuigkeiten zu Rust. Kolumnist:
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 #7

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.

(sih)