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 einenStringwie 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&strist 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:
" TEMP:23.5C "(Temperatursensor)" HUMID:60% "(Luftfeuchtigkeitssensor)" STATUS:OK "(Systemstatus)
Unser Ziel
Wir schreiben ein Programm, das:
- Führende und abschließende Leerzeichen entfernt.
- Den Sensortyp identifiziert (durch Prüfen von Präfixen).
- Den reinen numerischen Wert ausliest (durch Entfernen von Einheiten wie
Coder%). - Den Wert in eine Gleitkommazahl (
f32) konvertiert. - 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) -> boolPrü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) -> boolPrü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) -> boolPrü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) -> boolPrü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) -> &strEntfernt 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) -> &strEntfernt Whitespaces nur am Anfang des Strings.#![allow(unused)] fn main() { let s = " hallo "; assert_eq!(s.trim_start(), "hallo "); }trim_end(&self) -> &strEntfernt 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) -> charEntfernt 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) -> DrainEntfernt 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) -> boolFiltert 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) -> StringSpaltet den String an einem Byte-Index auf. Der aufrufende String behält alles bisat, der Rest wird als neuerStringzurü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) -> SplitTeilt 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) -> SplitNTeilt den String maximal innFragmente 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) -> RSplitNTeilt den String maximal innFragmente 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) -> LinesGibt 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) -> SplitWhitespaceTeilt 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) -> StringErsetzt 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) -> StringErsetzt maximal die erstencountVorkommen.#![allow(unused)] fn main() { let s = "alt und alt"; assert_eq!(s.replacen("alt", "neu", 1), "neu und alt"); }to_lowercase(&self) -> Stringundto_uppercase(&self) -> StringKonvertiert 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) -> &strGibt 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 strGibt 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) -> StringWiederholt einen Stringn-mal in einer einzigen Allokation.#![allow(unused)] fn main() { assert_eq!("-".repeat(5), "-----"); }escape_default(&self) -> EscapeDefaultErzeugt 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 dasFromStr-Trait implementiert.#![allow(unused)] fn main() { let val: i32 = "42".parse().unwrap(); }Box::leak(b: Box<str>) -> &'static strGibt 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:
- 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 "]; } - Iterieren Sie über dieses Array mit einer
for-Schleife. - Entfernen Sie für jedes Element die Leerzeichen mittels
.trim(). - Nutzen Sie
.split_once(':'), um den Sensornamen vom Rohwert zu trennen. - Prüfen Sie mit einem
matchoderif-Bedingungen den Sensornamen:- Wenn es “TEMP” ist: Entfernen Sie das Zeichen “C” aus dem Wert mittels
.replace("C", ""). Parsen Sie den verbleibenden Wert in einf32. 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
f32und geben Sie ihn formatiert aus. - Wenn es “STATUS” ist: Geben Sie den Status direkt aus.
- Wenn es “TEMP” ist: Entfernen Sie das Zeichen “C” aus dem Wert mittels
- 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). - 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 Methodetrim()analysiert die Byte-Repräsentation und gibt einen Sub-Slice&strzurück. Es findet keine Kopie statt;trimmedzeigt auf den Bereich innerhalb vonrawohne die führenden und abschließenden Leerzeichen. - Zeile 13:
trimmed.split_once(':')– Spaltet den Slice an der Position des Doppelpunkts. Der Rückgabetyp ist einOption-Tupel aus zwei Slices:&strfür den linken Teil und&strfü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_strbesitzt diesen Speicher. - Zeile 20:
let temp_val: f32 = clean_str.parse()...– Wandelt den String-Inhalt in eine Gleitkommazahl um. Der Turbofish (oder die Typannotation antemp_val) teiltparsemit, dass einf32erzeugt 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.10ist die Mindestbreite..2limitiert 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 vonlog_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
sauf dem Heap zu verändern (push_str), während über die Referenzrnoch eine unveränderliche Sicht darauf aktiv ist. Das Ändern vonskönnte dazu führen, dass der Heap-Speicher umallokiert wird, wodurch der Zeiger inrauf ungültigen Speicher zeigen würde (Dangling Pointer). - Lösung: Schränken Sie den Gültigkeitsbereich der Referenz
rein 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! }