Kapitel 14: Generische Programmierung – Die magische Ausstechform
Stell dir vor, du stehst in der Weihnachtszeit in der Küche und möchtest Plätzchen backen. Du hast verschiedene Teigsorten vorbereitet: einen hellen Mürbeteig, einen dunklen Schokoladenteig und einen nussigen Lebkuchenteig.
Nun nimmst du eine Ausstechform in Gestalt eines Sterns. Die Ausstechform selbst ist kein fertiges Plätzchen – du kannst sie nicht essen. Sie ist lediglich eine Schablone, eine geometrische Idee eines Sterns.
Erst wenn du diese Form in den Mürbeteig drückst, erhältst du ein Mürbeteig-Plätzchen. Drückst du sie in den Schokoladenteig, hast du ein Schokoladen-Plätzchen. Die Form (Stern) bleibt immer exakt dieselbe, aber das Material (der Teig) ändert sich.
In der Programmierung ist das exakt dasselbe. Stell dir vor, du schreibst eine Funktion, die die Positionen von zwei Werten im Speicher vertauscht. Ohne Generics müsstest du schreiben:
- Eine Funktion
tausche_i32(a: &mut i32, b: &mut i32) - Eine Funktion
tausche_f64(a: &mut f64, b: &mut f64) - Eine Funktion
tausche_string(a: &mut String, b: &mut String)
Das ist extrem lästig und führt zu dupliziertem Code. Wenn du einen Fehler in der Vertauschungslogik findest, musst du ihn an drei Stellen korrigieren!
Rust bietet hierfür die generische Programmierung (oft einfach Generics genannt) an. Ein Generic ist wie eine Ausstechform: Du definierst den Code mit einem Platzhalter (der Form) und der Compiler erzeugt später beim Übersetzen die konkreten Varianten (die Plätzchen) für jeden Datentyp, den du tatsächlich verwendest.
1. Lernziele – Das wirst du heute lernen
- Das Prinzip von Generics verstehen: Du begreifst, wie Platzhalter die Code-Duplizierung verhindern.
- Generische Funktionen schreiben: Du lernst, wie du Funktionen mit Typparametern deklarierst.
- Generische Strukturen erstellen: Du erfährst, wie du Structs für beliebige Typen entwirfst.
- Der impl-Block bei Generics: Du verstehst die Syntax
impl<T> Struktur<T>. - Option und Result verstehen: Du erkennst, dass die wichtigsten Enums in Rust eigentlich nur generische Ausstechformen sind.
- Der Turbofisch-Operator: Du lernst, wie du dem Compiler mit
::<>auf die Sprünge hilfst.
2. Generische Funktionen: Dein erster Platzhalter
Lass uns eine einfache Funktion schreiben. Sie soll zwei Werte desselben Typs entgegennehmen und den ersten davon zurückgeben.
Da wir noch nicht wissen, welcher Typ das sein wird, führen wir einen Platzhalter ein. In der Welt von Rust (und vielen anderen Sprachen) nennen wir diesen Platzhalter standardmäßig T (kurz für Type).
// Wir deklarieren den Platzhalter T in spitzen Klammern <T> direkt hinter dem Funktionsnamen.
// Dadurch weiß Rust: "T ist keine konkrete Struktur, sondern ein Platzhalter!"
fn wähle_erstes<T>(a: T, b: T) -> T {
// Da wir b nicht nutzen, geben wir einfach a zurück.
// Der Typ von a ist T, und das passt zum Rückgabetyp T.
a
}
fn main() {
// Wir rufen die Funktion mit Ganzzahlen (i32) auf:
let zahl = wähle_erstes(5, 10);
println!("Gewählte Zahl: {}", zahl);
// Wir rufen dieselbe Funktion mit Zeichenketten (&str) auf:
let wort = wähle_erstes("Apfel", "Birne");
println!("Gewähltes Wort: {}", wort);
}
Was passiert hier im Hintergrund?
Wenn der Compiler diese Datei liest, sieht er:
- Ah, der Entwickler ruft
wähle_erstesmit zwei Ganzzahlen auf. Ich erstelle im fertigen Programm heimlich eine Variante der Funktion, die nur für Ganzzahlen (i32) da ist. - Oh, und da ist noch ein Aufruf mit Text (
&str). Ich erstelle eine weitere Variante der Funktion, die nur für Text da ist. - Der Platzhalter
Twird also zur Compilezeit durch echte, konkrete Typen ersetzt.
3. Generische Strukturen (Structs)
Genauso wie Funktionen können auch Strukturen generisch sein. Stell dir vor, du möchtest eine Struktur Punkt erstellen, die eine Koordinate im zweidimensionalen Raum darstellt.
Einige Koordinaten sind Ganzzahlen (z. B. auf einem Pixel-Bildschirm: x = 100, y = 200). Andere sind Fließkommazahlen (z. B. auf einer mathematischen Karte: x = 1.5, y = 2.7).
Mit Generics schreiben wir das so:
// T ist der Platzhalter für den Typ der Koordinaten x und y.
// Wichtig: Da wir zweimal T verwenden, müssen x und y denselben Typ haben!
struct PunktSimple<T> {
x: T,
y: T,
}
fn main() {
// Ein Punkt mit Ganzzahlen (i32)
let pixel = PunktSimple { x: 10, y: 20 };
// Ein Punkt mit Kommazahlen (f64)
let gps = PunktSimple { x: 52.5206, y: 13.4049 };
}
Was ist, wenn wir unterschiedliche Typen erlauben wollen?
Wenn du einen Punkt erstellen willst, bei dem x eine Ganzzahl und y eine Kommazahl ist, müssen wir zwei getrennte Platzhalter einführen (z. B. T und U):
struct PunktGemischt<T, U> {
x: T,
y: U,
}
fn main() {
// Das funktioniert jetzt! x ist i32, y ist f64.
let gemischt = PunktGemischt { x: 5, y: 4.5 };
}
4. Methoden implementieren: Der impl-Block
Wenn wir Methoden für eine generische Struktur schreiben möchten, stolpern wir als Einsteiger oft über die Syntax. Wir müssen dem Compiler nämlich zweimal sagen, dass wir mit dem Platzhalter arbeiten:
#![allow(unused)]
fn main() {
struct Container<T> {
inhalt: T,
}
// 1. impl<T> deklariert, dass wir einen generischen impl-Block starten.
// 2. Container<T> sagt, für welche Struktur wir die Methoden schreiben.
impl<T> Container<T> {
// Eine Methode, die uns eine Referenz auf den Inhalt liefert
fn inhalt_zeigen(&self) -> &T {
&self.inhalt
}
}
}
Warum diese Dopplung impl<T> Container<T>?
Das liegt daran, dass Rust es uns erlaubt, Methoden nur für ganz bestimmte Typen zu schreiben (Spezialisierung).
Stell dir vor, wir wollen eine Methode schreiben, die den Inhalt auf der Konsole ausgibt, aber nur, wenn der Inhalt eine Fließkommazahl ist. Das schreiben wir so:
#![allow(unused)]
fn main() {
// Hier deklarieren wir KEIN impl<T>! Wir schreiben stattdessen konkret f64 in die Klammern.
impl Container<f64> {
fn wurzel_berechnen(&self) -> f64 {
self.inhalt.sqrt()
}
}
}
Die Methode wurzel_berechnen existiert nun auf einem Container<f64>, aber nicht auf einem Container<String>! Das ist ein extrem mächtiges Feature von Rust.
5. Option und Result: Die bekanntesten generischen Enums
Vielleicht hast du in den vorherigen Kapiteln schon mit Option und Result gearbeitet. Diese beiden Typen sind unter der Haube nichts anderes als generische Enumerationen!
In der Standardbibliothek sind sie wie folgt definiert:
#![allow(unused)]
fn main() {
// Option kann entweder Nichts sein (None) oder Etwas enthalten (Some)
enum OptionSimple<T> {
Some(T),
None,
}
// Result stellt das Ergebnis einer Operation dar, die fehlschlagen kann
enum ResultSimple<T, E> {
Ok(T), // T ist der Erfolgs-Typ
Err(E), // E ist der Fehler-Typ
}
}
Wenn du also Some(42) schreibst, erzeugt Rust im Hintergrund ein OptionSimple<i32>. Wenn du Some("Hallo".to_string()) schreibst, wird daraus ein OptionSimple<String>. Die Ausstechform OptionSimple passt sich flexibel an jeden Inhalt an!
6. Der Turbofisch-Operator ::<>
Normalerweise ist der Rust-Compiler extrem schlau und findet den Typ des Platzhalters ganz allein heraus (Typinferenz). Wenn du let x = Some(5); schreibst, weiß er sofort, dass T ein Ganzzahltyp ist.
Manchmal gibt es jedoch Situationen, in denen der Compiler keine Anhaltspunkte hat. Ein klassisches Beispiel ist das Erstellen eines leeren Vektors (Vec):
#![allow(unused)]
fn main() {
// Der Compiler weiß nicht, was später in die Liste hineinkommen soll!
// let liste = Vec::new(); // Compilerfehler!
}
Hier müssen wir dem Compiler helfen. Wir können das über eine Typ-Annotation der Variable tun (let liste: Vec<i32> = Vec::new();), oder wir nutzen den legendären Turbofisch-Operator ::<>:
#![allow(unused)]
fn main() {
// Der Turbofisch schwimmt direkt hinter dem Methodennamen herum!
let liste = Vec::<i32>::new();
}
Der Name “Turbofisch” kommt von der Form des Operators ::<>, die mit etwas Fantasie wie ein kleiner Fisch aussieht, der nach links schwimmt: :: ist das Auge, < das Maul und > die Schwanzflosse.
7. Compilerfehler-Show: Typische Fehler verstehen und beheben
Weil Generics mit Platzhaltern arbeiten, schränkt Rust ein, was du mit diesen Platzhaltern tun darfst. Standardmäßig darfst du mit einem Wert vom Typ T gar nichts tun, außer ihn im Speicher hin- und herzuschieben.
Lass uns einen typischen Fehler ansehen:
#![allow(unused)]
fn main() {
// Wir möchten zwei Werte addieren
fn addiere<T>(a: T, b: T) -> T {
a + b // Compilerfehler!
}
}
Die Fehlermeldung des Compilers:
error[E0369]: cannot add `T` to `T`
--> src/main.rs:3:7
|
3 | a + b
| - ^ - T
| |
| T
|
help: consider restricting type parameter `T`
|
2 | fn addiere<T: std::ops::Add<Output = T>>(a: T, b: T) -> T {
| +++++++++++++++++++++++++++
Warum schimpft der Compiler?
Der Platzhalter T steht für jeden beliebigen Typ. Was würde passieren, wenn jemand versucht, zwei String-Variablen oder zwei Punkt-Strukturen mit unserer Funktion zu addieren? Diese Typen haben standardmäßig keine Rechenoperation für das Pluszeichen definiert!
Der Compiler schützt uns vor diesem Fehler. Er sagt: “Du darfst das Pluszeichen nur verwenden, wenn du mir garantierst, dass der Typ T auch wirklich addiert werden kann!”
Wie wir diese Garantie (genannt Trait Bounds) formulieren, lernen wir im Profi-Teil des Kapitels.
8. Zusammenfassung
- Generics sind Schablonen (Ausstechformen) für Code. Sie verhindern, dass wir dieselbe Logik für unterschiedliche Typen mehrfach schreiben müssen.
- Der Compiler erzeugt zur Kompilierzeit konkreten Code für jeden tatsächlich verwendeten Typ. Das kostet keine Laufzeit-Performance.
- Bei generischen Strukturen und Funktionen deklarieren wir die Platzhalter in spitzen Klammern (z. B.
<T>). - Bei
impl-Blöcken müssen wir das generischeimpl<T>voranstellen, um den Platzhalter anzumelden. - Der Turbofisch
::<>hilft dem Compiler, wenn er den Typ eines Platzhalters nicht selbstständig erraten kann.