Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Kapitel 14: Generische Programmierung

Beim Schreiben von Software stellen wir oft fest, dass derselbe Algorithmus oder dieselbe Datenstruktur für unterschiedliche Datentypen identisch funktioniert. Ein klassisches Beispiel ist ein Stack (Stapelspeicher): Ob wir Ganzzahlen, Strings oder benutzerdefinierte Strukturen auf den Stapel legen, die Logik für push und pop bleibt völlig unverändert.

In vielen Sprachen löst man dies, indem man einen gemeinsamen Basis-Typ verwendet (wie Object in Java oder any in Go/TypeScript). Dies führt jedoch zu Laufzeit-Overhead, Typunsicherheit und manuellem Type-Casting.

Rust wählt einen anderen Weg: Generics (Generische Programmierung). Generics ermöglichen es uns, Typen und Funktionen mit “Platzhaltern” zu definieren, ohne an Performance einzubüßen.

In diesem Kapitel bieten wir Ihnen drei verschiedene Perspektiven auf das Thema an. Wählen Sie die Sicht, die am besten zu Ihrem Hintergrund passt:

  • Für Anfänger (Einfach): Konzentriert sich auf das Konzept der Ausstechform, generische Funktionen und Strukturen, Enums mit Generics (Option und Result) sowie den Turbofisch-Operator.
  • für Profis (Architektur): Behandelt Trait Bounds (Typ-Einschränkungen), die where-Klausel, Blanket-Implementierungen, assoziierte Typen vs. Typparameter und Const Generics.
  • Hardware-Sicht (CPU/RAM): Analysiert die Monomorphisierung unter der Lupe, Speichergrößen, den Binary Bloat (Code-Aufblähung) und das Entwurfsmuster der “inneren nicht-generischen Hilfsfunktion”.

Begleitvideo zu Kapitel 14: Generische Programmierung


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:

  1. Ah, der Entwickler ruft wähle_erstes mit zwei Ganzzahlen auf. Ich erstelle im fertigen Programm heimlich eine Variante der Funktion, die nur für Ganzzahlen (i32) da ist.
  2. Oh, und da ist noch ein Aufruf mit Text (&str). Ich erstelle eine weitere Variante der Funktion, die nur für Text da ist.
  3. Der Platzhalter T wird 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

  1. Generics sind Schablonen (Ausstechformen) für Code. Sie verhindern, dass wir dieselbe Logik für unterschiedliche Typen mehrfach schreiben müssen.
  2. Der Compiler erzeugt zur Kompilierzeit konkreten Code für jeden tatsächlich verwendeten Typ. Das kostet keine Laufzeit-Performance.
  3. Bei generischen Strukturen und Funktionen deklarieren wir die Platzhalter in spitzen Klammern (z. B. <T>).
  4. Bei impl-Blöcken müssen wir das generische impl<T> voranstellen, um den Platzhalter anzumelden.
  5. Der Turbofisch ::<> hilft dem Compiler, wenn er den Typ eines Platzhalters nicht selbstständig erraten kann.

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:

  1. Generische Typparameter (Trait<T>): Der Typ wird in spitzen Klammern übergeben.
  2. 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 Struktur Strasse möchte sowohl From<String> als auch From<&str> implementieren. Das muss generisch sein.
  • Assoziierte Typen: Wenn es für jeden implementierenden Typ nur genau eine logische Kombination gibt.
    • Beispiel: Iterator. Ein Kartenstapel-Iterator liefert immer nur Objekte vom Typ Karte zurück. Es macht keinen Sinn, dass derselbe Iterator gleichzeitig i32 und String liefert. Der Typ der Elemente ist fest mit dem Iterator verbunden.
#![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:

  1. Lebenszeiten (z. B. 'a)
  2. Typparameter (z. B. T)
  3. Const Generics (z. B. const N: usize)
#![allow(unused)]
fn main() {
struct ReferenzPuffer<'a, T, const N: usize> {
    daten: [&'a T; N],
}
}

Kapitel 14 - Hardware-Sicht: Generische Programmierung unter der Lupe von CPU und RAM

Hallo Thorsten! Nachdem wir uns mit den ausdrucksstarken Möglichkeiten von Trait Bounds und den Architekturmustern generischer Programmierung beschäftigt haben, reißen wir jetzt die Motorhaube auf.

In vielen objektorientierten Sprachen wie Java oder C# werden Generics über Type Erasure (Typ-Löschung) implementiert. Das bedeutet, dass der Compiler zur Laufzeit alle Typinformationen verwirft und stattdessen mit Zeigern auf ein universelles Basisobjekt (z. B. Object) arbeitet. Dies spart zwar Platz in der Binärdatei, kostet aber enorm viel Performance, da die CPU ständig Zeiger dereferenzieren muss (Indirektion) und Werte auf dem Heap allokiert werden müssen (Boxing).

Rust geht den entgegengesetzten Weg: Null-Kosten-Abstraktion (Zero-Cost Abstractions). Generics werden in Rust so übersetzt, dass sie zur Laufzeit exakt die Performance von handgeschriebenem, spezialisiertem Code haben.


1. Die Monomorphisierung im Detail

Der Prozess, der dies ermöglicht, heißt Monomorphisierung (von griechisch mono = einzeln und morph = Form; “in eine einzelne Form bringen”).

Schauen wir uns an, was der Compiler aus generischem Code macht:

// Quellcode, den Sie schreiben:
struct Wrapper<T> {
    wert: T,
}

fn main() {
    let a = Wrapper { wert: 42i32 };
    let b = Wrapper { wert: 3.14f64 };
}

Wenn der Compiler diesen Code übersetzt, führt er im Hintergrund folgende Schritte aus:

  1. Er analysiert die main-Funktion und stellt fest, dass Wrapper mit i32 und f64 aufgerufen wird.
  2. Er dupliziert die Strukturdefinition und generiert konkreten Maschinencode für beide Typen.
  3. Er ersetzt die generischen Aufrufe durch die spezifischen Typen.

Der generierte Zwischencode sieht gedanklich so aus:

// Vom Compiler generierter Code:
struct Wrapper_i32 {
    wert: i32,
}

struct Wrapper_f64 {
    wert: f64,
}

fn main() {
    let a = Wrapper_i32 { wert: 42i32 };
    let b = Wrapper_f64 { wert: 3.14f64 };
}

Die Hardware-Auswirkung von Monomorphisierung:

  • Kein Laufzeit-Overhead: Die CPU führt exakt dieselben Befehle aus, als hättest du zwei separate Strukturen geschrieben. Es gibt keine Indirektionen, keine Vtables (Dynamic Dispatch) und kein Laufzeit-Type-Casting.
  • Effizientes Alignment: Der Compiler berechnet das Speicherlayout für jede Variante optimal. Ein Wrapper_i32 belegt 4 Bytes im RAM (mit einem Alignment von 4), während ein Wrapper_f64 8 Bytes belegt (Alignment 8).

2. Die Schattenseite: Code-Aufblähung (Binary Bloat)

Die Monomorphisierung klingt nach einem Traum für Performance-Enthusiasten. Sie hat jedoch einen physikalischen Haken: Speicherplatz.

Wenn du eine komplexe generische Funktion (z. B. einen Sortieralgorithmus mit Hunderten Zeilen Code) mit 15 verschiedenen Datentypen aufrufst, kopiert der Compiler diese Funktion 15-mal in deine ausführbare Binärdatei.

Das hat zwei gravierende Nachteile für die Hardware:

  1. Größere Binärdatei: Die ausführbare Datei wächst stark an (Binary Bloat).
  2. I-Cache-Verschmutzung (Instruction Cache Pollution): CPUs besitzen einen sehr schnellen, aber winzigen internen Speicher für Instruktionen (den L1-Instruction-Cache). Wenn das Programm ständig zwischen verschiedenen monomorphisierten Kopien derselben Funktion hin- und herspringt, passt der ausführbare Code nicht mehr in den Cache. Die CPU muss den Code aus dem langsameren Hauptspeicher (RAM) nachladen. Die Folge? Cache-Misses und ein spürbarer Performance-Einbruch.

3. Optimierungsmuster: Die innere nicht-generische Hilfsfunktion

Um die Code-Aufblähung bei großen generischen Funktionen zu verhindern, wendet die Rust-Standardbibliothek (und professionelle Bibliotheken) oft ein fortgeschrittenes Entwurfsmuster an: Thin Generic Wrappers.

Dabei wird die komplexe Logik aus der generischen Funktion in eine innere, nicht-generische Funktion ausgelagert, die mit rohen Typen (z. B. Zeigern und Byte-Größen) arbeitet. Die generische Funktion dient nur noch als typsichere Hülle.

Schauen wir uns dieses Muster an:

#![allow(unused)]
fn main() {
// Die generische API, die der Benutzer sieht:
pub fn daten_verarbeiten<T>(daten: &mut [T]) {
    // Wir konvertieren den typisierten Slice in einen rohen Byte-Slice
    let ptr = daten.as_mut_ptr() as *mut u8;
    let laenge = daten.len();
    let element_groesse = std::mem::size_of::<T>();

    // Wir rufen die eigentliche, nicht-generische Implementierung auf
    unsafe {
        hilfs_verarbeitung(ptr, laenge, element_groesse);
    }
}

// Diese Funktion enthält die gesamte komplexe Logik.
// Da sie KEINE generischen Parameter besitzt, existiert sie im fertigen
// Programm genau EINMAL!
unsafe fn hilfs_verarbeitung(daten: *mut u8, laenge: usize, element_groesse: usize) {
    for i in 0..laenge {
        let element_ptr = daten.add(i * element_groesse);
        // Komplexe byteweise Verarbeitung hier...
    }
}
}

Der Gewinn für die Hardware:

  • Die große Funktion hilfs_verarbeitung belegt nur einmal Platz im I-Cache der CPU.
  • Die generische Funktion daten_verarbeiten<T> wird zwar weiterhin monomorphisiert, ist aber so winzig (sie reicht nur Parameter durch), dass die Kopien kaum ins Gewicht fallen.
  • Wir behalten die volle Typsicherheit beim Aufruf, optimieren aber das Cache-Verhalten der CPU!

4. Verweis auf Übungen

Sie haben nun gelernt, wie Sie Generics deklarieren, Trait Bounds formulieren und wie diese hardwareseitig übersetzt werden. Jetzt ist es an der Zeit, dieses Wissen in die Praxis umzusetzen.

Wechseln Sie in das Verzeichnis: exercises/04_collections/ (oder ein entsprechendes Generics-Verzeichnis Ihres Übungs-Workspaces).

Dort finden Sie praktische Aufgaben, bei denen Sie:

  1. Eine generische Struktur für ein Wertepaar implementieren müssen.
  2. Trait Bounds hinzufügen müssen, um mathematische Operationen zu erlauben.
  3. Die where-Klausel nutzen sollen, um unleserlichen Code aufzuräumen.