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.