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) 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.