Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Kapitel 05 (Fortgeschritten): Architektur & Profi-Techniken mit Zeichenketten

Dieses Kapitel richtet sich an Entwickler, die Rust in produktiven Umgebungen einsetzen und hochperformante, speichereffiziente und flexible APIs entwerfen möchten. Zeichenketten gehören in fast jeder Anwendung zu den am häufigsten genutzten Datentypen. Umso wichtiger ist es, ihre Speicherarchitektur zu verstehen und zu beherrschen.


Item 6: Bevorzuge &str als Funktionsargument zur Erhöhung der API-Flexibilität

Wenn Sie Funktionen entwerfen, die Text als Eingabe verarbeiten, stehen Sie oft vor der Frage, welchen Typ das Argument haben sollte. Die naheliegende Wahl für viele Einsteiger ist der Typ String. Für Bibliotheken und APIs ist dies jedoch in den meisten Fällen eine Einschränkung der Flexibilität und führt zu unnötigen Performance-Kosten.

Die Alltagsanalogie: Das Vergrößerungsglas

Stellen Sie sich vor, Sie betreiben ein Fotoalbum-Kopierstudio. Wenn ein Kunde Ihnen ein Foto zeigt, das Sie in ein Album einfügen sollen, haben Sie zwei Möglichkeiten:

  1. Besitzübertragung (String): Sie verlangen das Originalbild und behalten es. Wenn der Kunde das Foto später noch für sich selbst haben möchte, muss er vor dem Besuch bei Ihnen eine teure Kopie des Bildes anfertigen lassen (entspricht .clone() auf dem Heap).
  2. Sicht auf das Original (&str): Sie werfen einfach nur einen Blick durch ein Vergrößerungsglas auf das Bild des Kunden. Der Kunde behält das Foto, und Sie können die Bilddaten trotzdem auslesen und verarbeiten.

In Rust ist &str dieses Vergrößerungsglas. Es ermöglicht den Zugriff auf den Text, ohne dass der Speicher kopiert oder der Besitz übertragen werden muss.

Das Prinzip der Deref Coercion

Rust bietet einen Mechanismus namens Deref Coercion (automatische Typumwandlung durch Entreferenzierung). Dieser Mechanismus greift ein, wenn Sie eine Referenz auf einen Typ T an eine Funktion übergeben, die eine Referenz auf einen Typ U erwartet, sofern T das Trait Deref<Target = U> implementiert.

Da String das Trait std::ops::Deref mit dem Zieltyp str implementiert, gilt Folgendes:

#![allow(unused)]
fn main() {
// Vereinfachte Darstellung der Implementierung in der Standardbibliothek:
impl Deref for String {
    type Target = str;
    
    fn deref(&self) -> &str {
        // Gibt eine Sicht auf das zugrundeliegende Byte-Array zurück
        &self[..]
    }
}
}

Wenn Sie nun eine Referenz auf einen String (Typ &String) an eine Funktion übergeben, die einen &str erwartet, wandelt der Compiler die Referenz automatisch und ohne Laufzeitkosten in einen &str um.

Kompilierbares Beispiel

Das folgende Beispiel zeigt, wie eine einzige Funktion dank &str verschiedene String-Typen akzeptieren kann, ohne dass Daten auf dem Heap dupliziert werden müssen:

// Diese API ist maximal flexibel. Sie akzeptiert jeden Typ, 
// der sich als String-Slice darstellen lässt.
fn print_message(message: &str) {
    println!("Nachricht empfangen: {}", message);
}

fn main() {
    // Fall 1: Ein klassisches String-Literal (Typ: &'static str)
    // Literale liegen direkt im schreibgeschützten Datensegment des Binärprogramms.
    let literal: &'static str = "Hallo, statische Welt!";
    print_message(literal);

    // Fall 2: Ein dynamischer String auf dem Heap (Typ: String)
    let dynamic_string: String = String::from("Hallo, dynamische Welt!");
    
    // Wir übergeben eine Referenz auf den String (&String).
    // Dank Deref Coercion konvertiert Rust dies automatisch in &str.
    print_message(&dynamic_string);

    // Fall 3: Ein Teilausschnitt (Slice) des dynamischen Strings
    // Wir übergeben nur die Zeichen von Index 7 bis 17.
    let slice: &str = &dynamic_string[7..17];
    print_message(slice);
}

Zeilenweise Erklärung des Codes:

  • Zeile 3: Die Funktion print_message deklariert den Parameter message als &str. Dadurch signalisiert sie: “Ich benötige nur Lesezugriff auf den Text und beanspruche keinen Besitz.”
  • Zeile 10: literal zeigt direkt auf ein vordefiniertes Literal im statischen Programmspeicher. Es wird kein Heap-Speicher allokiert.
  • Zeile 14: dynamic_string allokiert Speicher auf dem Heap, um den Text dynamisch verwalten zu können.
  • Zeile 18: print_message(&dynamic_string) zeigt die Magie der Deref Coercion. Obwohl &dynamic_string den Typ &String hat, kompiliert der Code fehlerfrei, da Rust im Hintergrund dynamic_string.deref() aufruft, um den passenden &str zu erhalten.

Typischer Compilerfehler und Behebung

Wenn Sie eine Funktion fälschlicherweise so definieren, dass sie den Besitz eines String erzwingt, obwohl sie den Text nur lesen möchte, schränken Sie die Verwendbarkeit stark ein.

// Fehlerhafte API-Definition
fn verarbeite_nachricht(nachricht: String) {
    println!("Verarbeite: {}", nachricht);
}

fn main() {
    let literal = "Mein Text";
    
    // COMPILER-FEHLER:
    // verarbeite_nachricht(literal);
    // expected struct `String`, found `&str`
}

Warum lehnt der Compiler das ab?

Der Compiler lehnt diesen Aufruf ab, weil ein &str (ein einfacher Zeiger mit Längenangabe) nicht die Besitzanforderungen eines String-Objekts erfüllt. Ein String besitzt seinen Speicher auf dem Heap und ist für dessen Freigabe verantwortlich. Um die Funktion aufzurufen, müssten Sie das Literal explizit umwandeln (z.B. mit .to_string()), was eine teure Heap-Allokation zur Folge hätte.

Behebung:

Ändern Sie die Funktionssignatur von nachricht: String zu nachricht: &str. Dadurch entfallen alle Allokationskosten beim Aufruf mit Literalen oder bereits existierenden Zeichenketten.


Item 7: Minimiere Heap-Allokationen durch Vorab-Dimensionierung von String-Kapazitäten

Ein String in Rust ist intern als Vektor von Bytes (Vec<u8>) implementiert. Er speichert UTF-8-kodierten Text auf dem Heap. Da sich die Länge des Strings zur Laufzeit ändern kann, verwaltet Rust den Speicher dynamisch. Das unbedachte Vergrößern eines Strings in Schleifen kann jedoch zu massiven Performance-Einbußen führen.

Die Alltagsanalogie: Der Umzugskarton

Stellen Sie sich vor, Sie ziehen um und besitzen 50 Bücher.

  • Der ineffiziente Weg: Sie kaufen zuerst einen winzigen Karton, in den genau ein Buch passt. Sie legen das erste Buch hinein. Um das zweite Buch einzupacken, müssen Sie einen neuen Karton kaufen, in den zwei Bücher passen. Sie holen das erste Buch aus dem alten Karton, legen beide Bücher in den neuen Karton und werfen den alten Karton weg. Diesen Vorgang wiederholen Sie für jedes einzelne Buch. Der Arbeitsaufwand ist gigantisch.
  • Der effiziente Weg: Sie wissen im Voraus, dass Sie 50 Bücher haben. Sie gehen zum Laden und kaufen direkt einen großen Karton, der Platz für 50 Bücher bietet. Sie packen alle Bücher nacheinander ein, ohne jemals den Karton wechseln zu müssen.

In Rust entspricht die Größe des Kartons der Kapazität (capacity) des Strings, während die Anzahl der aktuell eingepackten Bücher der Länge (len) entspricht.

Speicher-Reallokation im Detail

Ein String besteht auf dem Stack aus drei Datenfeldern (jeweils mit der Breite eines Systemzeigers, also insgesamt 24 Bytes auf 64-Bit-Systemen):

  1. Pointer (ptr): Zeigt auf die Startadresse des Speicherbereichs auf dem Heap.
  2. Length (len): Die Anzahl der aktuell belegten UTF-8-Bytes.
  3. Capacity (cap): Die Gesamtzahl der Bytes, die auf dem Heap für diesen String reserviert wurden.

Wenn Sie mit .push() oder .push_str() Text an einen String anhängen, prüft Rust, ob len nach dem Anhängen größer als cap wäre. Ist dies der Fall, tritt eine Reallokation auf:

  1. Rust fordert vom Betriebssystem (bzw. dem Speichermanager) einen neuen, meist doppelt so großen Speicherbereich auf dem Heap an.
  2. Die bestehenden Bytes werden an die neue Adresse kopiert.
  3. Der alte Speicherbereich wird freigegeben.
  4. Der Zeiger ptr wird auf die neue Adresse umgebogen und cap wird aktualisiert.

Diese Schritte sind extrem teuer, da sie Systemaufrufe und Speicherkopieroperationen beinhalten.

Kompilierbares Beispiel

Das folgende Beispiel demonstriert den Performance-Vorteil von String::with_capacity() beim Aufbau einer großen Zeichenkette:

fn main() {
    // ----------------------------------------------------
    // Ineffizienter Ansatz: Ständige Reallokation
    // ----------------------------------------------------
    let mut ineffizienter_string = String::new();
    println!("Start-Kapazität (neu): {}", ineffizienter_string.capacity()); // Gibt 0 aus

    for i in 0..5 {
        ineffizienter_string.push_str("Messwert;");
        // Wir beobachten, wie die Kapazität schrittweise wächst und Reallokationen auslöst
        println!("Durchlauf {}: Kapazität = {}", i, ineffizienter_string.capacity());
    }

    // ----------------------------------------------------
    // Effizienter Ansatz: Vorab-Reservierung
    // ----------------------------------------------------
    // Wir berechnen im Voraus: 5 Iterationen * 9 Bytes ("Messwert;") = 45 Bytes.
    // Wir runden zur Sicherheit leicht auf.
    let mut effizienter_string = String::with_capacity(50);
    println!("\nStart-Kapazität (optimiert): {}", effizienter_string.capacity()); // Garantiert >= 50

    for _ in 0..5 {
        effizienter_string.push_str("Messwert;");
        // Die Kapazität bleibt über die gesamte Schleife hinweg konstant
        println!("Optimiert - Kapazität: {}", effizienter_string.capacity());
    }
}

Zeilenweise Erklärung des Codes:

  • Zeile 6: String::new() erstellt einen leeren String ohne Allokation auf dem Heap (capacity == 0).
  • Zeile 9: In jedem Schleifendurchlauf wird Text angehängt. Da die Kapazität anfangs 0 ist, muss Rust sofort Speicher allokieren und bei weiteren Überschreitungen mehrmals vergrößern.
  • Zeile 20: String::with_capacity(50) teilt dem Speichermanager sofort mit, dass wir 50 Bytes Speicher auf dem Heap benötigen. Die Allokation findet genau einmal statt.
  • Zeile 23: Da alle angehängten Daten in den reservierten Puffer passen, bleibt die Kapazität stabil und es finden keine teuren Kopierprozesse statt.

Item 8: Verwende FromStr zur Standardisierung von String-Parsing für eigene Domänentypen

Das Parsen von Zeichenketten in strukturierte Daten ist eine der häufigsten Aufgaben in der Softwareentwicklung. In Rust gibt es dafür eine standardisierte Schnittstelle: das Trait std::str::FromStr. Sobald Sie dieses Trait für Ihre eigenen Typen implementieren, können Sie die universelle Methode .parse() der Standardbibliothek nutzen.

Die Alltagsanalogie: Der Paket-Scanner

Stellen Sie sich ein Logistikzentrum vor. Auf dem Förderband kommen unzählige Pakete an, die mit unstrukturierten Text-Etiketten beklebt sind. Manche Etiketten sind beschädigt oder unleserlich. Ein automatischer Paket-Scanner (FromStr) liest die rohe Zeichenkette. Entspricht sie dem erwarteten Format (z. B. “PLZ-Straße-Hausnummer”), wandelt er sie in eine strukturierte Lieferroute um (den Domänentyp). Wenn das Etikett fehlerhaft ist, sortiert der Scanner das Paket aus und gibt eine Fehlermeldung aus, damit das Problem manuell behoben werden kann.

Das FromStr-Trait und die Fehlerbehandlung

Das Trait FromStr ist wie folgt definiert:

#![allow(unused)]
fn main() {
pub trait FromStr: Sized {
    type Err;
    fn from_str(s: &str) -> Result<Self, Self::Err>;
}
}

Zwei Aspekte sind hier architektonisch von Bedeutung:

  1. type Err (Assoziierter Typ): Sie müssen festlegen, welcher Fehlertyp zurückgegeben wird, wenn das Parsen fehlschlägt. Verwenden Sie hierfür niemals String, sondern definieren Sie präzise Enums, die es dem Aufrufer erlauben, programmatisch auf verschiedene Fehlerursachen zu reagieren.
  2. Result<Self, Self::Err>: Die Methode gibt niemals panisch unwrap() aufgerufen zurück. Sie nutzt das Result-Muster, um Fehler sicher an den Aufrufer zu melden. Innerhalb der Implementierung können Sie den ?-Operator verwenden, um Zwischenfehler elegant weiterzuleiten.

Kompilierbares Beispiel: Ein IPv4-Adressen-Parser

Wir implementieren einen Parser für eine IPv4-Adresse, die aus vier Oktetten (Bytes) besteht, getrennt durch Punkte (z. B. "192.168.1.1").

use std::str::FromStr;

// Unser strukturierter Domänentyp
#[derive(Debug, PartialEq, Eq)]
pub struct IPv4Address {
    pub octets: [u8; 4],
}

// Unser präziser Fehlertyp für das Parsing
#[derive(Debug, PartialEq, Eq)]
pub enum ParseIPError {
    InvalidFormat,      // Zu viele oder zu wenige Punkte
    InvalidOctet,       // Ein Wert ist keine Zahl oder außerhalb des Bereichs 0-255
}

// Die Implementierung des standardisierten FromStr-Traits
impl FromStr for IPv4Address {
    type Err = ParseIPError;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        // 1. Text an den Punkten aufteilen
        let parts: Vec<&str> = s.split('.').collect();
        
        // Eine IPv4-Adresse muss exakt aus 4 Teilen bestehen
        if parts.len() != 4 {
            return Err(ParseIPError::InvalidFormat);
        }

        // Puffer für die geparsten Bytes
        let mut octets = [0u8; 4];

        // 2. Jedes Oktett einzeln parsen
        for i in 0..4 {
            // parse() auf &str gibt ein Result<u8, ParseIntError> zurück.
            // Wir wandeln diesen Fehler mit map_err in unseren ParseIPError um
            // und leiten ihn im Fehlerfall mit dem ? Operator sofort weiter.
            let parsed_octet = parts[i]
                .parse::<u8>()
                .map_err(|_| ParseIPError::InvalidOctet)?;
            
            octets[i] = parsed_octet;
        }

        // 3. Erfolgreiches Ergebnis zurückgeben
        Ok(IPv4Address { octets })
    }
}

fn main() {
    // Dank der FromStr-Implementierung können wir die parse()-Methode direkt auf Slices nutzen!
    let ip_str = "192.168.178.1";
    
    // Die Typinferenz von Rust weiß, dass wir eine IPv4Address erwarten
    match ip_str.parse::<IPv4Address>() {
        Ok(ip) => println!("Erfolgreich geparst: {:?}", ip),
        Err(e) => println!("Parsing fehlgeschlagen: {:?}", e),
    }

    // Beispiel für ein ungültiges Format
    let kaputt = "192.168.1";
    assert_eq!(kaputt.parse::<IPv4Address>(), Err(ParseIPError::InvalidFormat));
}

Zeilenweise Erklärung des Codes:

  • Zeile 4: IPv4Address kapselt ein Array mit 4 Bytes (u8). Dies stellt sicher, dass ungültige Werte wie negative Zahlen oder Werte über 255 gar nicht erst im Typ gespeichert werden können.
  • Zeile 17: Wir legen fest, dass das Trait für IPv4Address implementiert wird.
  • Zeile 18: type Err = ParseIPError; verknüpft unseren Fehlertyp mit dem Trait.
  • Zeile 22: s.split('.') erzeugt einen Iterator über die Teilstücke. Wir sammeln sie in einem Vektor.
  • Zeile 25: Falls die Anzahl der Segmente nicht genau 4 beträgt, brechen wir sofort ab und geben das Resultat Err(ParseIPError::InvalidFormat) zurück.
  • Zeile 35: Hier nutzen wir .parse::<u8>() für jedes Segment. Da diese Methode bei Fehlschlag einen ParseIntError der Standardbibliothek zurückgibt, passen wir diesen mit .map_err(...) an unser eigenes Fehler-Enum an. Das ? sorgt dafür, dass die Funktion im Fehlerfall sofort beendet wird und der Fehler nach oben gereicht wird.

Typischer Compilerfehler und Behebung

Ein häufiger Fehler bei der Implementierung von FromStr ist das Vergessen des assoziierten Typs Err oder das Verwenden einer falschen Signatur.

#![allow(unused)]
fn main() {
impl FromStr for IPv4Address {
    // COMPILER-FEHLER: missing associated type `Err`
    fn from_str(s: &str) -> Result<Self, Self::Err> {
        // ...
    }
}
}

Behebung:

Sie müssen zwingend type Err = ...; innerhalb des impl-Blocks definieren. Rust benötigt diesen Typ zur Kompilierzeit, um die Signatur der Methode from_str vollständig aufzulösen.


Item 9: Optimiere Lebenszeiten mit Box::leak für langlebige globale Konfigurations-Slices

In Rust ist das concept der Lebenszeiten (Lifetimes) allgegenwärtig. Manchmal benötigen Sie im gesamten Programm Zugriff auf Konfigurationsdaten (z. B. eine Datenbank-URL), die erst zur Laufzeit aus einer Datei oder Umgebungsvariablen gelesen werden. Der naive Ansatz, diese Daten als Referenz durch alle Strukturen zu reichen, führt zu komplexen Lifetime-Annotationen (<'a>). Die Standardbibliothek bietet mit Box::leak eine elegante Lösung, um zur Laufzeit statischen Speicher zu erzeugen.

Die Alltagsanalogie: Das Denkmal auf dem Marktplatz

Stellen Sie sich vor, Sie leihen ein Buch aus der Bibliothek aus. Sie dürfen es nur für eine begrenzte Zeit behalten (temporäre Lebenszeit). Wenn Sie das Buch an einen Freund weitergeben möchten, müssen Sie sicherstellen, dass er es zurückgibt, bevor Ihre Leihfrist abläuft. Das erfordert ständige Absprachen und Koordination (Lifetime-Annotationen).

Wenn Sie das Buch jedoch kaufen, es mit Zement übergießen und fest auf dem Marktplatz einbetonieren (Speicher leaken), bleibt es dort für immer stehen ('static). Jedes Mitglied der Gemeinschaft kann jederzeit zu diesem Buch gehen und es lesen, ohne sich jemals um Fristen oder Rückgaben sorgen zu müssen.

Funktionsweise von Box::leak

Normalerweise wird der Speicher einer Heap-Allokation (Box<T>) automatisch freigegeben, sobald die Box den Gültigkeitsbereich verlässt (durch das Trait Drop).

Die Funktion Box::leak nimmt eine Box entgegen, umgeht den automatischen Destruktor und gibt eine veränderbare Referenz mit der Lebenszeit 'static (&'static mut T) zurück. Der Speicher wird somit dauerhaft aus der Verwaltung des Heap-Sammlers entlassen. Er bleibt an einer festen Adresse im Speicher erhalten, bis das Programm beendet wird.

Kompilierbares Beispiel

Das folgende Beispiel zeigt, wie Sie eine zur Laufzeit geladene Konfiguration in eine 'static str Referenz umwandeln, um sie einfach global zu nutzen:

use std::collections::HashMap;

// Eine globale Struktur, die eine statische Referenz auf die Konfiguration hält.
// Dadurch müssen wir keine komplexen Lifetimes an der Struktur deklarieren!
struct ConfigManager {
    connection_string: &'static str,
}

fn lade_konfiguration_aus_umgebung() -> &'static str {
    // 1. Wir simulieren das Auslesen eines Werts zur Laufzeit.
    // Dieser String wird dynamisch auf dem Heap allokiert.
    let raw_input: String = String::from("postgres://admin:secret@localhost:5432/prod_db");

    // 2. Speicheroptimierung: Wir wandeln den String in eine Box<str> um.
    // String besitzt oft zusätzliche Kapazitäts-Reserven auf dem Heap.
    // into_boxed_str() gibt diesen überschüssigen Speicher frei und schrumpft 
    // die Allokation auf die exakte Größe des Texts.
    let boxed_str: Box<str> = raw_input.into_boxed_str();

    // 3. Statischer Leak: Wir leaken den Speicher dauerhaft.
    // Box::leak gibt uns eine &'static str Referenz zurück.
    let static_ref: &'static str = Box::leak(boxed_str);

    static_ref
}

fn main() {
    // Konfiguration zur Laufzeit erzeugen
    let db_url: &'static str = lade_konfiguration_aus_umgebung();

    // Der ConfigManager benötigt keine Lebenszeit-Parameter,
    // da &'static str das gesamte Programm über gültig bleibt!
    let manager = ConfigManager {
        connection_string: db_url,
    };

    println!("Datenbank-URL im Manager: {}", manager.connection_string);
}

Zeilenweise Erklärung des Codes:

  • Zeile 12: raw_input ist un dynamischer String. Seine Lebenszeit ist auf die Funktion lade_konfiguration_aus_umgebung beschränkt.
  • Zeile 18: into_boxed_str() ist ein wichtiger Optimierungsschritt. Ein String hat oft eine größere Kapazität als Länge. Box<str> hingegen belegt auf dem Heap exakt die Byte-Breite des Textes. Dadurch wird kein Speicherplatz verschwendet.
  • Zeile 22: Box::leak(boxed_str) ist der entscheidende Systemaufruf. Er teilt Rust mit: “Lösche diesen Speicherbereich nicht, wenn die Funktion endet. Ich übernehme die Verantwortung dafür, dass er bis zum Programmende existiert.”
  • Zeile 33: Wir erstellen den ConfigManager. Da manager.connection_string die Lebenszeit 'static besitzt, kann die Struktur im gesamten Programm herumgereicht werden, ohne dass wir Lifetime-Parameter wie ConfigManager<'a> deklarieren müssen.

Warning

Architektonischer Warnhinweis: Box::leak sollte ausschließlich für Daten verwendet werden, die einmalig beim Programmstart initialisiert werden und über die gesamte Programmlaufzeit benötigt werden. Wenn Sie Box::leak in Schleifen oder bei periodisch wiederkehrenden Events aufrufen, erzeugen Sie ein unkontrolliertes Speicherleck (Memory Leak), welches das Programm nach einiger Zeit wegen Speichermangels abstürzen lässt.


Item 10: Implementiere Display für maßgeschneiderte, performante String-Formatierung von Domänentypen

Rust unterscheidet strikt zwischen zwei Formatierungs-Traits für die Textausgabe:

  1. std::fmt::Debug (Platzhalter {:?}): Für Entwickler zur Fehlersuche. Zeigt interne Details des Typs. Kann fast immer über #[derive(Debug)] automatisch generiert werden.
  2. std::fmt::Display (Platzhalter {}): Für Endanwender. Zeigt eine schön formatierte, benutzerfreundliche Textdarstellung. Muss manuell implementiert werden.

Für eine professionelle API ist die saubere Implementierung von Display unerlässlich, um eigene Domänentypen nahtlos in das Formatierungs-Ökosystem von Rust (z.B. println!, format!, write!) zu integrieren.

Die Alltagsanalogie: Der Dolmetscher

Stellen Sie sich vor, Sie haben eine Struktur, die eine Uhrzeit speichert (z. B. Sekunden seit Mitternacht).

  • Die Bäcker-Sicht (Debug): Zeigt Ihnen die Rohdaten: Uhrzeit { sekunden: 45296 }. Das hilft Ihnen beim Debuggen des Programmcodes.
  • Die Dolmetscher-Sicht (Display): Übersetzt diese Rohdaten für den Hotelgast auf der Speisekarte in ein gut lesbares Format: 12:34:56.

Display ist dieser Dolmetscher. Er übersetzt die internen Datenstrukturen in eine für Menschen verständliche Sprache.

Zero-Allocation-Formatierung

Ein zentraler Vorteil des Display-Traits in Rust ist seine hohe Performance. Die Methode fmt arbeitet direkt mit einem Formatter (Typ &mut fmt::Formatter). Wenn Sie innerhalb der Implementierung das Makro write! verwenden, werden die formatierten Daten direkt in den Zielpuffer geschrieben (z. B. direkt in den Standard-Ausgabekanal der Konsole oder in eine Datei).

Es wird kein temporärer Zwischen-String auf dem Heap allokiert, wie es in vielen anderen Sprachen der Fall ist (wo oft erst ein String zusammengesetzt und dann zurückgegeben wird).

Kompilierbares Beispiel

Wir erweitern unseren in Item 8 erstellten Typ IPv4Address um eine ansprechende Display-Implementierung.

use std::fmt;

#[derive(Debug)] // Debug-Trait wird automatisch generiert (für {:?})
pub struct IPv4Address {
    pub octets: [u8; 4],
}

// Manuelle Implementierung von Display (für {})
impl fmt::Display for IPv4Address {
    // Die Methode nimmt eine unveränderliche Referenz auf sich selbst (&self)
    // und eine veränderliche Referenz auf den Formatter-Stream (f) entgegen.
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        // Das write! Makro schreibt die Daten formatiert in den Stream 'f'.
        // Wir verwenden kein Semikolon am Ende, da wir das Resultat von write!
        // (das ein fmt::Result ist) direkt als Rückgabewert der Funktion nutzen.
        write!(
            f,
            "{}.{}.{}.{}",
            self.octets[0], self.octets[1], self.octets[2], self.octets[3]
        )
    }
}

fn main() {
    let ip = IPv4Address {
        octets: [192, 168, 1, 1],
    };

    // 1. Ausgabe über Debug (technisch)
    println!("Debug-Ausgabe: {:?}", ip);
    // Ausgabe: Debug-Ausgabe: IPv4Address { octets: [192, 168, 1, 1] }

    // 2. Ausgabe über Display (benutzerfreundlich)
    println!("Display-Ausgabe: {}", ip);
    // Ausgabe: Display-Ausgabe: 192.168.1.1

    // 3. Verwendung im format!-Makro
    let s = format!("Standard-Gateway ist: {}", ip);
    assert_eq!(s, "Standard-Gateway ist: 192.168.1.1");
}

Zeilenweise Erklärung des Codes:

  • Zeile 8: Wir implementieren das Trait fmt::Display. Im Gegensatz zu Debug kann dieses Trait nicht mit #[derive] generiert werden, da der Compiler nicht wissen kann, welches Layout für den Endbenutzer gewünscht ist.
  • Zeile 11: Die Methode fmt gibt ein fmt::Result zurück. Dieses signalisiert, ob der Schreibvorgang erfolgreich war oder ob der Ziel-Stream (z. B. eine volle Festplatte) das Schreiben blockiert hat.
  • Zeile 14: Das Makro write! verhält sich genau wie format!, schreibt die Bytes jedoch direkt in den Stream f. Durch die Rückgabe des Resultats von write! (ohne abschließendes Semikolon) leiten wir eventuelle Schreibfehler automatisch weiter.
  • Zeile 31: println!("Display-Ausgabe: {}", ip) sucht im Hintergrund nach der Display-Implementierung für IPv4Address und führt unsere Methode aus.