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

Praxisteil & Übungen: Zeichenketten (Strings)

In diesem Praxisteil wenden wir die theoretischen Konzepte über Zeichenketten an. Wir bauen einen Sensor-Protokoll-Parser für ein Smart-Home-Gateway. Dabei lernen wir die wichtigsten Methoden der Typen String und &str kennen.


1. Didaktische Analogien zur Veranschaulichung

Um den Unterschied der Typen zu verstehen, helfen uns zwei einprägsame Alltagsanalogien:

Der Notizblock vs. Die Schablone

  • String (Heap): Stellen Sie sich einen String wie einen Notizblock vor. Sie sind der Besitzer dieses Notizblocks. Sie können neue Seiten anheften (push_str), Einträge ausradieren (clear) oder Text in die Mitte einfügen. Das Papier liegt auf dem Tisch (Heap-Speicher) und kann beliebig wachsen. Auf dem Stack speichern Sie nur eine Notiz darüber, wo der Block liegt (Pointer), wie viel Text aktuell aufgeschrieben ist (Länge) und wie viele Seiten noch frei sind (Kapazität).
  • &str (String-Slice): Ein &str ist wie eine Schablone oder Lesebrille. Sie besitzen das darunterliegende Buch nicht, sondern legen die Schablone nur auf einen bestimmten Textbereich. Sie können den Text lesen, aber nichts daran ändern. Diese Schablone ist extrem leichtgewichtig (Fat Pointer: Startadresse und Länge), benötigt keine neue Papierallokation (Heap-Allokation) und lässt sich blitzschnell verschieben.

Das Schneiden an UTF-8-Grenzen

UTF-8-Zeichenketten sind wie ein Band aus beschriebenen Bausteinen. Manche Buchstaben (wie Standard-ASCII-Zeichen A, B, C) belegen nur 1 Byte (einen schmalen Baustein). Besondere Zeichen (wie Umlaute ä, ö oder Emojis) belegen 2 bis 4 Byte (breite Bausteine).

  • Wenn Sie mit der Schere (Slicing-Index &s[0..1]) blind in der Mitte eines breiten Bausteins schneiden, zerstören Sie das Zeichen. Da Rust ungültigen UTF-8-Text verbietet, bringt dieser falsche Schnitt Ihr Programm zur Laufzeit sofort zum Absturz (Panic). Wir müssen daher Methoden nutzen, die diese Byte-Grenzen respektieren.

2. Praxis-Szenario: Das IoT-Sensor-Gateway

Unser IoT-Gateway empfängt rohe Sensordaten über ein Netzwerk. Die empfangenen Zeilen sind oft unsauber formatiert und enthalten Whitespaces, Messeinheiten und unterschiedliche Sensortypen.

Wir erhalten drei typische Roh-Strings:

  1. " TEMP:23.5C " (Temperatursensor)
  2. " HUMID:60% " (Luftfeuchtigkeitssensor)
  3. " STATUS:OK " (Systemstatus)

Unser Ziel

Wir schreiben ein Programm, das:

  1. Führende und abschließende Leerzeichen entfernt.
  2. Den Sensortyp identifiziert (durch Prüfen von Präfixen).
  3. Den reinen numerischen Wert ausliest (durch Entfernen von Einheiten wie C oder %).
  4. Den Wert in eine Gleitkommazahl (f32) konvertiert.
  5. Die Ausgabe für das Systemlogbuch rechtsbündig formatiert und auf 2 Nachkommastellen genau darstellt.

Die Übungsaufgabe befindet sich im Verzeichnis:


3. Der große Methoden-Katalog: Alle String- und &str-Methoden im Detail

Für das Lösen der Übungsaufgaben und die tägliche Praxis finden Sie hier alle in Kapitel 5 und dem Video besprochenen Methoden, sortiert nach Kategorien und detailliert erklärt:

3.1 Suchen, Prüfen und Verifizieren (Lesezugriffe)

Diese Methoden arbeiten direkt auf String-Slices (&str) und allokieren keinen zusätzlichen Speicher auf dem Heap.

  • contains(&self, pat: Pattern) -> bool Prüft, ob ein Suchmuster im Text enthalten ist.
    #![allow(unused)]
    fn main() {
    let text = "Messeingabe_TEMP";
    assert!(text.contains("TEMP")); // true
    }
  • starts_with(&self, pat: Pattern) -> bool Prüft, ob ein Text mit einem bestimmten Präfix beginnt.
    #![allow(unused)]
    fn main() {
    let text = "TEMP:23.5";
    assert!(text.starts_with("TEMP")); // true
    }
  • ends_with(&self, pat: Pattern) -> bool Prüft, ob ein Text mit einem bestimmten Suffix endet.
    #![allow(unused)]
    fn main() {
    let text = "sensor_data.csv";
    assert!(text.ends_with(".csv")); // true
    }
  • find(&self, pat: Pattern) -> Option<usize> Sucht von links nach rechts nach dem ersten Vorkommen des Musters und gibt den Byte-Index zurück.
    #![allow(unused)]
    fn main() {
    let s = "abcdefg";
    assert_eq!(s.find("cd"), Some(2));
    }
  • rfind(&self, pat: Pattern) -> Option<usize> Sucht von rechts nach links (vom Ende her) nach dem ersten Vorkommen des Musters.
    #![allow(unused)]
    fn main() {
    let s = "hallo_welt_hallo";
    assert_eq!(s.rfind("hallo"), Some(11));
    }
  • is_char_boundary(&self, index: usize) -> bool Prüft vor einem Slicing, ob ein Byte-Index auf einer gültigen UTF-8-Grenze liegt. Verhindert Laufzeit-Panics.
    #![allow(unused)]
    fn main() {
    let s = "ö"; // 'ö' belegt 2 Bytes
    assert!(s.is_char_boundary(0));
    assert!(!s.is_char_boundary(1)); // Dazwischen
    assert!(s.is_char_boundary(2)); // Ende
    }

3.2 Whitespace-Entfernung und Bereinigung

Gibt immer einen temporären, speichereffizienten Slice (&str) auf die bereinigten Bereiche der Originaldaten zurück.

  • trim(&self) -> &str Entfernt alle Whitespaces am Anfang und am Ende des Strings.
    #![allow(unused)]
    fn main() {
    let dirty = "  daten  \n";
    assert_eq!(dirty.trim(), "daten");
    }
  • trim_start(&self) -> &str Entfernt Whitespaces nur am Anfang des Strings.
    #![allow(unused)]
    fn main() {
    let s = "  hallo  ";
    assert_eq!(s.trim_start(), "hallo  ");
    }
  • trim_end(&self) -> &str Entfernt Whitespaces nur am Ende (nützlich bei Zeilenumbrüchen).
    #![allow(unused)]
    fn main() {
    let s = "  hallo  ";
    assert_eq!(s.trim_end(), "  hallo");
    }

3.3 Einfügen, Anfügen und Schreiben (Mutationen auf String)

Erfordern eine veränderbare Variable (mut String) auf dem Heap.

  • push(&mut self, ch: char) Hängt ein einzelnes Zeichen (char) am Ende an.
    #![allow(unused)]
    fn main() {
    let mut s = String::from("Rust");
    s.push('y'); // s ist nun "Rusty"
    }
  • push_str(&mut self, string: &str) Hängt eine ganze Zeichenkette (&str) am Ende an.
    #![allow(unused)]
    fn main() {
    let mut s = String::from("Rust");
    s.push_str(" Lehrbuch"); // "Rust Lehrbuch"
    }
  • insert(&mut self, idx: usize, ch: char) Fügt ein Zeichen an einem bestimmten Byte-Index ein.
    #![allow(unused)]
    fn main() {
    let mut s = String::from("Rt");
    s.insert(1, 'u'); // s ist nun "Rut"
    }
  • insert_str(&mut self, idx: usize, string: &str) Fügt einen String-Slice an einem bestimmten Byte-Index ein.
    #![allow(unused)]
    fn main() {
    let mut s = String::from("Rbuch");
    s.insert_str(1, "ehr"); // s ist nun "Rehrbuch"
    }

3.4 Löschen und Speicherfreigabe (Mutationen auf String)

  • pop(&mut self) -> Option<char> Entfernt das letzte Zeichen und gibt es zurück.
    #![allow(unused)]
    fn main() {
    let mut s = String::from("Tee");
    assert_eq!(s.pop(), Some('e')); // s ist nun "Te"
    }
  • remove(&mut self, idx: usize) -> char Entfernt das Zeichen an einem bestimmten Byte-Index und verschiebt nachfolgende Zeichen (Laufzeit: O(n)).
    #![allow(unused)]
    fn main() {
    let mut s = String::from("Rlust");
    s.remove(1); // s ist nun "Rust"
    }
  • clear(&mut self) Setzt den String auf die Länge 0 zurück, behält aber den Heap-Speicherbereich (Kapazität) für neue Daten.
    #![allow(unused)]
    fn main() {
    let mut s = String::from("Daten");
    s.clear(); // s ist leer ("")
    }
  • truncate(&mut self, new_len: usize) Kürzt den String auf die angegebene Byte-Länge.
    #![allow(unused)]
    fn main() {
    let mut s = String::from("Abschneiden");
    s.truncate(6); // s ist nun "Abschn"
    }
  • drain<R>(&mut self, range: R) -> Drain Entfernt einen Bereich aus dem String und gibt die Zeichen als Iterator zurück. Der String behält seine Kapazität.
    #![allow(unused)]
    fn main() {
    let mut s = String::from("abcde");
    let removed: String = s.drain(1..4).collect(); // "bcd" entfernt
    assert_eq!(s, "ae");
    }
  • retain<F>(&mut self, f: F) where F: FnMut(char) -> bool Filtert den String in-place. Nur Zeichen, die die Bedingung erfüllen, bleiben erhalten.
    #![allow(unused)]
    fn main() {
    let mut s = String::from("A1B2C3");
    s.retain(|c| c.is_alphabetic()); // s ist nun "ABC"
    }
  • split_off(&mut self, at: usize) -> String Spaltet den String an einem Byte-Index auf. Der aufrufende String behält alles bis at, der Rest wird als neuer String zurückgegeben.
    #![allow(unused)]
    fn main() {
    let mut s1 = String::from("HalloWelt");
    let s2 = s1.split_off(5); // s1 = "Hallo", s2 = "Welt"
    }

3.5 Spalten, Teilen und Zerlegen (Iteratoren)

Geben hocheffiziente Iteratoren zurück, die über die Teilsegmente (&str) iterieren.

  • split(&self, pat: Pattern) -> Split Teilt den String an jedem Vorkommen des Musters auf.
    #![allow(unused)]
    fn main() {
    let csv = "a,b,c";
    let teile: Vec<&str> = csv.split(',').collect(); // ["a", "b", "c"]
    }
  • split_once(&self, delimiter: Pattern) -> Option<(&str, &str)> Teilt den String beim ersten Vorkommen des Trennzeichens in ein Tupel auf.
    #![allow(unused)]
    fn main() {
    let kv = "key=value";
    let (k, v) = kv.split_once('=').unwrap(); // k = "key", v = "value"
    }
  • rsplit_once(&self, delimiter: Pattern) -> Option<(&str, &str)> Teilt den String beim letzten Vorkommen des Trennzeichens auf.
    #![allow(unused)]
    fn main() {
    let pfad = "/home/user/datei.txt";
    let (_, dateiname) = pfad.rsplit_once('/').unwrap(); // "datei.txt"
    }
  • splitn(&self, n: usize, pat: Pattern) -> SplitN Teilt den String maximal in n Fragmente auf.
    #![allow(unused)]
    fn main() {
    let raw = "A B C D";
    let parts: Vec<&str> = raw.splitn(2, ' ').collect(); // ["A", "B C D"]
    }
  • rsplitn(&self, n: usize, pat: Pattern) -> RSplitN Teilt den String maximal in n Fragmente auf, beginnt aber von rechts.
    #![allow(unused)]
    fn main() {
    let raw = "A B C D";
    let parts: Vec<&str> = raw.rsplitn(2, ' ').collect(); // ["D", "A B C"]
    }
  • lines(&self) -> Lines Gibt einen Iterator über alle Zeilen des Textes aus.
    #![allow(unused)]
    fn main() {
    let text = "Zeile 1\nZeile 2";
    for zeile in text.lines() { /* ... */ }
    }
  • split_whitespace(&self) -> SplitWhitespace Teilt den Text an Leerzeichen, Tabulatoren und Zeilenumbrüchen und überspringt mehrere Leerzeichen hintereinander.
    #![allow(unused)]
    fn main() {
    let s = "  Rust   ist  toll  ";
    let worte: Vec<&str> = s.split_whitespace().collect(); // ["Rust", "ist", "toll"]
    }

3.6 Suchen, Ersetzen und Konvertieren (Transformationen)

Geben neue, besitzende String-Instanzen auf dem Heap zurück.

  • replace(&self, from: Pattern, to: &str) -> String Ersetzt alle Vorkommen eines Musters.
    #![allow(unused)]
    fn main() {
    let s = "alt und alt";
    assert_eq!(s.replace("alt", "neu"), "neu und neu");
    }
  • replacen(&self, from: Pattern, to: &str, count: usize) -> String Ersetzt maximal die ersten count Vorkommen.
    #![allow(unused)]
    fn main() {
    let s = "alt und alt";
    assert_eq!(s.replacen("alt", "neu", 1), "neu und alt");
    }
  • to_lowercase(&self) -> String und to_uppercase(&self) -> String Konvertiert Groß- und Kleinschreibung gemäß Unicode-Standard.
    #![allow(unused)]
    fn main() {
    let s = "ß";
    assert_eq!(s.to_uppercase(), "SS");
    }

3.7 Byte- und Slice-Konvertierungen (O(1) konstante Zeit)

  • as_str(&self) -> &str Gibt eine unveränderliche Sicht auf den String zurück (kein Kopieren).
    #![allow(unused)]
    fn main() {
    let s = String::from("Text");
    let slice = s.as_str();
    }
  • as_bytes(&self) -> &[u8] Gibt den Text als Slice von rohen Bytes (u8) zurück.
    #![allow(unused)]
    fn main() {
    let s = String::from("Hi");
    assert_eq!(s.as_bytes(), &[72, 105]);
    }
  • as_mut_str(&mut self) -> &mut str Gibt eine veränderbare Sicht auf den Text zurück, um In-Place-Transformationen an den Bytes durchzuführen (ohne die Gesamtlänge des Slices zu verändern).
    #![allow(unused)]
    fn main() {
    let mut s = String::from("abc");
    s.as_mut_str().make_ascii_uppercase(); // s ist nun "ABC"
    }

3.8 Spezialmethoden & Optimierungen

  • repeat(&self, n: usize) -> String Wiederholt einen String n-mal in einer einzigen Allokation.
    #![allow(unused)]
    fn main() {
    assert_eq!("-".repeat(5), "-----");
    }
  • escape_default(&self) -> EscapeDefault Erzeugt einen Iterator, der Steuerzeichen sichtbar ausgibt (z.B. \n).
    #![allow(unused)]
    fn main() {
    let raw = "Line 1\nLine 2";
    for c in raw.escape_default() { print!("{}", c); } // Gibt "Line 1\nLine 2" aus
    }
  • parse::<T>(&self) -> Result<T, T::Err> Parsiert eine Zeichenkette in einen beliebigen Zieltyp, der das FromStr-Trait implementiert.
    #![allow(unused)]
    fn main() {
    let val: i32 = "42".parse().unwrap();
    }
  • Box::leak(b: Box<str>) -> &'static str Gibt den Heap-Speicher permanent frei (leakt ihn), um eine statische Lebenszeit für dynamisch erzeugte Daten zu erhalten.
    #![allow(unused)]
    fn main() {
    let s: Box<str> = String::from("global").into_boxed_str();
    let static_ref: &'static str = Box::leak(s);
    }

4. Aufgabenstellung

Öffnen Sie die Datei exercises/03_strings/src/main.rs. Schreiben Sie dort ein Programm, das die folgenden Schritte ausführt:

  1. Deklarieren Sie ein Array von String-Slices mit den drei Testdaten:
    #![allow(unused)]
    fn main() {
    let raw_inputs = ["  TEMP:23.5C  ", "  HUMID:60%  ", "  STATUS:OK  "];
    }
  2. Iterieren Sie über dieses Array mit einer for-Schleife.
  3. Entfernen Sie für jedes Element die Leerzeichen mittels .trim().
  4. Nutzen Sie .split_once(':'), um den Sensornamen vom Rohwert zu trennen.
  5. Prüfen Sie mit einem match oder if-Bedingungen den Sensornamen:
    • Wenn es “TEMP” ist: Entfernen Sie das Zeichen “C” aus dem Wert mittels .replace("C", ""). Parsen Sie den verbleibenden Wert in ein f32. Formatieren Sie die Ausgabe rechtsbündig mit Unterstrichen aufgefüllt auf eine Gesamtbreite von 12 Zeichen, wobei die Temperatur auf genau 2 Nachkommastellen gerundet wird (Beispiel: "__23.50_Grad" oder analog).
    • Wenn es “HUMID” ist: Entfernen Sie das “%”-Zeichen, parsen Sie den Wert in ein f32 und geben Sie ihn formatiert aus.
    • Wenn es “STATUS” ist: Geben Sie den Status direkt aus.
  6. Erstellen Sie vor der Schleife einen leeren, veränderbaren Log-String (String::new()). Hängen Sie in jedem Schleifendurchlauf den formatierten Text an diesen Log-String an (mittels .push_str()), getrennt durch einen Zeilenumbruch (\n).
  7. Geben Sie am Ende den gesamten Log-String auf der Konsole aus.

5. Detaillierte Code-Erklärung der Musterlösung

Der vollständige Quellcode der Musterlösung befindet sich in solutions/03_strings/src/main.rs.

fn main() {
    // 1. Definition der Testeingaben
    let raw_inputs = ["  TEMP:23.5C  ", "  HUMID:60%  ", "  STATUS:OK  "];
    
    // 2. Erstellen eines veränderbaren Log-Puffers auf dem Heap
    let mut log_book = String::new();

    // 3. Iteration über die Eingabewerte
    for raw in raw_inputs.iter() {
        // 4. Leerzeichen entfernen (liefert einen &str)
        let trimmed = raw.trim();

        // 5. Trennung an der Position des Doppelpunkts
        if let Some((sensor_type, raw_value)) = trimmed.split_once(':') {
            // 6. Fallunterscheidung basierend auf dem Sensortyp
            match sensor_type {
                "TEMP" => {
                    // "23.5C" -> "23.5" (erzeugt neuen String auf dem Heap)
                    let clean_str = raw_value.replace("C", "");
                    // Konvertierung in eine Fließkommazahl f32
                    let temp_val: f32 = clean_str.parse().expect("Ungültiger Temperaturwert");
                    // Formatierung: Rechtsbündig, auffüllen mit Punkten auf 10 Stellen, 2 Dezimalstellen
                    let formatted = format!("Temp:{:.<10.2}°C", temp_val);
                    
                    // Zeile an Log-Buch anfügen
                    log_book.push_str(&formatted);
                    log_book.push('\n');
                }
                "HUMID" => {
                    let clean_str = raw_value.replace("%", "");
                    let humid_val: f32 = clean_str.parse().expect("Ungültiger Feuchtigkeitswert");
                    let formatted = format!("Humid:{:.<9.1}%", humid_val);
                    
                    log_book.push_str(&formatted);
                    log_book.push('\n');
                }
                "STATUS" => {
                    let formatted = format!("Status:{}", raw_value);
                    
                    log_book.push_str(&formatted);
                    log_book.push('\n');
                }
                _ => {
                    println!("Unbekannter Sensor: {}", sensor_type);
                }
            }
        }
    }

    // 7. Ausgabe des gesamten Logbuchs
    println!("--- SYSTEM LOG ---");
    print!("{}", log_book);
}

Zeilen-Analyse der Lösung:

  • Zeile 6: let mut log_book = String::new(); – Deklariert eine leere Zeichenkette auf dem Stack. Es findet noch keine Heap-Allokation statt, da die Kapazität initial 0 ist.
  • Zeile 10: let trimmed = raw.trim(); – Die Methode trim() analysiert die Byte-Repräsentation und gibt einen Sub-Slice &str zurück. Es findet keine Kopie statt; trimmed zeigt auf den Bereich innerhalb von raw ohne die führenden und abschließenden Leerzeichen.
  • Zeile 13: trimmed.split_once(':') – Spaltet den Slice an der Position des Doppelpunkts. Der Rückgabetyp ist ein Option-Tupel aus zwei Slices: &str für den linken Teil und &str für den rechten.
  • Zeile 18: let clean_str = raw_value.replace("C", ""); – Sucht nach “C” und ersetzt es durch nichts. Da sich der Inhalt ändert, muss Rust Speicher auf dem Heap reservieren und kopiert die Bytes "23.5" dorthin. clean_str besitzt diesen Speicher.
  • Zeile 20: let temp_val: f32 = clean_str.parse()... – Wandelt den String-Inhalt in eine Gleitkommazahl um. Der Turbofish (oder die Typannotation an temp_val) teilt parse mit, dass ein f32 erzeugt werden soll.
  • Zeile 22: format!("Temp:{:.<10.2}°C", temp_val) – Erzeugt einen formatierten String. {:.<10.2} bedeutet:
    • : startet die Formatierung.
    • . ist das Füllzeichen.
    • < richtet den Text linksbündig aus.
    • 10 ist die Mindestbreite.
    • .2 limitiert die Ausgabe der Zahl auf 2 Nachkommastellen.
  • Zeile 25: log_book.push_str(&formatted); – Hängt den Inhalt des neu erzeugten Strings an den Log-Puffer an. Wenn nötig, vergrößert Rust hierbei automatisch die Heap-Kapazität von log_book.

6. Typische Compilerfehler & Fehlerbehebung

Fehler 1: Indizierung von Strings

#![allow(unused)]
fn main() {
let s = String::from("hallo");
let c = s[0]; // COMPILER-FEHLER!
}
  • Ursache: Der Compiler lehnt dies ab, weil Strings UTF-8-kodiert sind und der Direktzugriff per Byte-Index keine konstante Laufzeit O(1) garantieren kann.
  • Lösung: Nutzen Sie die Methode .chars() für einen Zeichen-Iterator oder greifen Sie über einen Byte-Slice zu:
    #![allow(unused)]
    fn main() {
    let erstes_zeichen = s.chars().next(); // Gibt Option<char> (Some('h'))
    }

Fehler 2: Mutation während einer aktiven Ausleihe (Borrow Checker)

#![allow(unused)]
fn main() {
let mut s = String::from("Hallo");
let r = &s; // Unveränderliche Ausleihe
s.push_str(" Welt"); // COMPILER-FEHLER: cannot mutate while borrowed!
println!("{}", r);
}
  • Ursache: Sie versuchen, den Wert von s auf dem Heap zu verändern (push_str), während über die Referenz r noch eine unveränderliche Sicht darauf aktiv ist. Das Ändern von s könnte dazu führen, dass der Heap-Speicher umallokiert wird, wodurch der Zeiger in r auf ungültigen Speicher zeigen würde (Dangling Pointer).
  • Lösung: Schränken Sie den Gültigkeitsbereich der Referenz r ein oder nutzen Sie den Wert vor der Mutation:
    #![allow(unused)]
    fn main() {
    let mut s = String::from("Hallo");
    {
        let r = &s;
        println!("{}", r); // Referenz wird hier letztmalig verwendet
    }
    s.push_str(" Welt"); // Nun ist die Mutation erlaubt!
    }