Kapitel 14: Generische Programmierung – Fortgeschrittene Typ-Abstraktion und Trait Bounds
In der professionellen Software-Architektur dienen Generics nicht nur der Vermeidung von Code-Duplizierung. Sie sind das primäre Werkzeug zur Definition von Schnittstellen-Garantien, zur Durchsetzung von Invarianten zur Compilezeit und zum Entwurf modularer, erweiterbarer Bibliotheken.
Um sinnvolle Logik auf generischen Typen auszuführen, müssen wir dem Compiler mitteilen, welche Fähigkeiten (Schnittstellen/Traits) diese Typen besitzen. Dies geschieht über Trait Bounds.
1. Lernziele – Das wirst du heute lernen
- Trait Bounds formulieren: Sie zwingen generische Typen, bestimmte Schnittstellen zu implementieren.
- Kombination von Bounds (
+): Sie fordern mehrere Fähigkeiten gleichzeitig von einem Typ ein. - Die
where-Klausel nutzen: Sie strukturieren komplexe Typparameter und erhöhen die Lesbarkeit. - Blanket-Implementierungen entwerfen: Sie implementieren Schnittstellen pauschal für ganze Typfamilien.
- Assoziierte Typen beherrschen: Sie verstehen den Unterschied zu Standard-Typparametern.
- Const Generics anwenden: Sie binden konstante Werte (wie Array-Größen) in das Typsystem ein.
2. Trait Bounds: Fähigkeiten einfordern
Wie wir im Anfänger-Teil gesehen haben, verweigert der Compiler Rechenoperationen auf einem nackten Typ T. Wir müssen dem Compiler garantieren, dass T das mathematische Trait Add implementiert.
Hier ist die Lösung für unsere Additionsfunktion:
use std::ops::Add;
// Wir schränken T mit der Syntax "T: Add" ein.
// Das bedeutet: "T kann jeder Typ sein, solange er das Add-Trait implementiert."
// Output = T spezifiziert, dass das Ergebnis der Addition wieder vom Typ T ist.
fn addiere<T>(a: T, b: T) -> T
where
T: Add<Output = T>
{
a + b
}
fn main() {
let summe = addiere(5, 10);
println!("Summe: {}", summe);
}
Kombination mehrerer Bounds (+)
Manchmal muss ein Typ mehrere Anforderungen erfüllen. Wenn wir einen Wert klonen und ausgeben wollen, muss der Typ sowohl Clone als auch Display implementieren:
#![allow(unused)]
fn main() {
use std::fmt::Display;
fn kloniere_und_drucke<T: Display + Clone>(wert: &T) {
let kopie = wert.clone(); // Benötigt Clone
println!("Kopie: {}", kopie); // Benötigt Display
}
}
3. Die where-Klausel für sauberen Code
Wenn Sie Funktionen mit mehreren generischen Parametern schreiben, die jeweils mehrere Trait Bounds erfordern, wird die Funktionssignatur schnell unlesbar:
#![allow(unused)]
fn main() {
// Unübersichtlich und schwer zu lesen:
fn verarbeite_schlecht<T: Clone + Default, U: std::fmt::Debug + std::hash::Hash>(t: T, u: U) {
// ...
}
}
Rust bietet die where-Klausel, um die Einschränkungen übersichtlich hinter die Funktionssignatur zu verlagern. Dies entspricht dem Industriestandard für sauberen Rust-Code:
#![allow(unused)]
fn main() {
// Aufgeräumt und lesbar:
fn verarbeite_gut<T, U>(t: T, u: U)
where
T: Clone + Default,
U: std::fmt::Debug + std::hash::Hash,
{
// ...
}
}
4. Blanket-Implementierungen: Pauschale Traits
Eine Blanket-Implementierung (auch pauschale Implementierung genannt) erlaubt es Ihnen, ein Trait für jeden Typ zu implementieren, der bereits ein anderes bestimmtes Trait erfüllt.
Ein bekanntes Beispiel aus der Standardbibliothek ist das ToString-Trait. Jeder Typ, der sich über Display ausgeben lässt, bekommt die Konvertierung in einen String automatisch geschenkt:
#![allow(unused)]
fn main() {
// Vereinfachte Darstellung aus der Standardbibliothek:
impl<T> ToString for T
where
T: std::fmt::Display
{
fn to_string(&self) -> String {
format!("{}", self)
}
}
}
Warum ist das nützlich?
Sie sparen sich das manuelle Schreiben von Boilerplate-Code. Sobald Sie für Ihre Struktur Display implementieren, können Sie sofort .to_string() aufrufen.
5. Assoziierte Typen vs. Typparameter
Beim Entwurf von Traits stoßen wir auf zwei Wege, um mit Typen zu arbeiten:
- Generische Typparameter (
Trait<T>): Der Typ wird in spitzen Klammern übergeben. - Assoziierte Typen (
type Item): Der Typ wird als Platzhalter innerhalb des Traits deklariert.
Wann nutzt man was?
- Generische Parameter: Wenn es sinnvoll ist, dass ein Typ dasselbe Trait mehrfach für unterschiedliche Typen implementiert.
- Beispiel:
From<T>. Eine StrukturStrassemöchte sowohlFrom<String>als auchFrom<&str>implementieren. Das muss generisch sein.
- Beispiel:
- Assoziierte Typen: Wenn es für jeden implementierenden Typ nur genau eine logische Kombination gibt.
- Beispiel:
Iterator. EinKartenstapel-Iteratorliefert immer nur Objekte vom TypKartezurück. Es macht keinen Sinn, dass derselbe Iterator gleichzeitigi32undStringliefert. Der Typ der Elemente ist fest mit dem Iterator verbunden.
- Beispiel:
#![allow(unused)]
fn main() {
// Ein Trait mit einem assoziierten Typ
pub trait Sammler {
type Inhalt; // Assoziierter Typ
fn sammeln(&mut self) -> Option<Self::Inhalt>;
}
}
6. Const Generics: Werte im Typsystem
Seit Rust 1.51 können Generics nicht mehr nur Typen, sondern auch konstante Werte als Parameter akzeptieren. Dies ist besonders nützlich für die Arbeit mit Arrays, da in Rust die Größe eines Arrays Teil seines Typs ist (ein [i32; 4] ist ein völlig anderer Typ als ein [i32; 8]).
// N ist ein Const Generic vom Typ usize
struct Matrix<T, const SPALTEN: usize, const ZEILEN: usize> {
daten: [[T; SPALTEN]; ZEILEN],
}
fn main() {
// Eine 3x2 Matrix aus Ganzzahlen
let m = Matrix {
daten: [
[1, 2, 3],
[4, 5, 6]
]
};
}
Reihenfolge der Parameter
Wenn Sie Lebenszeiten, Typparameter und Const Generics kombinieren, müssen Sie die vom Compiler vorgeschriebene Reihenfolge einhalten:
- Lebenszeiten (z. B.
'a) - Typparameter (z. B.
T) - Const Generics (z. B.
const N: usize)
#![allow(unused)]
fn main() {
struct ReferenzPuffer<'a, T, const N: usize> {
daten: [&'a T; N],
}
}