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

Seite 2: Die Einführung von Structs und Enums

Inhaltsverzeichnis

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.