Ferris Talk #6: Ein neuer Trick für die Formatstrings in Rust

Rust 1.58 hat bei den Captured Identifiers etwas Neues gelernt – die Funktionserweiterung bietet Anlass, einen Blick auf die Formatstrings zu werfen.

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

Das einfachste Programm, das man zum Einstieg in eine neue Programmiersprache schreibt, ist das berühmte "Hello World". In Rust sieht es so aus:

fn main() {
    println!("Hello World!");
}

Listing 1: "Hello World"

Das println-Kommando sticht sofort ins Auge. Tatsächlich handelt es sich um ein Makro, um genau zu sein, um ein Function-like Procedural Macro. Für Einsteiger und Einsteigerinnen ist die Tatsache, dass Rust schon für ein so einfaches Programm auf ein Makro zurückgreift, die erste Überraschung, die die Sprache bereithält. Wer bereits mehr mit Rust gemacht hat, weiß, dass das keine Ausnahme, sondern die Regel ist. Rust-Programme stecken in der Praxis voller Makros, die einem das Leben spürbar erleichtern. Wer Makros aus anderen Sprachen kennt und dort nicht liebgewonnen hat, den kann ich beruhigen: Makros in Rust sind ein mächtiges und stabiles Werkzeug.

Ferris Talks – die Kolumne für Rustaceans

Natürlich kann println mehr als nur Textkonstanten auf stdout ausgeben. Die wirkliche Power ergibt sich aus dem Formatstring, den das Makro entgegennimmt. Mit Rust 1.58 sind Formatstrings durch die neue Captured Identifiers-Funktion noch einmal kompakter und leichter lesbar geworden. Wir nehmen die aktuelle Spracherweiterung zum Anlass, um in dieser Ausgabe der Ferris Talks die Formatstrings in Rust genauer unter die Lupe zu nehmen und auch die erwähnten Captured Identifiers vorzustellen.

Rust bietet vier Basis-Makros zum Ausgeben auf stdout und stderr:

fn main() {
    print!("Hello ");               // Print without \n
    println!("World!");             // Print with \n

    eprint!("Oops, something ");    // Print to stderr without \n
    eprintln!("bad happened!");     // Print to stderr with \n
}

Listing 2: Ausgabe auf stdout und stderr

Will man Variablenwerte in die Ausgabe einbauen, baut man "Lücken" in den Formatstring ein. Das folgende Beispiel zeigt das Grundprinzip. Anschließend geht es um sämtliche Varianten, wie Formatstrings aufgebaut sein können.

fn main() {
    // Print variable value
    let answer = 42;
    println!("The answer is {}", answer);

    // Use format string to build string for further processing
    // (here: convert answer string to base64 string).
    let answer_base64 = base64::encode(format!("The answer is {}", answer));
    println!("{}", answer_base64);
}

Listing 3: Platzhalter für Variablen im Formatstring

Das oben gezeigte Beispiel enthält neben println ein weiteres, häufig verwendetes Makro: format. Es ermöglicht das Verwenden von Formatstrings zum Zusammenbauen einer Ergebniszeichenkette im Hauptspeicher. Listing 3 baut mit format einen Text zusammen, um ihn anschließend in Base64 zu encodieren.

Aufmerksame Leserinnen und Leser wundern sich, warum in der letzten Zeile von Listing 3 ein Formatstring zum Einsatz kam. Ist println!("{}", answer_base64); notwendig oder ließe sich nicht einfach println!(answer_base64); schreiben? Tatsächlich ist der Formatstring in diesem Fall verpflichtend, da Rust nur konstante Zeichenketten (String Literals) als Formatstrings akzeptiert. Der Grund dafür ist, dass Rust zugunsten der Performance und der Stabilität die Formatstrings nicht zur Laufzeit, sondern zur Kompilierzeit parst und prüft. Dynamisch zur Laufzeit erstellte Formatstrings sind in Rust nicht möglich. Das folgende Beispiel funktioniert daher nicht:

fn main() {
    let answer = 42;
    let format_string = "The answer is {}";
    println!(format_string, answer);    // This does NOT compile because
                                        // format string needs to be
                                        // a string literal.
}

Listing 4: Keine Variablen als Formatstrings

Zwei weitere Makros, die Formatstrings akzeptieren, sind write und writeln. Sie erstellen die Ergebniszeichenfolge und schreiben sie in einen Writer. Ein Writer ist in diesem Zusammenhang eine beliebige Instanz, die eine write_fmt-Methode anbietet. In der Praxis kommt die Implementierung von write_fmt meistens aus den Traits std::fmt::Write oder std::io::Write. Letzterer wird insbesondere von Strukturen implementiert, die Dateien, Streams und Ähnliches repräsentieren. Interessanterweise implementiert aber auch die Vec-Struktur den std::io::Write-Trait, wodurch folgender Code möglich wird:

use std::io::Write;

fn main() {
    let answer = 42;

    // facts will receive the UTF8 byte of the resulting string
    let mut facts: Vec<u8> = Vec::new();
    write!(&mut facts, "The answer is {}", answer).unwrap();

    println!("{:?}", facts);
}

Listing 5: Formatstrings im write-Makro

Auch das panic-Makro kann mit Formatstrings umgehen. Das ist in der Praxis häufig relevant, um sprechende Fehlermeldungen zusammenzubauen. Hier ein Beispiel mit einer Panic, deren Nachricht mit Formatstring generiert ist:

fn main() {
    let answer = 41;
    let expected_answer = 42;

    if answer != expected_answer {
        panic!("{} is not {} - the world is going TO END :-O", answer, expected_answer);
    }

    // The output (stderr) will be:
    // thread 'main' panicked at '41 is not 42 - the world is going TO END :-O', src/main.rs:6:9
}

Listing 6: Formatstrings im panic-Makro

Die nächste Frage ist, wie die oben genannten Makros Variablenwerte in Text umwandeln. In vielen objektorientierten Programmiersprachen enthalten Objekte dafür eine virtuelle ToString-Methode, die sich überschreiben lässt. Bei Rust ist das etwas anders, es sieht dafür primär zwei Traits vor:

  • Eine Struktur, die sich in eine endbenutzertaugliche Textrepräsentation umwandeln lässt, muss explizit den Display-Trait implementieren.
  • Zum Umwandeln in eine technische Textrepräsentation (beispielsweise für Logs und zum Debuggen) muss eine Struktur den Debug-Trait implementieren. Dieser Trait lässt sich meist mit dem #[derive(Debug)]-Makro generieren.

Um genau zu sein, gibt es auch in Rust im ToString-Trait eine to_string-Methode. Der Trait ist jedoch nicht direkt einzubetten, sondern der Fokus sollte auf Display liegen. Strukturen, die Display implementieren, implementieren automatisch auchToString.

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 folgende Beispiel zeigt das Prinzip von Display, Debug und ToString:

use std::fmt::{Display, Formatter, Result};

#[derive(Debug)] // Generate Debug trait impl using derive makro
struct Person {
    first_name: String,
    last_name: String,
}

impl Display for Person {
    fn fmt(&self, f: &mut Formatter) -> Result {
        // Use write macro to generate end-user friendly
        // text representation of person instance.
        write!(f, "{}, {}", self.last_name, self.first_name)
    }
}

fn main() {
    let p = Person {
        first_name: String::from("Foo"),
        last_name: String::from("Bar"),
    };

    // Print end-user friendly person
    // Result: Bar, Foo
    println!("{}", &p);

    // Every struct that implements Display auto-implements
    // the ToString trait. It offers the to_string function that
    // turns the struct into a string.
    println!("{}", p.to_string());

    // Print technical string representation of person
    // Result: Person { first_name: "Foo", last_name: "Bar" }
    println!("{:?}", &p);
}

Listing 7: Display-, Debug- und ToString-Traits

In Listing 7 ist der Formatstring {:?} zu beachten. Er führt dazu, dass println den Debug-Trait verwendet. Um exakt zu sein, signalisiert der Doppelpunkt, dass danach eine Formatangabe folgt. Das Fragezeichen in der Formatangabe ist es, das die Debug-Ausgabe statt der endbenutzertauglichen Ausgabe auslöst. Das bringt uns zum nächsten Thema, der Ausgabeformatierung.

In der Praxis ist es häufig notwendig, Variablenwerte, die sich mit Hilfe eines Formatstrings in eine Zeichenkette umwandeln lassen, entsprechend zu formatieren. Beispiele, die über die oben bereits erwähnte Debug-Ausgabe hinaus gehen, sind:

  • Padding, also das Auffüllen auf eine gewünschte Ausgabelänge
  • Auswahl des gewünschten Zahlensystems (wie hexadezimal oder binär) bei numerischen Datentypen
  • Anzahl der Ziffern nach dem Komma bei Gleitkommawerten
  • Ausgabe der Adresse eines Pointers statt des Speicherinhalts, auf den er referenziert

Eine vollständige Beschreibung aller Parameter für Formatstrings würde den Rahmen dieses Artikels sprengen – zumal sich auch in der Rust-Dokumentation eine genaue Beschreibung aller Möglichkeiten nachschlagen lässt. Das folgende Beispiel zeigt daher nur das Prinzip der Ausgabeformatierung. Es berechnet einen Zapfen (oder einen Turm, wie es in Österreich heißt), eine beliebte Übung zum Lernen von Multiplizieren und Dividieren in der Grundschule. Dabei geht man von einer Basiszahl aus und multipliziert sie mit 2, dann mit 3, mit 4 und so weiter. Wenn man bei der gewünschten Höhe angelangt ist, dividiert man durch 2, dann durch 3 und so fort – schließlich muss wieder die Basiszahl herauskommen. Am Ende des Listings ist ein Beispiel für einen Zapfen dargestellt.

/// Calculates the length of a given, positive number
fn len_of_positive_number(mut value: i32) -> usize {
    let mut len = 0;
    while value > 0 {
        len += 1;
        value /= 10;
    }

    len
}

fn main() {
    let start_value = 5;
    let height = 6;
    let len_height = len_of_positive_number(height);

    let mut value = start_value;
    for op in ['*', '/'] {
        for i in 2..=height {
            let new_value = match op {
                '*' => value * i,
                '/' => value / i,
                _ => panic!("Invalid op"),
            };
            // Use format string parameters to align numbers properly (padding left/right).
            // Note that length of third format string parameter is given
            // as a dynamic, usize value ($ postfix to positional parameter).
            println!("{:>9} {} {:<4$} = {}", value, op, i, new_value, len_height);
            value = new_value;
        }
    }

    /*
    The output will be:
        5 * 2 = 10
       10 * 3 = 30
       30 * 4 = 120
      120 * 5 = 600
      600 * 6 = 3600
     3600 / 2 = 1800
     1800 / 3 = 600
      600 / 4 = 150
      150 / 5 = 30
       30 / 6 = 5
    */
}

Listing 8: Formatierung von Variablenwerten

In der Praxis gibt es oft mehrere Variablen, deren Werte Entwicklerinnen und Entwickler mit Hilfe des Formatstrings in die Ergebniszeichenkette einbauen möchten. Auch für das mehrfache Einfügen ein und derselben Variable besteht realer Bedarf. Für solche Fälle sehen Formatstrings in Rust Positional- und Named-Parameter vor. Das folgende Beispiel stellt die beiden Ansätze gegenüber:

fn main() {
    let answer = 42;

    // Positional parameter
    println!("{} is the answer so {0} is a great number", answer);

    // Note: Named parameter
    println!("{a} is the answer so {a} is a great number", a = answer);
}

Listing 9: Parameter referenzieren

Wie eingangs erwähnt, hat die Rust-Version 1.58 (erschienen im Januar 2022) einen neuen Trick in Sachen Referenzieren von Parametern gelernt: Captured Identifiers in Format Strings. Man kann jetzt Variablen mit ihrem Namen referenzieren, ohne sie nach dem Formatstring explizit als Parameter angeben zu müssen. Listing 10 veranschaulicht neben der richtigen Verwendung auch die wesentliche Einschränkung, dass sich aktuell nur Identifier, aber keine Expressions im Formatstring einsetzen lassen.

#[derive(Debug)]
struct Person {
    first_name: String,
    last_name: String,
}

fn main() {
    // Captured identifiers in format strings
    let answer = 42;
    println!("{answer} is the answer so {answer} is a great number");

    let answer = 41;
    // Note that the following line would NOT compile. You can
    // only capture identifier, you cannot use expressions. Use
    // a helper variable or other parameter forms (positional or named)
    // if you want to use expressions.
    //println!("{answer + 1} is the answer so {answer + 1} is a great number");

    // Captured identifiers also support output formatting.
    // In this case, we use the Debug trait to get a technical string
    // representation of Person.
    let p = Person {
        first_name: String::from("Foo"),
        last_name: String::from("Bar"),
    };
    println!("Content of p: {p:?}");
}

Listing 10: Captured Identifiers in Format Strings

Obwohl wie erwähnt Rust die Formatstrings nicht zur Laufzeit parst, sind format und println oft nicht die besten Optionen, wenn es um performancekritischen Code geht. Sind beispielsweise einfach nur Zeichenketten aneinander zu hängen, gibt es schnellere Alternativen. Auf GitHub finden sich Beispielprojekte mit Benchmarks, die verschiedene Möglichkeiten gegenüberstellen. Da sich Rust ständig weiterentwickelt, ist es empfehlenswert, sich nicht blind auf die genannten Ergebnisse zu verlassen, sondern projektspezifische Benchmarks zu schreiben.

Ich möchte jedoch davor warnen, Formatstrings generell zu meiden und immer zu versuchen, das letzte Quäntchen Performance durch individuell optimierte Algorithmen zur Erstellung von Zeichenketten zu verwenden. Die Lesbarkeit und Wartbarkeit des Codes nimmt dadurch ab. Der individuelle Weg sollte dort eingeschlagen werden, wo Laufzeitperformance wirklich das entscheidende Kriterium ist.

Formatstrings lassen sich an vielen Stellen in Rust einsetzen. Der Rust-Compiler parst und prüft sie zur Übersetzungszeit, und er übersetzt sie in Code, der für den Normalfall ausreichende Performance bietet.

Rust 1.58 hat mit Captured Identifiers in Format Strings ein praktisches Feature hinzugewonnen, das den Code kompakter macht, ohne die Lesbarkeit zu verschlechtern. Entwickler und Entwicklerinnen, die jedoch von anderen Programmiersprachen wie C# kommen, werden in Rust immer noch einige Funktionen vermissen, die bei der dortigen String Interpolation zur Verfügung stehen. Im Gegenzug zeichnet sich Rust in der Regel durch bessere Performance aus. Es ist außerdem davon auszugehen, dass die Neuerungen in Rust 1.58 nicht die letzten in Sachen Formatstrings gewesen sind.

Ferris Talk – Neuigkeiten zu Rust. Kolumnist:
Rainer Stropek, timecockpit.com, Rust Meetup Linz, Autor der Ferris Talks, der Kolumne über die Programmiersprache Rust bei Heise Developer

Rainer Stropek, Autor von Ferris Talk #6

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. Neben der Tätigkeit als Trainer und Berater in seiner Firma software architects entwickelt er 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.

Regelmäßig tritt er als Speaker und Trainer auf namhaften Konferenzen in Europa und den USA auf. 2010 wurde er 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)