Kapitel 6: Daten aufräumen und sortieren – Collections für Einsteiger
Herzlich willkommen zu Kapitel 6! In den vorherigen Kapiteln hast du bereits gelernt, wie man einzelne Werte in Variablen speichert – zum Beispiel eine Zahl oder einen Text. Aber was machst du, wenn du eine ganze Einkaufsliste verwalten, die Highscores eines Spiels speichern oder ein Telefonbuch programmieren möchtest?
Wenn wir für jeden einzelnen Eintrag eine eigene Variable anlegen müssten (wie eintrag1, eintrag2, eintrag3 …), würden wir sehr schnell den Überblick verlieren. Unser Code würde riesig, unordentlich und extrem schwer zu erweitern sein.
Hier kommen Datenstrukturen (in Rust oft Collections genannt) ins Spiel. Sie sind wie praktische Ordnungshelfer oder Aufbewahrungsboxen in deinem Zimmer. Sie helfen dir, viele Daten sauber zu sortieren, zu durchsuchen und zu verwalten.
In diesem Kapitel schauen wir uns die fünf wichtigsten Ordnungshelfer in Rust an. Wir erklären sie dir mit einfachen Beispielen aus dem Alltag, sodass du sie sofort verstehst – selbst wenn du noch nie zuvor programmiert hast!
Die Lernziele dieses Kapitels
Am Ende dieses Kapitels wirst du:
- Wissen, wann du ein Tupel oder ein Array verwendest.
- Verstehen, warum der Vektor (
Vec\<T\>) der König unter den Listen in Rust ist und wie du sicher auf seine Elemente zugreifst, ohne dass dein Programm abstürzt. - Begreifen, wie ein Slice wie ein Suchscheinwerfer auf deine Daten wirkt.
- Mit einer HashMap wie in einem Telefonbuch blitzschnell Werte nachschlagen können.
- Die geniale Entry-API nutzen können, um Werte (wie in einem Einkaufswagen) elegant zu zählen.
1. Das Tupel: Die gemischte Pralinenschachtel
Stell dir vor, du kaufst eine edle Pralinenschachtel. In dieser Schachtel ist jedes Fach genau für eine bestimmte Praline geformt.
- Das erste Fach hält eine runde Marzipan-Praline (eine Zahl).
- Das zweite Fach hält eine eckige Nougat-Praline (einen Text).
- Das dritte Fach hält eine weiße Kokos-Praline (einen Wahrheitswert: ja oder nein).
Die Schachtel hat eine feste Anzahl an Fächern. Du kannst nachträglich keine neuen Fächer hineinkleben oder Fächer herausreißen. Und das Besondere ist: Die Pralinen in den Fächern dürfen völlig unterschiedliche Sorten (Datentypen) haben!
Genau das ist ein Tupel in Rust.
Wie sieht ein Tupel in Rust aus?
Hier ist ein komplettes, kompilierbares Beispiel. Kopiere es gerne in dein Projekt und probiere es aus!
fn main() {
// Wir erstellen unsere "Pralinenschachtel" (das Tupel).
// Es enthält eine ganze Zahl (i32), einen Text (&str) und einen Wahrheitswert (bool).
let pralinenschachtel: (i32, &str, bool) = (3, "Nougat-Traum", true);
// Methode 1: Die Pralinen einzeln über ihre Fachnummer (Index) herausholen.
// Achtung: In der Informatik fangen wir fast immer bei 0 an zu zählen!
let anzahl = pralinenschachtel.0;
let name = pralinenschachtel.1;
let lecker = pralinenschachtel.2;
println!("In der Schachtel liegen {} Stück der Sorte '{}'.", anzahl, name);
if lecker {
println!("Urteil: Super lecker!");
}
// Methode 2: Die Schachtel auf einmal auspacken (Destrukturierung).
// Dabei weisen wir den Inhalt der Fächer direkt neuen Variablen zu.
let (stueck, sorte, ist_lecker) = pralinenschachtel;
println!("Ausgepackt: {}x {} (Lecker: {})", stueck, sorte, ist_lecker);
}
Zeile für Zeile erklärt:
let pralinenschachtel: (i32, &str, bool) = ...: Hier definieren wir das Tupel. Die Typen stehen in runden Klammern, getrennt durch Kommas. Das sagt dem Rust-Compiler genau, was in jedem Fach liegt.pralinenschachtel.0: Mit dem Punkt.gefolgt von der Nummer greifen wir auf das jeweilige Fach zu.0ist das erste Fach,1das zweite und so weiter.let (stueck, sorte, ist_lecker) = pralinenschachtel;: Das nennen wir Destrukturierung (ein großes Wort für ein einfaches Prinzip). Rust nimmt das Tupel auseinander und packt die Werte in die drei neuen Variablenstueck,sorteundist_lecker. Das ist oft viel übersichtlicher als die Punkt-Schreibweise!
2. Das Array: Die Bahnhofs-Schließfächer
Stell dir nun eine lange Reihe von Schließfächern am Bahnhof vor.
- Jedes Schließfach ist exakt gleich groß. Du kannst also nur Dinge desselben Typs hineinlegen (zum Beispiel nur Rucksäcke).
- Die Anzahl der Schließfächer ist fest in die Wand gemauert. Wenn der Bahnhof gebaut wird, steht fest: Es gibt genau 5 Fächer. Du kannst nicht spontan ein sechstes Fach anbauen, ohne den Bahnhof umzubauen.
Das ist ein Array (auf Deutsch manchmal auch Feld genannt) in Rust. Es speichert eine feste Anzahl von Elementen, die alle den gleichen Datentyp haben müssen.
Wie programmieren wir ein Array?
fn main() {
// Wir erstellen eine Reihe von 5 Schließfächern, in denen wir die Temperaturen der letzten Tage speichern.
// Der Typ des Arrays wird als [Typ; Anzahl] geschrieben: hier [i32; 5]
let temperaturen: [i32; 5] = [12, 15, 14, 18, 16];
// Wir greifen auf das erste Schließfach (Index 0) zu:
let montag = temperaturen[0];
println!("Die Temperatur am Montag war: {}°C", montag);
// Wir können die Werte auch in einer Schleife durchlaufen:
for temp in temperaturen {
println!("Gemessene Temperatur: {}°C", temp);
}
}
Was passiert, wenn wir einen Fehler machen? (Compilerfehler & Abstürze)
Da Rust extrem auf Sicherheit bedacht ist, passt es gut auf unsere Schließfächer auf. Schauen wir uns an, was passiert, wenn wir versuchen, auf ein Fach zuzugreifen, das es gar nicht gibt!
Stell dir vor, du schreibst folgenden Code:
fn main() {
let temperaturen = [12, 15, 14, 18, 16]; // Ein Array mit 5 Elementen (Index 0 bis 4)
// Wir versuchen, auf das Schließfach mit Index 5 zuzugreifen (das wäre das 6. Fach!):
let kaputt = temperaturen[5];
println!("{}", kaputt);
}
Wenn du diesen Code kompilieren möchtest, schlägt Rust sofort Alarm! Der Compiler merkt schon beim Übersetzen, dass hier etwas schief läuft:
error: this operation will panic at runtime
--> src/main.rs:5:18
|
5 | let kaputt = temperaturen[5];
| ^^^^^^^^^^^^^^^ index out of bounds: the length is 5 but the index is 5
Der Compiler schützt uns vor uns selbst! Er sagt: “Halt! Dein Schrank hat nur 5 Fächer. Du versuchst, auf das Fach mit der Nummer 5 (das sechste Fach) zuzugreifen. Das erlaube ich nicht!”
Aber Vorsicht: Wenn der Index nicht direkt als feste Zahl im Code steht (sondern zum Beispiel vom Benutzer eingegeben wird), kann der Compiler das nicht im Voraus prüfen. In diesem Fall stürzt das Programm zur Laufzeit mit einer sogenannten Panic ab. Das wollen wir natürlich vermeiden. Wie das geht, lernen wir gleich beim Vektor!
3. Der Vektor (Vec\<T\>): Die magische, ausziehbare Schublade
Arrays sind toll, wenn man genau weiß, wie viele Elemente man hat (z. B. die 12 Monate des Jahres). Aber im echten Leben wissen wir das oft nicht. Wie viele Artikel legt ein Kunde in seinen Einkaufswagen? Wie viele Gegner tauchen im Spiel auf?
Für solche Fälle gibt es den Vektor (Vec\<T\>).
Stell dir eine magische Kommoden-Schublade vor. Sie hat anfangs vielleicht Platz für drei Paar Socken. Sobald du aber ein viertes Paar Socken hineinlegst, dehnt sich die Schublade wie von Zauberhand aus! Sie wächst dynamisch mit deinen Anforderungen.
Genau wie beim Array müssen aber auch im Vektor alle Elemente vom gleichen Typ sein (zum Beispiel nur Socken bzw. nur i32 Zahlen).
Kernoperationen eines Vektors
Wir können Elemente hinzufügen (push), vom Ende wegschmeißen (pop) oder sicher auslesen (get).
Schauen wir uns das in Aktion an:
fn main() {
// 1. Einen neuen, leeren Vektor erstellen.
// Wir müssen die Variable "mut" (veränderbar) machen, da wir später Dinge hinzufügen!
let mut einkaufsliste: Vec<&str> = Vec::new();
// Alternativ können wir einen Vektor direkt mit Startwerten über das vec!-Makro erstellen:
// let mut einkaufsliste = vec!["Äpfel", "Brot"];
// 2. Elemente am Ende hinzufügen mit .push()
einkaufsliste.push("Äpfel");
einkaufsliste.push("Brot");
einkaufsliste.push("Käse");
println!("Meine Einkaufsliste nach dem Hinzufügen: {:?}", einkaufsliste);
// 3. Das letzte Element wieder entfernen mit .pop()
// .pop() gibt uns das Element zurück, falls eines da war.
let gelöscht = einkaufsliste.pop();
println!("Ich habe das letzte Element gelöscht: {:?}", gelöscht);
println!("Aktuelle Liste: {:?}", einkaufsliste);
}
Der sichere Zugriff mit .get() (Absturzsicherung)
Erinnerst du dich an den Absturz beim Array, als wir auf ein nicht existierendes Fach zugegriffen haben? Bei Vektoren passiert das auch, wenn wir die eckigen Klammern benutzen (z. B. einkaufsliste[10]).
Um das zu verhindern, stellt uns Rust eine wunderbare Funktion zur Verfügung: .get().
Die Funktion .get() gibt uns nicht direkt das Element zurück, sondern verpackt es in einer Sicherheitsbox namens Option. Eine Option kann zwei Zustände haben:
Some(&wert): Ja, das Element existiert und hier ist eine Referenz darauf!None: Nein, dieses Fach ist leer (Index existiert nicht).
Das zwingt uns als Programmierer dazu, den Fall einzubalkulieren, dass das Element vielleicht gar nicht existiert. Das Programm stürzt dadurch niemals unvorhergesehen ab!
Hier ist das Beispiel, wie man das in der Praxis macht:
fn main() {
let einkaufsliste = vec!["Äpfel", "Brot"];
// Wir fragen nach dem Fach mit Index 1 (das zweite Element: "Brot")
// und nach dem Fach mit Index 10 (das gibt es nicht!).
let index_erlaubt = 1;
let index_zu_gross = 10;
// Wir testen beide Zugriffe mit .get()
for index in [index_erlaubt, index_zu_gross] {
match einkaufsliste.get(index) {
Some(artikel) => {
println!("Erfolg! An Index {} steht: {}", index, artikel);
}
None => {
println!("Fehler! An Index {} gibt es keinen Eintrag.", index);
}
}
}
}
Warum ist das genial?
Durch die Struktur von Option können wir mit einem match-Block sauber entscheiden, was passieren soll. Wenn der Benutzer nach einem ungültigen Index sucht, zeigen wir ihm einfach eine nette Fehlermeldung, statt dass das gesamte Programm mit einem lauten Knall (Panic) abstürzt!
4. Der Slice: Der Suchscheinwerfer auf eine Häuserzeile
Manchmal möchtest du nicht die ganze Liste an eine andere Funktion übergeben oder kopieren, sondern nur einen kleinen Ausschnitt davon betrachten.
Stell dir eine lange Häuserzeile vor. Ein Slice (auf Deutsch Ausschnitt oder Scheibe) ist wie ein Suchscheinwerfer, den du auf einen Teil dieser Häuserzeile richtest.
- Du kopierst die Häuser nicht (das wäre viel zu schwer und würde zu viel Speicherplatz verbrauchen).
- Du schaust dir einfach nur einen bestimmten Ausschnitt an (zum Beispiel von Haus 2 bis Haus 4).
- Ein Slice ist immer eine Referenz (gekennzeichnet durch das
&-Zeichen), da es sich die Daten nur ausleiht.
Wie sieht ein Slice im Code aus?
fn main() {
// Ein Vektor mit 6 Zahlen
let zahlen = vec![10, 20, 30, 40, 50, 60];
// Wir erstellen einen Slice, der die Elemente von Index 1 bis vor Index 4 anleuchtet.
// Das heißt: Index 1, 2 und 3 (20, 30, 40).
// Schreibweise: &vektor[start..ende_exklusive]
let ausschnitt: &[i32] = &zahlen[1..4];
println!("Der gesamte Vektor: {:?}", zahlen);
println!("Der angeleuchtete Ausschnitt (Slice): {:?}", ausschnitt);
// Wir können auf den Slice zugreifen wie auf ein Array:
println!("Das erste Element im Ausschnitt ist: {}", ausschnitt[0]); // Gibt 20 aus
}
Der Borrow-Checker passt auf! (Deep Dive)
Weil ein Slice eine Referenz (eine Ausleihe) auf die Originaldaten ist, passt der Rust Borrow Checker extrem gut auf uns auf. Er sorgt dafür, dass sich die Daten unter unserem Suchscheinwerfer nicht plötzlich verändern!
Stell dir vor, du hast einen Ausschnitt (Slice) ausgeliehen und versuchst dann, den Vektor zu verändern:
fn main() {
let mut zahlen = vec![10, 20, 30];
// Wir leihen uns einen Slice aus
let ausschnitt = &zahlen[0..2];
// Jetzt versuchen wir, ein neues Element an den Vektor anzuhängen!
zahlen.push(40);
// Und hier wollen wir den Slice benutzen
println!("Ausschnitt: {:?}", ausschnitt);
}
Wenn du versuchst, das auszuführen, wird dir der Compiler wütend auf die Finger klopfen:
error[E0502]: cannot borrow `zahlen` as mutable because it is also borrowed as immutable
--> src/main.rs:8:5
|
5 | let ausschnitt = &zahlen[0..2]; // Hier wird 'zahlen' unveränderbar ausgeliehen
| ------ immutable borrow occurs here
6 |
7 | // Jetzt versuchen wir, den Vektor zu verändern
8 | zahlen.push(40); // Fehler! Hier versuchen wir eine veränderbare Ausleihe
| ^^^^^^^^^^^^^^^ mutable borrow occurs here
9 |
10| println!("Ausschnitt: {:?}", ausschnitt); // Hier wird die unveränderbare Ausleihe noch gebraucht
| ---------- immutable borrow later used here
Warum macht Rust das?
Wenn ein Vektor wächst (durch .push()), kann es sein, dass im Arbeitsspeicher des Computers kein Platz mehr direkt hinter dem Vektor frei ist. Rust muss dann den gesamten Vektor an einen anderen Ort im Speicher umziehen lassen.
Wenn das passiert, würde unser ausschnitt plötzlich auf eine alte Speicheradresse zeigen, wo gar keine Daten mehr liegen! Das nennt man einen Dangling Pointer (einen Zeiger ins Nichts). In C oder C++ führt so etwas zu fiesen Sicherheitslücken oder Abstürzen. Rust verhindert das komplett, indem es das Programm gar nicht erst kompiliert!
5. Die HashMap: Das Telefonbuch
Bisher haben wir unsere Elemente immer über eine Zahl (den Index 0, 1, 2...) gesucht. Aber was ist, wenn du die Telefonnummer von “Anna” suchst? Du willst nicht wissen, ob Anna an Stelle 5 steht, sondern du willst direkt nach dem Namen “Anna” suchen!
Dafür gibt es die HashMap (oft auch als Assoziatives Array oder Dictionary bezeichnet).
Stell dir ein klassisches Telefonbuch vor. Zu jedem eindeutigen Schlüssel (Key, z. B. Name “Anna”) gehört genau ein Wert (Value, z. B. Telefonnummer 12345).
- Der Schlüssel muss einzigartig sein (es kann im Telefonbuch nur einen Eintrag für “Anna Müller” geben, sonst weiß das Buch nicht, welche Nummer gemeint ist).
- Die HashMap ordnet die Elemente intern so an, dass sie extrem schnell gefunden werden – egal wie viele Millionen Einträge im Buch stehen!
Die geniale Entry-API: Der Einkaufswagen
Eine der häufigsten Aufgaben beim Programmieren ist das Zählen von Dingen. Zum Beispiel: Wir haben einen Einkaufswagen und möchten zählen, wie oft ein bestimmtes Produkt hineingelegt wurde.
Wenn wir das Produkt zum ersten Mal sehen, müssen wir es in der HashMap eintragen (mit dem Zählerwert 1). Wenn wir es schon einmal gesehen haben, wollen wir den Zähler um 1 erhöhen.
In vielen Programmiersprachen muss man dafür viel Code schreiben: Prüfen, ob der Schlüssel existiert, wenn nein einfügen, wenn ja auslesen, erhöhen, neu speichern. In Rust gibt es dafür die Entry-API, die das mit einer einzigen, wunderschönen Zeile erledigt:
#![allow(unused)]
fn main() {
map.entry(schluessel).or_insert(0);
}
Schauen wir uns ein vollständiges, gut kommentiertes Code-Beispiel an:
// Wichtig: Die HashMap gehört nicht zum Standard-Sprachumfang, der immer geladen ist.
// Wir müssen sie aus der Standardbibliothek importieren!
use std::collections::HashMap;
fn main() {
// Wir erstellen eine neue, leere HashMap für unseren Einkaufswagen.
// Der Schlüssel (Key) ist der Name des Produkts (&str).
// Der Wert (Value) ist die Anzahl (i32).
let mut einkaufswagen: HashMap<&str, i32> = HashMap::new();
// Wir simulieren das Scannen von Produkten an der Kasse.
// Diese Produkte landen nacheinander in unserem Wagen:
let gescannte_produkte = vec![
"Apfel",
"Banane",
"Apfel",
"Brot",
"Apfel",
"Banane"
];
// Wir gehen jedes Produkt in einer Schleife durch
for produkt in gescannte_produkte {
// HIER PASSIERT DIE MAGIE:
// 1. .entry(produkt) schaut nach, ob das Produkt bereits in der HashMap existiert.
// 2. .or_insert(0) sagt: "Wenn das Produkt noch nicht da ist, trage es mit dem Wert 0 ein."
// Diese Funktion gibt uns eine veränderbare Referenz (&mut) auf den Wert in der HashMap zurück!
// 3. Mit dem Sternchen * (Dereferenzierung) greifen wir auf die Zahl dahinter zu und erhöhen sie um 1.
let anzahl_referenz = einkaufswagen.entry(produkt).or_insert(0);
*anzahl_referenz += 1;
}
// Wir geben das Endergebnis aus
println!("--- Kassenbon ---");
for (produkt, anzahl) in &einkaufswagen {
println!("{}: {}x", produkt, anzahl);
}
}
Zeile für Zeile erklärt:
use std::collections::HashMap;: Hiermit sagen wir Rust, dass wir die HashMap benutzen möchten. Sie liegt im Modulcollectionsder Standardbibliothek (std).let mut einkaufswagen: HashMap<&str, i32> = HashMap::new();: Wir erstellen die leere HashMap. Die Typen in den spitzen Klammern<Key, Value>sagen Rust, dass wir Text als Schlüssel und ganze Zahlen als Werte nutzen.einkaufswagen.entry(produkt): Diese Methode sucht den Eintrag für das Produkt. Sie gibt uns eine Struktur vom TypEntryzurück. DieserEntryweiß, ob der Schlüssel existiert oder nicht..or_insert(0): Das ist die wichtigste Methode auf demEntry. Wenn der Schlüssel existiert, tut sie nichts und gibt uns einfach den aktuellen Wert. Wenn der Schlüssel nicht existiert, fügt sie ihn mit dem Wert0ein. Am Ende bekommen wir eine veränderbare Referenz (&mut i32) auf den Speicherplatz in der HashMap, wo die Zahl liegt.*anzahl_referenz += 1;: Weilanzahl_referenzeine Referenz (ein Wegweiser) auf die Zahl in der HashMap ist, müssen wir das Sternchen*benutzen, um dem Wegweiser zu folgen und die echte Zahl im Speicher zu verändern (zu dereferenzieren). Wir addieren1hinzu. Beim nächsten Schleifendurchlauf für dasselbe Produkt ist der Wert also bereits um 1 höher!
Zusammenfassung: Welcher Ordnungshelfer für welche Aufgabe?
Damit du nie wieder den Überblick verlierst, ist hier ein schnelles Spickzettel-Cheat-Sheet:
| Ordnungshelfer | Datentypen | Größe (Länge) | Alltagsanalogie | Wann benutze ich es? |
|---|---|---|---|---|
Tupel (A, B) | Gemischt (z. B. i32, bool) | Feste Anzahl | Pralinenschachtel | Wenn du wenige, zusammenhängende Werte verschiedener Typen gruppieren willst (z. B. 2D-Koordinaten (x, y)). |
Array [T; N] | Alle gleich | Feste Anzahl | Bahnhofs-Schließfächer | Wenn du eine feste Anzahl von Elementen hast, die sich nie ändert (z. B. Wochentage). Sehr schnell und speichersparend. |
Vektor Vec\<T\> | Alle gleich | Dynamisch (wächst/schrumpft) | Ausziehbare Schublade | Dein Standard-Werkzeug für Listen im Alltag. Verwende es fast immer, wenn du eine Liste von Elementen verwaltest. |
Slice &[T] | Alle gleich | Ansicht auf einen Teilbereich | Suchscheinwerfer | Wenn du eine Funktion schreiben willst, die einen Teil einer Liste liest, ohne die Daten kopieren zu müssen. |
HashMap HashMap\<K, V\> | Schlüssel gleich, Werte gleich | Dynamisch | Telefonbuch | Wenn du Daten über einen Begriff oder Namen (Schlüssel) statt über eine Nummer (Index) suchen möchtest. |
Herzlichen Glückwunsch! Du hast nun die wichtigsten Collections in Rust verstanden. Mit diesem Wissen kannst du bereits komplexe Programme schreiben, die große Mengen an Daten verwalten und verarbeiten.
Im nächsten Schritt kannst du dieses Wissen in den Übungen festigen!