Projekt: Wortzähler in Rust
In diesem Projekt baust du ein kleines, aber echtes Werkzeug: einen Wortzähler. Du gibst ihm einen Text, und er sagt dir, welche Wörter am häufigsten vorkommen. Autorinnen und Autoren nutzen solche Werkzeuge, um Wörter zu finden, die sie zu oft wiederholen. Suchmaschinen verwenden dieselbe Idee, um zu verstehen, worum es auf einer Seite geht.
Wir bauen ihn in kleinen Schritten. Jeder Schritt ist ein vollständiges Programm, das läuft. Du kannst also jederzeit anhalten, damit herumspielen und dann weitermachen. Unterwegs nutzt du Strings, HashMaps, Sortieren, formatierte Ausgabe und Kommandozeilenargumente.
Am Ende gibt dein Programm etwa so etwas aus:
WORD COUNT----------------the 6cat 3happy 2Ein Hinweis vorab: Die Beispieltexte auf dieser Seite sind auf Englisch. So bleiben die Zählungen, die Sortierung und die Tabellen genau wie in der Originalversion des Tutorials. Dein Wortzähler funktioniert mit deutschem Text aber genauso gut: to_lowercase und is_alphanumeric kennen auch Buchstaben wie ä, ö, ü und ß.
Schritt 1: Text in Wörter zerlegen
Abschnitt betitelt „Schritt 1: Text in Wörter zerlegen“Die erste Aufgabe: den Text in Wörter schneiden. split_whitespace() trennt einen String an Leerzeichen, Tabulatoren und Zeilenumbrüchen und überspringt die leeren Stücke dazwischen:
fn main() { let text = "The cat sat on the mat. The dog sat on the log!";
let words: Vec<&str> = text.split_whitespace().collect();
println!("{:?}", words); println!("{} Wörter", words.len());}Ausgabe:
["The", "cat", "sat", "on", "the", "mat.", "The", "dog", "sat", "on", "the", "log!"]12 WörterDas klappt, aber schau genau hin. "The" und "the" gelten als verschiedene Wörter, und an "mat." hängt noch der Punkt. Würden wir jetzt zählen, wären die Ergebnisse falsch. Das beheben wir zuerst.
Schritt 2: Jedes Wort normalisieren
Abschnitt betitelt „Schritt 2: Jedes Wort normalisieren“Ein Wort zu normalisieren heißt, es in eine einheitliche Form zu bringen. Wir machen zwei Dinge:
- Satzzeichen am Anfang und am Ende entfernen, mit
trim_matches. - Das Wort kleinschreiben, mit
to_lowercase.
fn normalize(word: &str) -> String { word.trim_matches(|c: char| !c.is_alphanumeric()) .to_lowercase()}
fn main() { let text = "The cat sat on the mat. Don't stop -- said the DOG!";
for word in text.split_whitespace() { let clean = normalize(word); if clean.is_empty() { println!("{:>6} -> (übersprungen)", word); } else { println!("{:>6} -> {}", word, clean); } }}Ausgabe:
The -> the cat -> cat sat -> sat on -> on the -> the mat. -> mat Don't -> don't stop -> stop -- -> (übersprungen) said -> said the -> the DOG! -> dogtrim_matches bekommt ein Closure (eine anonyme Funktion), das entscheidet, welche Zeichen weg sollen. Hier entfernt es alles, was kein Buchstabe und keine Ziffer ist – aber nur an den Enden. Deshalb bleibt der Apostroph in don't erhalten. Ein „Wort“, das nur aus Satzzeichen besteht, wie --, wird zu einem leeren String. Das überspringen wir.
Schritt 3: Mit einer HashMap zählen
Abschnitt betitelt „Schritt 3: Mit einer HashMap zählen“Jetzt kommt die Hauptidee. Eine HashMap ordnet jedem Wort zu, wie oft wir es gesehen haben. Mit der Entry-API wird das Zählen ganz kurz:
use std::collections::HashMap;
fn normalize(word: &str) -> String { word.trim_matches(|c: char| !c.is_alphanumeric()) .to_lowercase()}
fn main() { let text = "The cat sat on the mat. The dog sat on the log! \ Is the cat happy? Yes, the cat is very happy.";
let mut counts: HashMap<String, usize> = HashMap::new(); for word in text.split_whitespace() { let clean = normalize(word); if !clean.is_empty() { *counts.entry(clean).or_insert(0) += 1; } }
println!("the: {}", counts["the"]); println!("cat: {}", counts["cat"]); println!("happy: {}", counts["happy"]); println!("{} verschiedene Wörter", counts.len());}Ausgabe:
the: 6cat: 3happy: 211 verschiedene WörterLesen wir *counts.entry(clean).or_insert(0) += 1; ganz langsam:
counts.entry(clean)sucht den Platz für dieses Wort in der Map..or_insert(0)legt dort eine0ab, wenn das Wort neu ist. In beiden Fällen bekommst du eine veränderbare Referenz auf den Zähler zurück.*folgt dieser Referenz bis zur Zahl selbst, und+= 1zählt eins dazu.
Der \ am Ende der ersten Textzeile setzt den String einfach in der nächsten Zeile fort. Und counts["the"] greift direkt auf einen Schlüssel zu. Fehlt der Schlüssel, gibt es eine Panic. Hier ist das in Ordnung, weil wir wissen, dass diese Wörter im Text vorkommen. Für Schlüssel, die fehlen könnten, nimmst du .get().
Warum haben wir nicht einfach die ganze Map mit einer Schleife ausgegeben? Eine HashMap hat keine feste Reihenfolge. Wenn du darüber iterierst, kann sich die Reihenfolge bei jedem Programmstart ändern. Für eine stabile, nützliche Reihenfolge müssen wir sortieren.
Schritt 4: Nach Anzahl sortieren, dann nach Wort
Abschnitt betitelt „Schritt 4: Nach Anzahl sortieren, dann nach Wort“Wir verschieben die Paare aus der Map in einen Vec und sortieren ihn. Die höchste Anzahl soll zuerst kommen. Haben zwei Wörter dieselbe Anzahl, sollen sie alphabetisch sortiert sein. So ist die Ausgabe immer gleich:
use std::collections::HashMap;
fn normalize(word: &str) -> String { word.trim_matches(|c: char| !c.is_alphanumeric()) .to_lowercase()}
fn main() { let text = "The cat sat on the mat. The dog sat on the log! \ Is the cat happy? Yes, the cat is very happy.";
let mut counts: HashMap<String, usize> = HashMap::new(); for word in text.split_whitespace() { let clean = normalize(word); if !clean.is_empty() { *counts.entry(clean).or_insert(0) += 1; } }
let mut pairs: Vec<(String, usize)> = counts.into_iter().collect(); pairs.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
for (word, count) in &pairs { println!("{word}: {count}"); }}Ausgabe:
the: 6cat: 3happy: 2is: 2on: 2sat: 2dog: 1log: 1mat: 1very: 1yes: 1Die Sortierzeile ist der kniffligste Teil dieses Projekts. Nehmen wir sie auseinander:
sort_bygibt uns zwei Elemente,aundb, und fragt: „Welches kommt zuerst?“b.1.cmp(&a.1)vergleicht die Anzahlen (.1ist der zweite Teil des Tupels). Wir schreibenbabsichtlich vora: Das dreht die Reihenfolge um, und große Anzahlen kommen zuerst..then(a.0.cmp(&b.0))kommt nur zum Zug, wenn die Anzahlen gleich sind. Es vergleicht die Wörter in normaler Reihenfolge vonanachb. Gleichstände landen also alphabetisch.
Ein Detail, wenn du deutschen Text zählst: Diese „alphabetische“ Sortierung vergleicht die Codes der einzelnen Zeichen. Wörter, die mit ä, ö oder ü beginnen, landen deshalb hinter dem z.
Auf der Seite Sortieren und Suchen findest du weitere Beispiele für sort_by und seine Verwandten.
Schritt 5: Eine Top-N-Tabelle ausgeben
Abschnitt betitelt „Schritt 5: Eine Top-N-Tabelle ausgeben“Eine lange Liste ist schwer zu lesen. Zeigen wir nur die häufigsten Wörter, in ordentlichen Spalten. Die Formatangaben übernehmen das Ausrichten für uns:
{:<10}bedeutet „linksbündig in einem 10 Zeichen breiten Feld“.{:>5}bedeutet „rechtsbündig in einem 5 Zeichen breiten Feld“. Zahlen sehen rechtsbündig meist am besten aus.
Die Spaltenüberschriften WORD und COUNT lassen wir auf Englisch. Das deutsche „ANZAHL“ hat 6 Buchstaben und passt nicht in die 5 Zeichen breite Spalte {:>5}. Die Überschrift wäre dann breiter als die Linie aus 16 Bindestrichen, und die Tabelle würde verrutschen.
Außerdem verschieben wir den Code in Funktionen. So liest sich main wie eine kurze Geschichte:
use std::collections::HashMap;
fn normalize(word: &str) -> String { word.trim_matches(|c: char| !c.is_alphanumeric()) .to_lowercase()}
fn count_words(text: &str) -> HashMap<String, usize> { let mut counts = HashMap::new(); for word in text.split_whitespace() { let clean = normalize(word); if !clean.is_empty() { *counts.entry(clean).or_insert(0) += 1; } } counts}
fn sorted_counts(counts: HashMap<String, usize>) -> Vec<(String, usize)> { let mut pairs: Vec<(String, usize)> = counts.into_iter().collect(); pairs.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0))); pairs}
fn print_table(pairs: &[(String, usize)], top_n: usize) { println!("{:<10} {:>5}", "WORD", "COUNT"); println!("{}", "-".repeat(16)); for (word, count) in pairs.iter().take(top_n) { println!("{:<10} {:>5}", word, count); }}
fn main() { let text = "The cat sat on the mat. The dog sat on the log! \ Is the cat happy? Yes, the cat is very happy.";
let pairs = sorted_counts(count_words(text)); print_table(&pairs, 5);}Ausgabe:
WORD COUNT----------------the 6cat 3happy 2is 2on 2.take(top_n) hört nach top_n Elementen auf. Gibt es weniger Wörter, hört es einfach früher auf. Du musst dir also nie Sorgen machen, über das Ende hinauszulaufen.
Beachte, dass print_table ein &[(String, usize)] bekommt, also einen Slice (Ausschnitt). Die Funktion leiht sich die Liste also nur aus und funktioniert mit jedem Vec von Paaren.
Schritt 6: Die fertige Version liest eine Datei
Abschnitt betitelt „Schritt 6: Die fertige Version liest eine Datei“Ein richtiger Wortzähler sollte eine Datei lesen. Den Dateipfad nehmen wir von der Kommandozeile, dazu ein optionales zweites Argument für die Zahl der Zeilen in der Tabelle. Geht etwas schief, geben wir mit eprintln! eine freundliche Meldung aus (das schreibt in die Fehlerausgabe) und beenden das Programm mit dem Code 1.
Lege das Projekt und eine Textdatei zum Testen an:
cargo new word_countercd word_counterecho "The cat sat on the mat. The dog sat on the log!" > story.txtSchreib das hier in src/main.rs. Diese Version ist für deinen eigenen Rechner, denn der Playground kann deine Dateien nicht lesen:
use std::collections::HashMap;use std::env;use std::fs;use std::process;
fn normalize(word: &str) -> String { word.trim_matches(|c: char| !c.is_alphanumeric()) .to_lowercase()}
fn count_words(text: &str) -> HashMap<String, usize> { let mut counts = HashMap::new(); for word in text.split_whitespace() { let clean = normalize(word); if !clean.is_empty() { *counts.entry(clean).or_insert(0) += 1; } } counts}
fn sorted_counts(counts: HashMap<String, usize>) -> Vec<(String, usize)> { let mut pairs: Vec<(String, usize)> = counts.into_iter().collect(); pairs.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0))); pairs}
fn print_table(pairs: &[(String, usize)], top_n: usize) { println!("{:<10} {:>5}", "WORD", "COUNT"); println!("{}", "-".repeat(16)); for (word, count) in pairs.iter().take(top_n) { println!("{:<10} {:>5}", word, count); }}
fn main() { let args: Vec<String> = env::args().collect();
// args[0] ist der Programmname, args[1] der Dateipfad let Some(path) = args.get(1) else { eprintln!("Aufruf: word_counter <datei> [top_n]"); process::exit(1); };
// das zweite Argument ist optional; fehlt es oder ist keine Zahl, nehmen wir 10 let top_n: usize = match args.get(2) { Some(n) => n.parse().unwrap_or(10), None => 10, };
let text = match fs::read_to_string(path) { Ok(text) => text, Err(e) => { eprintln!("{path} konnte nicht gelesen werden: {e}"); process::exit(1); } };
let pairs = sorted_counts(count_words(&text)); let total: usize = pairs.iter().map(|(_, count)| count).sum();
println!("{} Wörter, {} verschiedene\n", total, pairs.len()); print_table(&pairs, top_n);}Starte es. Denk daran: Argumente für dein Programm kommen hinter --:
cargo run -- story.txt 312 Wörter, 7 verschiedene
WORD COUNT----------------the 4on 2sat 2Und wenn du den Dateinamen vergisst, bekommst du statt eines Absturzes die Hilfe zum Aufruf:
cargo runAufruf: word_counter <datei> [top_n]let Some(path) = args.get(1) else { ... }; heißt let-else. Es bedeutet: „Wenn dieses Pattern passt, mach weiter. Sonst führe den else-Block aus, der das Programm stoppen muss (hier, indem es sich beendet).“ Die Seite Dateien lesen und schreiben erklärt read_to_string und seine Fehler genauer. Gibt es die Datei nicht, zeigt {e} etwa No such file or directory (os error 2) („Datei oder Verzeichnis nicht gefunden“). Diese Meldung kommt vom Betriebssystem, deshalb ist sie meist auf Englisch.
Hier ist dasselbe Programm mit eingebautem Text, damit du es im Playground starten kannst. Nur main ist anders:
use std::collections::HashMap;
fn normalize(word: &str) -> String { word.trim_matches(|c: char| !c.is_alphanumeric()) .to_lowercase()}
fn count_words(text: &str) -> HashMap<String, usize> { let mut counts = HashMap::new(); for word in text.split_whitespace() { let clean = normalize(word); if !clean.is_empty() { *counts.entry(clean).or_insert(0) += 1; } } counts}
fn sorted_counts(counts: HashMap<String, usize>) -> Vec<(String, usize)> { let mut pairs: Vec<(String, usize)> = counts.into_iter().collect(); pairs.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0))); pairs}
fn print_table(pairs: &[(String, usize)], top_n: usize) { println!("{:<10} {:>5}", "WORD", "COUNT"); println!("{}", "-".repeat(16)); for (word, count) in pairs.iter().take(top_n) { println!("{:<10} {:>5}", word, count); }}
fn main() { // stell dir vor, das kommt aus einer Datei let text = "Rust is fast. Rust is safe. Rust is fun! Is it hard? It takes practice, and practice is fun too."; let top_n = 4;
let pairs = sorted_counts(count_words(text)); let total: usize = pairs.iter().map(|(_, count)| count).sum();
println!("{} Wörter, {} verschiedene\n", total, pairs.len()); print_table(&pairs, top_n);}Ausgabe:
20 Wörter, 11 verschiedene
WORD COUNT----------------is 5rust 3fun 2it 2Häufige Fehler
Abschnitt betitelt „Häufige Fehler“Den * beim Zählen vergessen. or_insert gibt dir eine Referenz auf die Zahl, nicht die Zahl selbst. Ohne * weigert sich Rust, etwas dazuzuzählen:
*counts.entry(word).or_insert(0) += 1; // richtigcounts.entry(word).or_insert(0) += 1; // Fehlererror[E0368]: binary assignment operation `+=` cannot be applied to type `&mut {integer}` --> src/main.rs:6:9 |6 | counts.entry(word).or_insert(0) += 1; | -------------------------------^^^^^ | | | cannot use `+=` on type `&mut {integer}` |help: `+=` can be used on `{integer}` if you dereference the left-hand side |6 | *counts.entry(word).or_insert(0) += 1; | +Der Compiler sagt dir, dass += nicht auf eine veränderbare Referenz (&mut {integer}) angewendet werden kann. Und er zeigt dir sogar die Lösung: Setz den * davor, um zur Zahl selbst zu kommen.
Die HashMap direkt ausgeben. println!("{:?}", counts) funktioniert, aber die Reihenfolge ist zufällig und kann sich von Lauf zu Lauf ändern. Wenn die Reihenfolge wichtig ist, sortiere immer zuerst in einen Vec.
Nur nach Anzahl sortieren. Sortierst du nur mit b.1.cmp(&a.1), können Wörter mit gleicher Anzahl in beliebiger Reihenfolge auftauchen. Ergänze den Tie-Breaker .then(...), damit die Ausgabe jedes Mal gleich ist.
Ideen zum Erweitern
Abschnitt betitelt „Ideen zum Erweitern“Du hast jetzt ein funktionierendes Werkzeug. Hier ein paar Ideen, wie du es zu deinem eigenen machst, von leicht bis schwer:
- Stoppwörter ignorieren. Wörter wie „the“, „a“, „is“ und „and“ (oder auf Deutsch „der“, „die“, „und“ und „ist“) stehen überall und sind nicht besonders interessant. Pack sie in ein
HashSetund überspringe jedes Wort, das darin steht. - Prozente anzeigen. Gib neben jeder Anzahl aus, welchen Anteil sie an allen Wörtern hat, etwa
the 6 28.6%. Mit{:.1}zeigst du eine Nachkommastelle. - Von stdin lesen, wenn keine Datei angegeben ist. Nimm
std::io::read_to_string(std::io::stdin()), dann kannst du Text per Pipe hineinschicken:cat story.txt | cargo run. - Mehrere Dateien zählen. Geh in einer Schleife über alle Pfade in
args[1..]und zähle alle Wörter in einer gemeinsamen Map. clapfür die Argumente nutzen. Führecargo add clap --features deriveaus und füge echte Optionen wie--top 5und--ignore-casehinzu. Eine--help-Meldung bekommst du gratis dazu. Ein Beispiel steht auf der Seite Kommandozeilenargumente.- Tests schreiben. Verschiebe die Funktionen nach
src/lib.rsund schreib ein paar Tests fürnormalizeundcount_words.
Zusammenfassung
Abschnitt betitelt „Zusammenfassung“split_whitespace()zerlegt Text in Wörter;trim_matchesundto_lowercasenormalisieren sie.*map.entry(key).or_insert(0) += 1ist der übliche Weg, mit einer HashMap etwas zu zählen.- Eine HashMap hat keine Reihenfolge. Sammle die Paare in einen
Vecund sortiere mitsort_byund einem Tie-Breaker, damit die Ausgabe stabil bleibt. {:<10}und{:>5}richten Text in Spalten aus;.take(n)begrenzt, wie viele Zeilen du ausgibst.- Lies den Dateipfad aus
env::args(), fang Fehler mit einer freundlichen Meldung ab und beende das Programm mitprocess::exit(1).
Kurzer Check
Abschnitt betitelt „Kurzer Check“Kurzer Check
1. Was gibt "Hallo!".trim_matches(|c: char| !c.is_alphanumeric()) zurück?
2. Warum sammeln wir die HashMap vor der Ausgabe in einen Vec?
3. Warum steht in pairs.sort_by(|a, b| b.1.cmp(&a.1)) das b vor dem a?
4. Was macht {:>5} in println!?
Punkte: 0 / 4
Weiterlesen
Abschnitt betitelt „Weiterlesen“- Schlüssel mit zugehörigen Werten in Hashtabellen ablegen – Das Rust-Buch: enthält ein Beispiel zum Wörterzählen mit
entryundor_insert, ganz nah an Schritt 3. - Ein E/A-Projekt: Ein Kommandozeilenprogramm erstellen – Das Rust-Buch: baut einen kleinen
grep-Klon, der Argumente und Dateien liest. Das sind dieselben Fähigkeiten wie in Schritt 6, nur ausführlicher. HashMap::entry– Doku der Standardbibliothek (auf Englisch): gibt einenEntryzurück, dessen Methoden unter anderemor_insert,or_defaultundand_modifysind.slice::sort_by– Doku der Standardbibliothek (auf Englisch): wie Vergleichs-Closures funktionieren und warum die Sortierung stabil ist (gleiche Elemente behalten ihre Reihenfolge).- Breite und Ausrichtung –
std::fmt-Doku (auf Englisch): alle Optionen für Auffüllen und Ausrichten, wie{:<10}und{:>6}, die in den Tabellen auf dieser Seite vorkommen.