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: Zeichenketten (Strings)

Zeichenketten gehören zu den am häufigsten verwendeten Datentypen in der Programmierung. In Rust gibt es jedoch eine Besonderheit: Es gibt nicht nur “den einen” String-Typ, sondern im Wesentlichen zwei verschiedene Typen, die sich in ihrer Speicherstruktur und Verwendung stark unterscheiden: den String-Slice (&str) und den dynamischen String.

In diesem Kapitel bieten wir Ihnen drei verschiedene Perspektiven auf das Thema an. Wählen Sie die Sicht, die am besten zu Ihrem Hintergrund passt:

  • Für Anfänger: Konzentriert sich auf die Unterscheidung zwischen dem veränderbaren String (Notizbuch) und dem unveränderbaren Slice (Leselupe) sowie einfache Textoperationen.
  • Für Profis: Behandelt API-Flexibilität mittels &str, Allokationsminimierung, die Implementierung des FromStr- und Display-Traits sowie Box::leak für langlebige Slices.
  • Hardware-Sicht: Analysiert das physikalische Stack/Heap-Layout, String-Literale im .rodata-Segment, die Funktionsweise von UTF-8 und das Speicher-Reallokationsverhalten auf Systemebene.

Begleitvideo zu Kapitel 5: Zeichenketten & Strings


Kapitel 05: Zeichenketten (Strings) kinderleicht verstehen (Sicht für Anfänger)

Herzlich willkommen im Reich der Buchstaben und Wörter! In diesem Kapitel beschäftigen wir uns mit Zeichenketten – in der Programmierwelt nennen wir sie meistens einfach Strings (vom englischen Wort für „Faden“ oder „Kette“).

Wenn du aus anderen Programmiersprachen wie Python, Scratch oder JavaScript kommst, kennst du Text wahrscheinlich als eine ganz einfache Sache: Du schreibst "Hallo" und der Computer macht damit, was du willst. In Rust ist das ein kleines bisschen anders. Rust ist eine Sprache, die extrem viel Wert auf Geschwindigkeit und absolute Sicherheit legt. Deshalb gibt es hier nicht nur eine Art von Text, sondern zwei Haupt-Typen.

Das kann am Anfang verwirrend wirken. Aber keine Sorge! Mit unseren zwei Alltagsanalogien – dem Notizbuch und der Leselupe – wirst du den Unterschied sofort verstehen.


1. Lernziele

In diesem Abschnitt wirst du:

  • Verstehen, warum Rust zwei verschiedene Text-Typen (String und &str) benutzt.
  • Die Alltagsanalogie vom Notizbuch auf dem Heap (String) und der Leselupe (&str) kennenlernen.
  • Lernen, wie du Text veränderst, Buchstaben anhängst und Textstücke zusammenklebst (.push() und .push_str()).
  • Verstehen, wie du überflüssige Leerzeichen wegsaugst (.trim()).
  • Lernen, wie du Text magisch in echte Zahlen verwandelst (.parse()) und dabei Fehler verhinderst.
  • Text elegant auf dem Bildschirm ausgibst (println!) oder im Speicher zusammenbaust (format!).
  • Die unsichtbare UTF-8-Falle kennenlernen und verstehen, warum Umlaute (ä, ö, ü) und Emojis (🦀) dein Programm zum Abstürzen bringen können, wenn du sie falsch zerschneidest.

2. Das große String-Dilemma: Warum gibt es zwei Typen?

Stell dir vor, du möchtest ein Programm schreiben, das den Namen eines Spielers speichert, ihn begrüßt und später seinen Punktestand anhängt. Warum gibt es in Rust dafür zwei verschiedene Datentypen?

Die Antwort liegt darin, wie der Computer seinen Speicher organisiert:

  1. Der Stapel (Stack): Ein extrem schneller, aber sehr starrer Speicher. Hier muss der Computer schon vorher genau wissen, wie groß eine Information ist. Wenn ein Text wachsen soll (z. B. weil der Spieler seinen Namen verlängert), passt er nicht mehr in das starre Fach auf dem Stack.
  2. Der Haufen (Heap): Ein großer, flexibler Schreibtisch. Hier kann der Computer sich so viel Platz nehmen, wie er gerade braucht. Wenn der Text wächst, wird einfach ein neues, größeres Stück Platz auf dem Tisch reserviert. Das Anfordern von Platz auf dem Heap dauert allerdings einen klitzekleinen Moment länger als auf dem Stack.

Um beide Vorteile zu nutzen (Flexibilität und Lichtgeschwindigkeit), teilt Rust Text in zwei Rollen auf:

graph TD
    A[Text in Rust] --> B(String - Das veränderbare Notizbuch)
    A --> C(&str - Die unveränderliche Leselupe)
    B --> B1[Liegt auf dem Heap]
    B --> B2[Kann wachsen und schrumpfen]
    B --> B3[Gehört dir Ownership]
    C --> C1[Zeigt auf einen Speicherbereich]
    C --> C2[Feste Größe im Stack]
    C --> C3[Nur zum Lesen gedacht]

Analogie 1: String – Das Notizbuch auf dem Heap

Stell dir den Typ String wie ein Notizbuch in einem Ringordner vor.

  • Du besitzt es: Es gehört dir ganz allein.
  • Du kannst es verändern: Du kannst Seiten hinzufügen (Text anhängen), Wörter durchstreichen oder Seiten herausreißen.
  • Speicherort: Es liegt auf dem großen Schreibtisch (dem Heap). Weil du jederzeit neue Blätter einheften kannst, weiß der Computer am Anfang nicht, wie dick das Buch am Ende sein wird. Das ist aber kein Problem, denn auf dem Schreibtisch (Heap) ist genug Platz zum Ausbreiten.

In Rust erstellen wir ein solches Notizbuch so:

#![allow(unused)]
fn main() {
// Wir erstellen ein komplett leeres, aber veränderbares Notizbuch auf dem Heap
let mut mein_notizbuch = String::new();
}

Analogie 2: &str – Die Leselupe (String Slice)

Stell dir den Typ &str (gesprochen: String Slice oder Referenz auf einen String) wie eine Leselupe vor, mit der du auf ein fest gedrucktes Plakat schaust.

  • Du besitzt das Plakat nicht: Das Plakat hängt fest an einer Werbewand (z. B. fest im Programmcode als Text-Literal wie "Hallo Welt").
  • Du kannst es nicht verändern: Du kannst den Text auf dem Plakat weder übermalen noch verlängern.
  • Die Lupe ist superleicht: Die Lupe selbst speichert nicht den Text. Sie merkt sich nur zwei Dinge:
    1. Wo auf dem Plakat schaust du hin (die Startadresse)?
    2. Wie breit ist das Sichtfenster deiner Lupe (die Länge des Textabschnitts)?
  • Speicherort: Da diese beiden Informationen (Startpunkt und Breite) winzig und immer gleich groß sind, passen sie perfekt auf den superschnellen Stapel (den Stack).

In Rust sieht ein solcher Text so aus:

#![allow(unused)]
fn main() {
// Ein fest gedrucktes Plakat im Speicher. Es ist unveränderlich.
let plakat = "Rust ist fantastisch!"; 

// Die Lupe zeigt nur auf den Ausschnitt von Zeichen 0 bis 4 (das Wort "Rust")
let lupe: &str = &plakat[0..4]; 
}

Zusammenfassung: Wenn du Text verändern, dynamisch zusammenbauen oder vom Benutzer einlesen willst, brauchst du ein String-Notizbuch. Wenn du Text nur blitzschnell lesen oder als festen Text im Code definieren willst, benutzt du die &str-Leselupe.


3. Die Kernoperationen: Arbeiten mit Text

Lass uns nun die Ärmel hochkrempeln und schauen, wie wir in Rust mit diesen beiden Typen arbeiten können. Wir schauen uns die vier wichtigsten Werkzeuge an.

3.1 Text hinzufügen: .push() und .push_str()

Wenn wir ein veränderbares String-Notizbuch haben, können wir Text an das Ende anhängen. Rust unterscheidet dabei sehr streng, ob wir ein einzelnes Zeichen (einen char) oder ein ganzes Wort (einen &str-Slice) hinzufügen wollen.

  • .push() (drücken): Hängt genau ein einzelnes Zeichen (char) an. Ein char wird in Rust immer in einfache Anführungszeichen gesetzt, zum Beispiel 'A' oder '!'.
  • .push_str() (String-drücken): Hängt eine Zeichenkette (&str) an. Eine Zeichenkette wird in doppelte Anführungszeichen gesetzt, zum Beispiel "Hallo".

Hier ist ein komplettes, kompilierbares Programm, das diesen Unterschied zeigt:

fn main() {
    // 1. Wir erstellen ein veränderbares Notizbuch aus einem festen Text-Literal.
    //    Dazu nutzen wir String::from(), um das Plakat in ein Notizbuch umzuwandeln.
    let mut text = String::from("Hallo");

    // 2. Wir hängen eine ganze Zeichenkette (einen Slice) am Ende an.
    //    Beachte die doppelten Anführungszeichen!
    text.push_str(" Welt");
    // Der String enthält jetzt: "Hallo Welt"

    // 3. Wir hängen ein einzelnes Zeichen (ein Ausrufezeichen) an.
    //    Beachte die einfachen Anführungszeichen!
    text.push('!');
    // Der String enthält jetzt: "Hallo Welt!"

    // 4. Wir geben das fertige Notizbuch auf dem Bildschirm aus.
    println!("{}", text);
}

Zeile-für-Zeile-Erklärung:

  • let mut text = String::from("Hallo");: Wir erstellen eine veränderbare Variable text. Da wir mut verwenden, dürfen wir das Notizbuch verändern. String::from("Hallo") nimmt den Text "Hallo" (der ein unveränderliches Plakat im Programmspeicher war) und kopiert ihn auf den Heap in ein neues, veränderbares String-Notizbuch.
  • text.push_str(" Welt");: Wir rufen die Methode push_str auf. Sie liest den Text " Welt" und heftet ihn an das Ende unseres Heap-Strings an.
  • text.push('!');: Wir rufen die Methode push auf. Da es sich um ein einzelnes Zeichen handelt, verwenden wir einfache Anführungszeichen. Das Ausrufezeichen wird ganz hinten angefügt.
  • println!("{}", text);: Das Makro println! gibt das Ergebnis auf der Konsole aus.

3.2 Den Text aufräumen: .trim()

Wenn Benutzer etwas in ein Programm eintippen oder wir Daten aus einer Datei lesen, schleichen sich oft unerwünschte Leerzeichen, Tabulatoren oder unsichtbare Zeilenumbrüche (wenn der Benutzer die Eingabetaste drückt) am Anfang oder Ende des Textes ein.

Die Methode .trim() verhält sich wie ein digitaler Staubsauger: Sie saugt alle Leerzeichen und unsichtbaren Steuerzeichen am Anfang und am Ende des Textes weg. Das Geniale daran: .trim() verändert den Originaltext nicht und kopiert ihn auch nicht aufwendig um. Stattdessen gibt es uns eine neue Leselupe (&str), die einfach den sauberen Bereich in der Mitte scharf stellt!

fn main() {
    // Ein Text mit viel störendem "Schmutz" (Leerzeichen und Zeilenumbruch \n)
    let schmutziger_text = "   Thorsten \n";

    // Der Staubsauger wird aktiv!
    // sauberer_text ist eine leichte &str-Lupe auf den sauberen Teil.
    let sauberer_text = schmutziger_text.trim();

    // Wir geben das Ergebnis aus. Umgeben von Klammern, damit wir Leerzeichen sehen würden.
    println!("Vorher: '[{}]'", schmutziger_text);
    println!("Nachher: '[{}]'", sauberer_text);
}

Ausgabe des Programms:

Vorher: '[   Thorsten 
]'
Nachher: '[Thorsten]'

3.3 Der Zaubertrick: .parse()

Stell dir vor, du fragst den Benutzer nach seinem Alter. Er tippt auf seiner Tastatur die Tasten 3 und 0 ein. Für den Computer ist das erst einmal nur Text: "30". Du kannst mit dem Text "30" aber nicht rechnen. Du kannst nicht sagen: "30" + 1.

Wir müssen den Text in eine echte Zahl (wie einen i32-Integer) umwandeln. Das nennen wir Parsen (Analysieren). In Rust gibt es dafür die Allzweck-Methode .parse().

Da beim Umwandeln Fehler passieren können (was ist, wenn der Benutzer "dreiunddreißig" oder "Zwieback" eingibt?), gibt uns .parse() nicht direkt die Zahl zurück, sondern verpackt das Ergebnis in ein Sicherheits-Paket namens Result. Dieses Paket kann zwei Zustände haben:

  1. Ok(zahl): Die Umwandlung hat geklappt, hier ist deine Zahl!
  2. Err(fehler): Das war keine Zahl! Ich konnte den Text nicht umwandeln.

Hier zeigen wir dir, wie du das Paket sicher mit einem match-Ausdruck auspackst:

fn main() {
    let eingabe_text = "42";

    // Wir versuchen, den Text in eine Ganzzahl vom Typ i32 zu verwandeln.
    // Da parse() flexibel ist, müssen wir Rust sagen, welchen Typ wir wollen.
    // Das machen wir durch die Typangabe `::<i32>` bei parse.
    match eingabe_text.parse::<i32>() {
        // Fall 1: Die Verwandlung hat geklappt!
        Ok(zahl) => {
            println!("Erfolg! Die Zahl ist: {}", zahl);
            let naechstes_jahr = zahl + 1;
            println!("Nächstes Jahr bist du {} Jahre alt.", naechstes_jahr);
        }
        // Fall 2: Der Text war keine gültige Zahl!
        Err(fehler) => {
            println!("Fehler! Das war keine Zahl. Grund: {}", fehler);
        }
    }
}

Was passiert, wenn ein Fehler auftritt?

Lass uns ein Beispiel konstruieren, bei dem der Compiler uns zeigt, wie sicher Rust ist. Wenn wir versuchen, den Text "Kartoffelsalat" in eine Zahl zu parsen, springt das Programm sofort in den Err-Zweig:

fn main() {
    let ungueltiger_text = "Kartoffelsalat";

    // Diesmal nutzen wir .unwrap_or(), eine Abkürzung:
    // Wenn es klappt, nimm die Zahl. Wenn nicht, nimm einen Standardwert (z.B. 0).
    let alter: i32 = ungueltiger_text.parse().unwrap_or(0);

    println!("Da die Eingabe ungültig war, setzen wir das Alter auf: {}", alter);
}

4. Text formatieren und ausgeben: println! vs. format!

Wenn wir Daten ausgeben oder Textnachrichten zusammenstellen wollen, nutzen wir Formatierungs-Werkzeuge. Die beiden wichtigsten heißen println! und format!.

println!: Der direkte Postbote auf den Bildschirm

Das Makro println! (ausgesprochen: print line, also „Zeile drucken“) nimmt einen Text, setzt Werte in die geschweiften Klammern {} ein und gibt das Ergebnis direkt in deiner Konsole aus. Am Ende springt es automatisch in eine neue Zeile.

fn main() {
    let name = "Jonas";
    let punkte = 95;
    
    // Die geschweiften Klammern {} sind Platzhalter.
    // Rust setzt die Variablen der Reihe nach dort ein.
    println!("Spieler {} hat {} Punkte erreicht!", name, punkte);
}

format!: Der Briefentwurf im Speicher

Manchmal willst du einen Text zusammenbauen, ihn aber noch nicht ausgeben. Vielleicht willst du ihn in einer Datei speichern oder an eine andere Funktion übergeben.

Dafür gibt es das Makro format!. Es funktioniert exakt genauso wie println!, gibt den Text aber nicht auf dem Bildschirm aus, sondern gibt dir ein neues String-Notizbuch zurück, in dem der fertige Text steht.

fn main() {
    let vorname = "Mia";
    let nachname = "Müller";

    // format! baut den Text zusammen und speichert ihn in der Variable 'voller_name'.
    // Es wird nichts auf dem Bildschirm ausgegeben!
    let voller_name: String = format!("{} {}", vorname, nachname);

    // Jetzt können wir mit dem String arbeiten
    println!("Der gespeicherte Name lautet: {}", voller_name);
}

5. Die UTF-8-Falle: Die unsichtbare Gefahr von Umlauten und Emojis

Nun kommen wir zu einem Thema, bei dem selbst erfahrene Programmierer, die von Sprachen wie C++ oder Java kommen, manchmal ins Stolpern geraten. Es geht um die Frage: Warum darf ich in Rust nicht einfach den 3. Buchstaben eines Textes mit text[2] abfragen?

Das Geheimnis von UTF-8

Rust speichert alle Strings im sogenannten UTF-8-Format. Das ist eine internationale Codierung für Schriftzeichen. Stell dir den Speicher wie ein langes Bücherregal vor. Jedes Fach im Regal ist genau 1 Byte (8 Bit) groß.

  • Einfache ASCII-Zeichen (wie A, b, c, 1, ?) sind wie schmale Hefte. Sie passen genau in ein einziges Fach. Sie verbrauchen 1 Byte.
  • Sonderzeichen und Umlaute (wie ä, ö, ü, ß) sind wie dickere Bücher. Sie brauchen zwei Fächer im Regal. Sie verbrauchen 2 Bytes.
  • Emojis und asiatische Schriftzeichen (wie 🦀 oder ⛩️) sind wie riesige Lexika. Sie verbrauchen 3 bis 4 Bytes.
Zeichen:      H     a     l     l     o     ä      🦀
Byte-Größe:  [1]   [1]   [1]   [1]   [1]   [2]    [4]
Bytes gesamt: 1     2     3     4     5     6 7    8 9 10 11

Warum einfache Indizierung (text[i]) gefährlich ist

Wenn Rust es erlauben würde, mit let buchstabe = text[6]; auf ein einzelnes Byte zuzugreifen, könnte folgendes passieren: Du greifst mitten in das dicke Buch des Umlauts ä oder des Emojis 🦀 hinein! Du reißt das Zeichen in der Mitte auseinander. Das Ergebnis wäre kein Buchstabe mehr, sondern digitaler Müll.

Um zu verhindern, dass dein Programm dadurch fehlerhaften Text erzeugt oder abstürzt, verbietet Rust den Zugriff über text[i] komplett! Wenn du es versuchst, verweigert der Compiler den Dienst.

Der Beweis: Ein Absturz durch Zerschneiden

Du kannst einen String in Rust zwar mit einer Bereichsangabe zerschneiden (Slicing), aber wenn du dabei die Grenzen eines Zeichens missachtest, stürzt dein Programm zur Laufzeit mit einer Panik ab.

Schau dir diesen Code an. Er zeigt, was passiert, wenn man unsachgemäß schneidet:

fn main() {
    // Das Zeichen 'ä' belegt in UTF-8 genau 2 Bytes.
    let text = "äpfel"; 

    // Wir versuchen, eine Lupe auf das allererste Byte (Index 0 bis 1) zu legen.
    // Aber Achtung: Das 'ä' geht von Byte 0 bis Byte 2!
    // Wir schneiden also mitten durch das 'ä' hindurch!
    let kaputter_schnitt = &text[0..1]; 

    println!("{}", kaputter_schnitt);
}

Wenn wir dieses Programm ausführen, bricht Rust sofort ab und gibt uns eine klare Fehlermeldung aus:

thread 'main' panicked at 'byte index 1 is not a char boundary; it is inside 'ä' (bytes 0..2) of `äpfel`'

„Byte-Index 1 ist keine Zeichengrenze, sondern liegt mitten im ‘ä’!“ Rust schützt sich selbst vor kaputtem Text.


Die Rettung: Wie machen wir es richtig?

Wie greifen wir denn nun sicher auf die einzelnen Zeichen zu, wenn wir nicht wissen, wie viele Bytes sie verbrauchen?

Methode 1: Die Zeichen-Schablone .chars()

Wir benutzen die Methode .chars(). Sie verhält sich wie eine Schablone, die automatisch erkennt, wie breit jedes Zeichen ist. Sie springt von Zeichen zu Zeichen – egal, ob es 1 Byte oder 4 Bytes groß ist.

fn main() {
    let text = "äffchen 🦀";

    // .chars() gibt uns einen Iterator (eine Perlenkette) der echten Zeichen.
    // Mit einer for-Schleife können wir diese sicher nacheinander herausholen:
    for zeichen in text.chars() {
        println!("Zeichen: {}", zeichen);
    }
}

Ausgabe:

Zeichen: ä
Zeichen: f
Zeichen: f
Zeichen: c
Zeichen: h
Zeichen: e
Zeichen: n
Zeichen:  
Zeichen: 🦀

Kein Absturz, keine kaputten Buchstaben! Jedes Zeichen wurde perfekt erkannt.

Methode 2: Ein bestimmtes Zeichen gezielt herausholen

Wenn du wirklich nur das zum Beispiel 5. Zeichen (Index 4) haben möchtest, kannst du mit .nth() (dem n-ten Element) danach fragen. Beachte, dass dies eine Suche von vorne startet (da Rust bei UTF-8 die Bytes durchzählen muss) und uns ein Option-Paket zurückgibt, falls der Text kürzer war als gewünscht:

fn main() {
    let text = "Rust 🦀";

    // Wir holen das 6. Zeichen (Index 5, da wir bei 0 anfangen zu zählen).
    // .nth() gibt uns ein Option<char>. Wir packen es mit match aus.
    match text.chars().nth(5) {
        Some(emoji) => println!("Das 6. Zeichen ist das Emoji: {}", emoji),
        None => println!("Der Text ist zu kurz!"),
    }
}

6. Verweis auf Übungen

Du hast nun das theoretische Fundament für den Umgang mit Text in Rust gelernt! Theorie ist gut, aber echtes Verständnis kommt erst durch die Praxis.

Öffne jetzt den Ordner exercises/03_strings/ in deiner Arbeitsumgebung. Dort findest du vorbereitete Aufgaben, in denen du:

  1. Ein einfaches Text-Eingabe-Programm schreibst und die Eingaben säuberst (.trim()).
  2. Benutzereingaben in Zahlen konvertierst (.parse()) und Fehleingaben abfängst.
  3. Einen Text-Formatierer baust, der mit println! und format! arbeitet.
  4. Eine sichere Funktion schreibst, die Emojis und Umlaute zählt, ohne abzustürzen.

Viel Spaß beim Coden! Wenn du Fragen hast oder der Compiler meckert, lies dir die Fehlermeldungen genau durch – sie sind in Rust deine besten Freunde und sagen dir fast immer ganz genau, wie du den Code reparieren kannst.


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.

Kapitel 05 - Hardware-Sicht: Die Physik der Zeichenketten

Willkommen im Maschinenraum! Wenn du aus der C-, C++- oder Assembler-Ecke kommst, hast du dich wahrscheinlich schon gefragt: „Warum macht Rust es mir mit Zeichenketten so schwer? Warum gibt es String und &str? Kann ein String nicht einfach ein simples Null-terminiertes Byte-Array sein wie in C?“

Die Antwort lautet: Ja, könnte er. Aber dann hätten wir wieder die gleichen Sicherheitslücken, Pufferüberläufe und Performance-Fallen, die die IT-Welt seit Jahrzehnten plagen. Rust geht einen anderen Weg, der maximale Speichersicherheit bei gleichzeitig kompromissloser Hardware-Effizienz garantiert.

In diesem Abschnitt legen wir die Samthandschuhe beiseite. Wir schnappen uns das virtuelle Oszilloskop und schauen uns an, wie Zeichenketten physikalisch im Arbeitsspeicher (RAM) liegen, wie der Prozessor (CPU) sie verarbeitet und was unter der Haube passiert, wenn du Text manipulierst. Schnall dich an, wir gehen auf Byte-Ebene!


1. Das Speicherlayout von String im Detail

Ein dynamischer String ist in Rust ein sogenannter Smart Pointer (intelligenter Zeiger), der die Eigentumsrechte (Ownership) über einen auf dem Heap allokierten Speicherbereich besitzt.

1.1 Die Stack-Komponente (Der Zettel auf dem Schreibtisch)

Wenn du eine Variable vom Typ String deklarierst, reserviert Rust dafür auf dem Stack exakt 24 Bytes (auf einer modernen 64-Bit-Architektur). Diese 24 Bytes sind in drei exakt gleich große Felder von jeweils 8 Bytes (64 Bits) unterteilt:

  1. Pointer (ptr): Eine 64-Bit-Speicheradresse, die auf den Anfang des Speicherbereichs im Heap zeigt, in dem die eigentlichen Textdaten liegen.
  2. Capacity (cap): Eine 64-Bit-Ganzzahl ohne Vorzeichen (usize), die angibt, wie viel Heap-Speicher (in Bytes) der Allocator für diesen String reserviert hat.
  3. Length (len): Eine 64-Bit-Ganzzahl ohne Vorzeichen (usize), die angibt, wie viele Bytes des reservierten Heap-Speichers aktuell tatsächlich mit gültigen UTF-8-Zeichen gefüllt sind.

Die Alltagsanalogie: Der Büro-Zettel und der Lagerraum

Note

Alltagsanalogie: Stell dir vor, du sitzt an deinem Schreibtisch (Stack). Auf deinem Schreibtisch liegt ein kleiner Notizzettel (die 24 Bytes des String). Auf diesem Zettel stehen drei Informationen:

  1. Lagerplatz-Adresse: „Halle B, Regal 4“ (der Zeiger ptr).
  2. Kapazität: „Maximal 10 Kisten passen in dieses Regal“ (die Kapazität cap).
  3. Aktueller Bestand: „Es stehen dort gerade 4 Kisten“ (die Länge len).

Das Regal selbst steht weit entfernt im riesigen Zentrallager (dem Heap). Wenn du nun eine neue Kiste ins Regal stellst, musst du nicht deinen Schreibtisch vergrößern. Der Notizzettel bleibt exakt gleich groß. Du streichst lediglich den aktuellen Bestand „4“ durch und schreibst eine „5“ hin.

1.2 Die Heap-Komponente (Der Lagerplatz)

Auf dem Heap liegen die eigentlichen Textzeichen als kontinuierliche Folge von Bytes. Wichtig ist: Diese Bytes sind nicht Null-terminiert wie in C (wo ein \0-Byte das Ende markiert). Rust benötigt kein Null-Byte, da die genaue Länge (len) direkt im Stack-Zettel gespeichert ist. Das verhindert die berüchtigten „Buffer Overreads“, bei denen ein Programm über das Ende des Strings hinausliest, weil das Null-Byte überschrieben wurde.

Hier ist die visuelle Repräsentation des Speicherlayouts für let s = String::from("Rust");:

graph TD
    subgraph Stack [Stack - Feste 24 Bytes]
        direction LR
        ptr["Pointer (ptr) <br> 8 Bytes <br> zeigt auf Heap"] 
        cap["Capacity (cap) <br> 8 Bytes <br> Wert: 4"]
        len["Length (len) <br> 8 Bytes <br> Wert: 4"]
    end
    
    subgraph Heap [Heap - Dynamischer Speicher]
        data["'R' (0x52) | 'u' (0x75) | 's' (0x73) | 't' (0x74)"]
    end
    
    ptr --> data

1.3 Speicher-Inspektion mit kompilierbarem Code

Lass uns die Theorie in der Praxis überprüfen! Wir schreiben ein kleines Programm, das die Größe der Stack-Daten misst und die genauen Adressen ausgibt.

// Dieser Code ist voll funktionsfähig und kann direkt ausgeführt werden.
use std::mem::size_of;

fn main() {
    // Wir erstellen einen veränderlichen String auf dem Heap.
    let s = String::from("Rust");
    
    // 1. Wir messen die Größe der String-Struktur auf dem Stack.
    // Da wir auf einer 64-Bit-Architektur arbeiten (8 Bytes pro Zeiger/usize),
    // erwarten wir hier exakt 24 Bytes (3 * 8 Bytes).
    println!("Größe des String-Objekts auf dem Stack: {} Bytes", size_of::<String>());
    
    // 2. Wir lassen uns die Speicheradresse des Stack-Objekts anzeigen.
    // Das ist der Ort, an dem unser 'Notizzettel' liegt.
    println!("Speicheradresse auf dem Stack: {:p}", &s);
    
    // 3. Wir lassen uns den Zeiger auf die echten Heap-Daten anzeigen.
    // Die Methode .as_ptr() gibt uns den rohen Zeiger (Pointer) aus der Struktur.
    println!("Speicheradresse der Daten auf dem Heap: {:p}", s.as_ptr());
    
    // 4. Länge und Kapazität auslesen.
    println!("Länge (len): {}", s.len());
    println!("Kapazität (cap): {}", s.capacity());
}

Erklärung der Code-Zeilen:

  • In Zeile 2 importieren wir size_of aus dem Modul std::mem. Diese Funktion verrät uns, wie viel Speicher ein Typ zur Kompilierzeit auf dem Stack einnimmt.
  • In Zeile 6 erzeugen wir den String "Rust". Auf dem Heap werden dafür 4 Bytes allokiert (da “Rust” aus 4 ASCII-Zeichen besteht, die in UTF-8 jeweils 1 Byte groß sind).
  • In Zeile 11 nutzen wir size_of::<String>(), um die Stack-Größe zu ermitteln. Sie wird auf 64-Bit-Systemen immer 24 sein.
  • In Zeile 15 gibt uns {:p} die Speicheradresse der Stack-Variable s selbst aus.
  • In Zeile 19 nutzen wir s.as_ptr(). Das greift direkt auf das erste Feld (ptr) unserer Stack-Struktur zu und gibt die Adresse auf dem Heap aus. Wenn du die Ausgabe mit der Stack-Adresse vergleichst, wirst du sehen, dass die Heap-Adresse in einem völlig anderen Adressbereich liegt (oft viel weiter „oben“ oder „unten“ im virtuellen Adressraum).

2. Der Fat Pointer: &str (String-Slice)

Jetzt wird es spannend. Was ist ein &str? Oft wird er als „Referenz auf einen String“ bezeichnet, aber das greift zu kurz. Ein &str ist ein Fat Pointer (breiter Zeiger).

2.1 Warum ein nacktes str nicht existieren kann

In Rust ist str ein Typ unbestimmter Größe (Dynamically Sized Type, DST). Da Text beliebig lang sein kann, kann der Compiler zur Kompilierzeit nicht wissen, wie viele Bytes er auf dem Stack für ein nacktes str reservieren müsste. Ein Typ, dessen Größe zur Kompilierzeit unbekannt ist, darf in Rust nicht direkt auf dem Stack liegen.

Die Lösung: Wir nutzen immer eine Referenz darauf, also &str. Und diese Referenz ist kein normaler, einfacher Zeiger (der nur 8 Bytes groß wäre), sondern ein Fat Pointer von exakt 16 Bytes (auf 64-Bit-Systemen).

2.2 Die Anatomie des Fat Pointers

Die 16 Bytes von &str teilen sich in zwei Felder auf:

  1. Pointer (ptr) (8 Bytes): Zeigt auf die Startadresse des Textes im Speicher (das kann im Heap eines String sein, oder im statischen Speicher, wie wir gleich sehen werden).
  2. Length (len) (8 Bytes): Gibt an, wie viele Bytes ab dieser Startadresse zu diesem Slice gehören.

Beachte: Ein &str besitzt keine Kapazität (cap). Warum? Weil ein Slice nur eine Sicht (View) auf bereits existierenden Speicher ist. Er darf diesen Speicher nicht vergrößern oder freigeben. Er ist ein reiner Beobachter.

Die Alltagsanalogie: Der Lieferschein

Note

Alltagsanalogie: Stell dir vor, du hast keinen eigenen Lagerraum gemietet. Stattdessen gibt dir dein Kollege einen Lieferschein (den Fat Pointer &str). Auf diesem Zettel steht:

  1. „Gehe zu Halle B, Regal 4, Kiste Nr. 2“ (der Zeiger ptr).
  2. „Du darfst von dort an genau 3 Kisten inspizieren“ (die Länge len).

Du darfst keine neuen Kisten anbauen und du darfst das Regal nicht wegwerfen. Du hast nur eine zeitlich begrenzte Sicht auf einen Ausschnitt des Regals.

Hier ist die visuelle Darstellung eines Slices let slice: &str = &s[1..3];, der aus unserem vorherigen String s (“Rust”) erzeugt wurde:

graph TD
    subgraph String_s [String s - Stack]
        s_ptr["ptr"]
        s_cap["cap: 4"]
        s_len["len: 4"]
    end

    subgraph Slice_slice [Slice &str - Stack]
        slice_ptr["ptr"]
        slice_len["len: 2"]
    end
    
    subgraph Heap [Heap]
        char_R["'R'"]
        char_u["'u'"]
        char_s["'s'"]
        char_t["'t'"]
    end
    
    s_ptr --> char_R
    slice_ptr --> char_u

Wie du siehst, zeigt slice_ptr direkt auf das Zeichen 'u' (das zweite Byte im Heap) und hat eine Länge von 2. Damit repräsentiert der Slice den Text "us".

2.3 Speicher-Inspektion des Fat Pointers

Schreiben wir auch hierfür ein verständliches Testprogramm:

use std::mem::size_of;

fn main() {
    let s = String::from("Rust-Lehrbuch");
    
    // Wir erzeugen einen Slice, der das Wort "Lehrbuch" ausschneidet.
    // "Rust-Lehrbuch" -> "Rust-" sind 5 Bytes (Indizes 0 bis 4).
    // Ab Index 5 ("L") bis Index 13 ("h") liegt "Lehrbuch" (8 Bytes).
    let slice: &str = &s[5..13];
    
    println!("Größe des &str auf dem Stack: {} Bytes", size_of::<&str>());
    
    // Die Startadresse des ursprünglichen Strings auf dem Heap:
    println!("Startadresse des Strings s: {:p}", s.as_ptr());
    
    // Die Startadresse des Slices:
    // Da "Lehrbuch" bei Byte-Index 5 beginnt, sollte diese Adresse
    // exakt um 5 Bytes nach der Adresse von s liegen!
    println!("Startadresse des Slices slice: {:p}", slice.as_ptr());
    
    println!("Länge des Slices: {}", slice.len());
}

Erklärung der Ausgabe: Wenn du dieses Programm ausführst, wirst du sehen, dass die Adresse des Slices im Hexadezimalsystem exakt 5 höher ist als die des ursprünglichen Strings. Aus 0x55d...0a0 wird 0x55d...0a5. Der Fat Pointer verweist also direkt mitten in die Heap-Allokation des String!


3. String-Literale im statischen Programmspeicher (.rodata)

Was passiert eigentlich, wenn wir im Code schreiben: let literal = "Hallo Welt";? Wo kommt dieser Text her? Er liegt weder auf dem Stack (außer dem Fat Pointer selbst), noch wird er zur Laufzeit dynamisch auf dem Heap allokiert.

3.1 Das .rodata-Segment

Wenn der Rust-Compiler dein Programm in eine ausführbare Datei übersetzt, sammelt er alle im Quellcode hartkodierten String-Literale und packt sie gesammelt in ein spezielles Segment der Binärdatei: das .rodata-Segment (Read-Only Data, schreibgeschützte Daten).

Wenn dein Betriebssystem das Programm startet, lädt es diese Binärdatei in den Arbeitsspeicher. Der Bereich, in dem das .rodata-Segment landet, wird vom Betriebssystem und der MMU (Memory Management Unit) der CPU als schreibgeschützt markiert.

3.2 Die Lebensdauer 'static

Ein String-Literal hat in Rust den Typ &'static str. Das Lebensdauer-Annotation 'static ist das Versprechen an den Compiler, dass diese Daten für die gesamte Laufzeit des Programms im Speicher existieren. Sie können niemals ungültig werden, weil sie fest im Binärcode eingebrannt sind.

Caution

Weil das .rodata-Segment schreibgeschützt ist, würde jeder Versuch, diese Daten direkt im Speicher zu verändern, zu einem sofortigen Programmabsturz durch das Betriebssystem führen (ein klassischer Segmentation Fault). Rust verhindert dies elegant, indem der Typ &str generell keine Schreibzugriffe erlaubt.

Die Alltagsanalogie: Die Inschrift im Museum

Note

Alltagsanalogie: Ein String-Literal ist wie eine in Stein gemeißelte Inschrift an der Wand eines historischen Museums. Jeder Besucher kann sie lesen (schreibgeschützt). Die Inschrift ist immer da, solange das Museum existiert (Lebensdauer 'static). Du kannst sie nicht mitnehmen oder verändern. Wenn du den Text ändern willst, musst du ihn auf einen Zettel abschreiben und dort bearbeiten (was dem Kopieren in einen String auf dem Heap entspricht).


4. UTF-8 unter der Haube: Die CPU-Perspektive

Rust-Strings sind standardmäßig immer als UTF-8 kodiert. Das ist ein fantastischer Standard für Internationalisierung, bringt aber aus Sicht der CPU einige drastische Konsequenzen mit sich.

4.1 Variable Byte-Breite

UTF-8 ist eine Unicode-Kodierung mit variabler Breite. Das bedeutet, dass ein einzelnes logisches Zeichen (char) im Speicher zwischen 1 und 4 Bytes groß sein kann:

  • Englische Standardbuchstaben (ASCII): 1 Byte (z. B. 'a' -> 0x61)
  • Deutsche Umlaute und Akzente: 2 Bytes (z. B. 'ä' -> 0xC3 0xA4)
  • Asiatische Schriftzeichen und Symbole: 3 Bytes (z. B. '€' -> 0xE2 0x82 0xAC)
  • Emojis: 4 Bytes (z. B. '🦀' -> 0xF0 0x9F 0xA6 0x80)

4.2 Warum die O(1)-Indexierung s[0] verboten ist

In Sprachen wie C++ oder Java kannst du oft über s[0] auf das erste Zeichen zugreifen. Viele Programmierer nehmen an, dass das eine extrem billige Operation ist, die in konstanter Zeit $\mathcal{O}(1)$ abläuft. Das ist aber nur der Fall, wenn jedes Zeichen exakt gleich groß ist!

Wenn ein String Zeichen unterschiedlicher Breite enthält, kann die CPU nicht im Voraus wissen, an welcher Byte-Adresse das n-te Zeichen beginnt.

Lass uns ein Beispiel anschauen: let s = String::from("äpfel");

  • 'ä' benötigt 2 Bytes (0xC3 0xA4).
  • 'p' benötigt 1 Byte (0x70).
  • Wenn du das Zeichen bei Index 1 haben willst, ist das 'p'. Aber im Speicher liegt es an Byte-Offset 2, nicht an Offset 1! An Offset 1 liegt das zweite Byte des Umlauts 'ä', was für sich genommen ungültiger Zeichensalat is.

Um das n-te Zeichen zu ermitteln, müsste Rust den String von Anfang an Byte für Byte durchlaufen und die UTF-8-Längenindikatoren analysieren. Das wäre eine Schleife mit einer Laufzeit von $\mathcal{O}(N)$ (linear zur Länge des Strings).

Da Rust eine Systemsprache ist und dem Prinzip „Keine versteckten Performance-Kosten“ folgt, verbietet der Compiler den direkten Indexzugriff mit Zahlen.

4.3 Der Compilerfehler im Rampenlicht

Versuchen wir trotzdem, einen String zu indexieren, um zu sehen, wie uns der Compiler sanft (aber bestimmt) zurückweist:

fn main() {
    let s = String::from("Rust");
    // Wir versuchen, das erste Zeichen über den Index 0 zu holen.
    let c = s[0]; 
}

Wenn wir versuchen, diesen Code zu kompilieren, bricht der Compiler mit folgendem Fehler ab:

error[E0277]: the type `String` cannot be indexed by `{integer}`
 --> src/main.rs:4:13
  |
4 |     let c = s[0];
  |             ^^^^ `String` cannot be indexed by `{integer}`
  |
  = help: the trait `Index<{integer}>` is not implemented for `String`

Was sagt uns dieser Fehler? Der Compiler teilt uns mit, dass der Trait Index für den Typ String mit einer Ganzzahl als Parameter nicht implementiert ist. Das ist eine bewusste Designentscheidung der Entwickler der Standardbibliothek, um uns vor Performance-Fallen zu schützen.

Wie reparieren wir das?

Wenn wir das erste Zeichen wollen, müssen wir explizit über den Iterator .chars() gehen:

fn main() {
    let s = String::from("Rust");
    
    // .chars() gibt uns einen Iterator über die echten Unicode-Zeichen (char).
    // .next() holt das erste Element (vom Typ Option<char>).
    if let Some(first_char) = s.chars().next() {
        println!("Das erste Zeichen ist: {}", first_char);
    }
}

4.4 .chars() vs. .bytes() auf Prozessorebene

Wie verhalten sich diese beiden Methoden im Inneren des Prozessors? Hier zeigt sich ein gigantischer Unterschied in der CPU-Auslastung.

.bytes(): Der Turbolader

Wenn du .bytes() aufrufst, gibt Rust einen Iterator zurück, der stur Byte für Byte durch den Speicher wandert.

  • CPU-Ablauf: Die CPU muss lediglich den Wert des Zeigers um 1 erhöhen (ptr = ptr + 1) und das Byte an der Adresse auslesen.
  • Hardware-Sicht: Das ist eine simple Speicherleseoperation ohne jegliches Branching (Verzweigungen). Die CPU-Pipeline kann perfekt vorausarbeiten (Instruction Prefetching) und der Code läuft mit maximaler Geschwindigkeit.

.chars(): Der Schwerstarbeiter

Wenn du .chars() aufrufst, muss Rust den UTF-8-Datenstrom dekodieren.

  • CPU-Ablauf: Der Iterator liest das erste Byte. Dann prüft er die Bitmaske dieses Bytes, um herauszufinden, wie viele Folgebytes gelesen werden müssen:
    • Fängt das Byte mit Bit 0 an? (ASCII, 1 Byte)
    • Fängt es mit 110 an? (2 Bytes)
    • Fängt es mit 1110 an? (3 Bytes)
    • Fängt es mit 11110 an? (4 Bytes)
  • Hardware-Sicht: Auf Assembly-Ebene bedeutet das zahlreiche Bitverschiebungen (SHR), logische Und-Verknüpfungen (AND) und vor allem bedingte Sprünge (CMP und JNZ). Wenn du einen Text mit vielen verschiedenen Zeichenbreiten verarbeitest, kann die Sprungvorhersage (Branch Prediction) der CPU fehlschlagen (Branch Misprediction), was die CPU-Pipeline leert und den Prozessor spürbar ausbremst.

5. Der Allocator und das dynamische Wachstum von String

Was passiert auf Betriebssystem- und Hardwareebene, wenn wir einen String wachsen lassen, zum Beispiel mit s.push_str("mehr text")?

5.1 Das Speicherwachstum (Reallokation)

Wenn du einen neuen String erstellst, reserviert der Speicher-Allocator (z. B. jemalloc oder der System-Allocator) einen bestimmten Speicherblock auf dem Heap (die Kapazität cap). Solange du Zeichen hinzufügst und die Länge len die Kapazität cap nicht überschreitet, ist alles wunderbar: Rust schreibt die Daten einfach in die bereits reservierten Bytes und erhöht len auf dem Stack.

Sobald aber len + neue_bytes > cap eintritt, ist das Regal voll. Da der Speicher direkt hinter unserem Heap-Block von anderen Variablen belegt sein könnte, können wir unseren Speicherbereich nicht einfach nach rechts vergrößern.

Nun läuft folgender Prozess ab:

  1. Neue Kapazität berechnen: Rust verdoppelt in der Regel die bisherige Kapazität (Wachstumsfaktor 2). Wenn die Kapazität vorher 4 Bytes war, wird nach einer neuen Allokation für 8 Bytes angefragt.
  2. Speicher anfordern: Der Allocator wird aufgerufen, um einen neuen freien Speicherblock auf dem Heap mit der neuen Größe zu finden.
  3. Daten kopieren: Die bisherigen Daten werden byteweise vom alten Speicherort an den neuen Speicherort kopiert (memcpy auf CPU-Ebene).
  4. Alten Speicher freigeben: Der alte Speicherblock wird dem Allocator wieder als frei gemeldet.
  5. Stack-Informationen aktualisieren: In der Stack-Struktur des String wird der ptr auf die neue Heap-Adresse umgebogen und cap auf den neuen Wert gesetzt.

Die Alltagsanalogie: Der Umzug

Note

Alltagsanalogie: Stell dir vor, du wohnst in einer WG mit 4 Zimmern (Kapazität 4) und alle Zimmer sind belegt (Länge 4). Jetzt will ein 5. Mitbewohner einziehen. Du kannst nicht einfach ein Zimmer an das Haus anbauen, da das Grundstück daneben dem Nachbarn gehört. Also musst du eine neue, größere Wohnung mit 8 Zimmern suchen. Du packst alle deine Sachen in Kartons, fährst mit dem Möbelwagen zur neuen Wohnung, lädst alles aus und der 5. Mitbewohner zieht mit ein. Die alte Wohnung gibst du an den Vermieter zurück.

Dieser Umzug (Reallokation) ist extrem teuer! Er erfordert Betriebssystem-Aufrufe (Syscalls) und blockiert die CPU mit Kopierarbeiten. Zudem führt es zu Cache-Misses, da die Daten plötzlich an einer ganz anderen Adresse liegen.

5.2 Das Wachstum im Code beobachten

Hier ist ein praktisches Programm, das zeigt, wie sich die Heap-Adresse und die Kapazität ändern, wenn wir Zeichen anhängen:

fn main() {
    let mut s = String::new();
    
    println!("Start: Kapazität = {}, Adresse = {:p}", s.capacity(), s.as_ptr());
    
    // Wir fügen in einer Schleife 20 Zeichen einzeln hinzu
    for i in 1..=20 {
        s.push('A');
        println!(
            "Nach {} Zeichen: Länge = {}, Kapazität = {}, Adresse = {:p}",
            i,
            s.len(),
            s.capacity(),
            s.as_ptr()
        );
    }
}

Was wir in der Ausgabe beobachten:

  • Zu Beginn ist die Kapazität 0 und der Zeiger zeigt ins Nirgendwo (ein spezieller Sentinel-Zeiger 0x1 oder 0x0, da noch kein Heap-Speicher allokiert wurde).
  • Beim ersten push wird Speicher allokiert (z. B. Kapazität 4 oder 8, je nach OS-Implementierung).
  • Sobald die Anzahl der Zeichen die Kapazität übersteigt, springt die Kapazität auf das Doppelte an (z. B. von 8 auf 16).
  • Achte auf die ausgegebene Speicheradresse: Bei fast jeder Kapazitätsänderung ändert sich die Hexadezimaladresse komplett! Das ist der Beweis, dass der String auf dem Heap physisch umgezogen ist.

5.3 Optimierung: with_capacity

Wenn du im Voraus weißt, wie groß dein String ungefähr wird, kannst du die teuren Reallokationen komplett vermeiden, indem du den Speicher direkt im Voraus reservierst:

fn main() {
    // Wir reservieren sofort Platz für 20 Bytes auf dem Heap.
    let mut s = String::with_capacity(20);
    
    let start_ptr = s.as_ptr();
    println!("Start-Adresse: {:p}", start_ptr);
    
    for _ in 0..20 {
        s.push('A');
    }
    
    let end_ptr = s.as_ptr();
    println!("End-Adresse:   {:p}", end_ptr);
    
    // Da wir im Voraus genug Platz reserviert haben,
    // sollte sich die Speicheradresse kein einziges Mal geändert haben!
    assert_eq!(start_ptr, end_ptr);
    println!("Erfolg: Kein Speicherumzug notwendig!");
}

Mit String::with_capacity(20) sparen wir uns alle Zwischenschritte. Die CPU dankt es uns mit maximaler Performance und null Kopier-Overhead.