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

Praxisteil & Übungen: Strukturen (Structs) und impl-Blöcke in der Praxis

Herzlich willkommen zum Praxisteil von Kapitel 10! In der Theorie haben wir gelernt, wie wir eigene Datentypen entwerfen und mit Methoden ausstatten können. Jetzt werden wir dieses Wissen in einem realitätsnahen Praxisprojekt anwenden.

Wir werden gemeinsam den geometrischen Kern einer 2D-Zeichen-Engine entwerfen. Dabei lernen wir, wie wir Daten sauber kapseln, ungültige Zustände verhindern (z. B. negative Breiten oder Höhen) und Methoden zur Berechnung und Manipulation bereitstellen.

Die Übungsaufgabe befindet sich im Verzeichnis:


1. Das Praxis-Szenario: Der geometrische Engine-Kern

Stellen Sie sich vor, wir entwickeln die Grafik-Engine für ein neues CAD-Programm (computergestütztes Design) oder ein 2D-Spiel. Jedes grafische Objekt auf dem Bildschirm – ob Rechteck, Kreis oder Dreieck – hat bestimmte Eigenschaften (Breite, Höhe, Position) und Fähigkeiten (Fläche berechnen, Skalieren, Kollisionsprüfung).

Um dieses System robust und wartbar zu gestalten, wollen wir:

  1. Ein Rechteck (Rectangle) als Struktur definieren.
  2. Konstruktoren bereitstellen, die verhindern, dass ein Rechteck mit einer Breite oder Höhe von 0.0 oder weniger erstellt wird.
  3. Methoden zur Berechnung der Fläche (area) und des Umfangs (perimeter) implementieren.
  4. Eine Methode schreiben, die das Rechteck um einen bestimmten Faktor skaliert (scale).
  5. Eine Methode schreiben, mit der wir prüfen können, ob ein Rechteck vollständig in ein anderes hineinpasst (can_hold).

Die Alltagsanalogie: Der Fernseher und die Fernbedienung

Bevor wir in den Code eintauchen, hilft uns eine Analogie, den Unterschied zwischen struct und impl (Methoden) zu verstehen:

  • Das Struct (Der Fernseher): Das Struct ist das physische Gerät. Es hat bestimmte Komponenten und Eigenschaften: Eine Bildschirmdiagonale, ein Gewicht, eine Anzahl von HDMI-Anschlüssen und einen aktuellen Zustand (z. B. Lautstärke: 15, Kanal: 3). Diese Daten liegen im Gehäuse des Fernsehers.
  • Der impl-Block (Die Fernbedienung): Wir greifen nicht direkt in das Gehäuse und löten an den Schaltkreisen herum, um den Sender zu wechseln. Stattdessen nutzen wir die Fernbedienung. Die Fernbedienung bietet uns definierte Tasten (Methoden) an:
    • Kanal ansehen (&self): Wir schauen auf den Bildschirm. Der Zustand des Fernsehers ändert sich nicht. Wir lesen nur Informationen ab.
    • Lautstärke ändern (&mut self): Wir drücken die “Lauter”-Taste. Die Fernbedienung verändert den inneren Zustand des Fernsehers direkt.
    • Fernseher einschalten/erstellen (Assoziierte Funktion / Konstruktor): Wir drücken den Power-Knopf auf der Fernbedienung, um das System aus dem Standby-Modus zu wecken und zu initialisieren.

2. Strukturierte Praxis-Einheiten

2.1 Get Started: Die Rechteck-Struktur definieren

Ein Rechteck im zweidimensionalen Raum wird durch seine Breite (width) und seine Höhe (height) definiert. Wir nutzen dafür Fließkommazahlen (f64).

#![allow(unused)]
fn main() {
struct Rectangle {
    width: f64,
    height: f64,
}
}
  • struct: Signalisiert dem Compiler, dass ein neuer Datentyp deklariert wird.
  • Rectangle: Der Name unseres Typs im CamelCase.
  • width & height: Die Felder der Struktur. Sie sind standardmäßig privat und nur innerhalb des eigenen Moduls sichtbar.

2.2 Methoden zur reinen Informationsabfrage (&self)

Wenn wir die Fläche oder den Umfang berechnen wollen, müssen wir die Struktur nicht verändern. Wir benötigen also nur Lesezugriff auf das Objekt.

#![allow(unused)]
fn main() {
impl Rectangle {
    // Gibt die Fläche des Rechtecks zurück
    fn area(&self) -> f64 {
        self.width * self.height
    }

    // Gibt den Umfang des Rechtecks zurück
    fn perimeter(&self) -> f64 {
        2.0 * (self.width + self.height)
    }
}
}
  • &self: Dies ist die Kurzform für self: &Self. Es bedeutet, dass die Methode eine unveränderliche Referenz auf das aktuelle Objekt (Rectangle) erhält. Der Aufrufer behält das Ownership.

2.3 Methoden zur Zustandsänderung (&mut self)

Wenn wir unser Rechteck vergrößern oder verkleinern möchten (Skalierung), müssen wir die Werte von width und height modifizieren. Dazu benötigen wir eine veränderliche Referenz.

Wir wollen außerdem sicherstellen, dass der Skalierungsfaktor positiv ist. Hier nutzen wir die Rust-übliche Fehlerbehandlung mit Result.

#![allow(unused)]
fn main() {
impl Rectangle {
    fn scale(&mut self, factor: f64) -> Result<(), String> {
        if factor <= 0.0 {
            return Err(String::from("Der Skalierungsfaktor muss größer als 0.0 sein."));
        }
        self.width *= factor;
        self.height *= factor;
        Ok(())
    }
}
}
  • &mut self: Kurzform für self: &mut Self. Ermöglicht es der Methode, die Felder des Objekts direkt im Speicher zu modifizieren.
  • Result<(), String>: Gibt im Erfolgsfall nichts (()) zurück, im Fehlerfall eine beschreibende Fehlermeldung als String. Wir vermeiden unwrap() konsequent!

2.4 Der Compiler-Driven Development (CDD) Deep Dive: Fehler zeigen & beheben

Lassen Sie uns nun einen typischen Anfängerfehler betrachten, den der Rust-Compiler dank seiner strengen Speicherregeln (Borrow Checker) verhindert.

Stellen Sie sich vor, wir schreiben eine Methode zur Skalierung, machen aber einen entscheidenden Fehler in der Signatur: Wir vergessen das & und schreiben stattdessen self per Value (Besitzübergabe).

Der fehlerhafte Code:

struct Rectangle {
    width: f64,
    height: f64,
}

impl Rectangle {
    // FEHLER: 'self' wird per Value übergeben (Move)!
    fn scale_moved(mut self, factor: f64) -> Rectangle {
        self.width *= factor;
        self.height *= factor;
        self
    }
}

fn main() {
    let rect = Rectangle { width: 10.0, height: 5.0 };
    
    // Wir rufen die fehlerhafte Methode auf
    let scaled_rect = rect.scale_moved(2.0);
    
    // Versuchen wir nun, auf das ursprüngliche 'rect' zuzugreifen:
    println!("Ursprüngliche Breite: {}", rect.width);
}

Die Reaktion des Compilers:

Wenn wir versuchen, diesen Code mit cargo check zu prüfen, gibt uns der Compiler folgende Fehlermeldung aus:

error[E0382]: borrow of moved value: `rect`
  --> src/main.rs:22:43
   |
15 |     let rect = Rectangle { width: 10.0, height: 5.0 };
   |         ---- move occurs because `rect` has type `Rectangle`, which does not implement the `Copy` trait
16 |     
17 |     let scaled_rect = rect.scale_moved(2.0);
   |                            ---------------- `rect` moved due to this method call
...
22 |     println!("Ursprüngliche Breite: {}", rect.width);
   |                                           ^^^^^^^^^^ value borrowed here after move
   |
note: `Rectangle::scale_moved` takes ownership of the receiver `self`, which moves `rect`
  --> src/main.rs:8:20
   |
8  |     fn scale_moved(mut self, factor: f64) -> Rectangle {
   |                    ^^^^^^^^

Warum lehnt der Compiler das ab?

In Zeile 17 übergeben wir rect an scale_moved(mut self, ...). Da in Rust Strukturen standardmäßig die Move-Semantik besitzen (sie werden im Speicher verschoben, nicht kopiert), wandert der Besitz von rect vollständig in die Methode scale_moved. Nach dem Aufruf der Methode in Zeer 17 ist die Variable rect in main() “tot”. Ein Zugriff auf rect.width in Zeile 22 ist ein schwerer Fehler, da der Speicher bereits freigegeben oder ungültig sein könnte.

Wie beheben wir das?

Wir wollen das Objekt nicht verschieben, sondern es direkt modifizieren (In-Place Mutation). Dafür ändern wir die Signatur zu &mut self und rufen die Methode auf einer als veränderlich deklarierten Variable auf:

#![allow(unused)]
fn main() {
// Die Korrektur:
let mut rect = Rectangle { width: 10.0, height: 5.0 };
rect.scale(2.0).unwrap(); // Nun wird rect im Speicher geändert, kein Ownership-Transfer!
println!("Neue Breite: {}", rect.width); // Funktioniert!
}

3. Die vollständige Musterlösung

Der fertige, voll funktionsfähige Code der Übung befindet sich unter solutions/07_structs/src/main.rs:

1:  // Musterlösung: Geometrische Berechnungen mit Strukturen und impl-Blöcken
2:  
3:  #[derive(Debug)]
4:  struct Rectangle {
5:      width: f64,
6:      height: f64,
7:  }
8:  
9:  impl Rectangle {
10:     // Konstruktor: Erstellt ein neues Rechteck mit Validierung
11:     fn new(width: f64, height: f64) -> Result<Self, String> {
12:         if width <= 0.0 || height <= 0.0 {
13:             return Err(String::from(
14:                 "Breite und Höhe müssen strikt größer als 0.0 sein!"
15:             ));
16:         }
17:         Ok(Self { width, height })
18:     }
19: 
20:     // Methode zur Berechnung der Fläche (unveränderliche Referenz)
21:     fn area(&self) -> f64 {
22:         self.width * self.height
23:     }
24: 
25:     // Methode zur Berechnung des Umfangs (unveränderliche Referenz)
26:     fn perimeter(&self) -> f64 {
27:         2.0 * (self.width + self.height)
28:     }
29: 
30:     // Methode zur Skalierung des Rechtecks (veränderliche Referenz)
31:     fn scale(&mut self, factor: f64) -> Result<(), String> {
32:         if factor <= 0.0 {
33:             return Err(String::from(
34:                 "Der Skalierungsfaktor muss größer als 0.0 sein!"
35:             ));
36:         }
37:         self.width *= factor;
38:         self.height *= factor;
39:         Ok(())
40:     }
41: 
42:     // Prüft, ob ein anderes Rechteck vollständig in dieses passt
43:     fn can_hold(&self, other: &Rectangle) -> bool {
44:         self.width > other.width && self.height > other.height
45:     }
46: }
47: 
48: fn main() {
49:     // 1. Sichere Instanziierung über den Konstruktor
50:     let mut rect1 = match Rectangle::new(10.0, 5.0) {
51:         Ok(r) => r,
52:         Err(e) => {
53:             println!("Fehler beim Erstellen von rect1: {}", e);
54:             return;
55:         }
56:     };
57: 
58:     let rect2 = match Rectangle::new(8.0, 4.0) {
59:         Ok(r) => r,
60:         Err(e) => {
61:             println!("Fehler beim Erstellen von rect2: {}", e);
62:             return;
63:         }
64:     };
65: 
66:     // 2. Werte auslesen und berechnen
67:     println!("Rechteck 1: {:?}", rect1);
68:     println!("Fläche von rect1: {} Qm", rect1.area());
69:     println!("Umfang von rect1: {} m", rect1.perimeter());
70: 
71:     // 3. Überprüfung der can_hold-Methode
72:     if rect1.can_hold(&rect2) {
73:         println!("rect1 kann rect2 vollständig umschließen.");
74:     } else {
75:         println!("rect1 kann rect2 NICHT umschließen.");
76:     }
77: 
78:     // 4. Modifikation über veränderliche Referenz
79:     println!("Skaliere rect1 um Faktor 1.5...");
80:     if let Err(e) = rect1.scale(1.5) {
81:         println!("Fehler beim Skalieren: {}", e);
82:     } else {
83:         println!("Skaliertes Rechteck 1: {:?}", rect1);
84:         println!("Neue Fläche von rect1: {} Qm", rect1.area());
85:     }
86: 
87:     // 5. Test der Validierung
88:     let ungueltiges_rect = Rectangle::new(-3.0, 4.0);
89:     assert!(ungueltiges_rect.is_err());
90:     println!("Validierung funktioniert! Ungültiges Rechteck wurde abgelehnt.");
91: }

4. Anatomische Zeilenzerlegung und Detail-Analyse

Lassen Sie uns den Code der Musterlösung nun Zeile für Zeile genau analysieren:

  • Zeile 3: #[derive(Debug)] – Dies ist ein sogenanntes Makro-Attribut (auch Derivativ-Attribut genannt). Es weist den Rust-Compiler an, automatisch eine Implementierung des std::fmt::Debug-Traits für unsere Struktur zu generieren. Dadurch können wir die Struktur mit dem Platzhalter {:?} in println! ausgeben. Ohne dieses Attribut würde der Compiler die Ausgabe verweigern, da er nicht weiß, wie er die Struktur formatieren soll.
  • Zeilen 4–7: Hier definieren wir die Struktur Rectangle mit den beiden Feldern width (Breite) und height (Höhe) vom Typ f64 (64-Bit Fließkommazahl mit doppelter Genauigkeit). In Rust sind diese Felder standardmäßig privat.
  • Zeile 9: impl Rectangle – Wir öffnen den Implementierungsblock. Alle Funktionen, die hier definiert werden, sind eng mit der Struktur Rectangle verknüpft. Sie gehören zum Namensraum dieses Typs.
  • Zeilen 11–18: Dies ist ein Konstruktor (eine assoziierte Funktion).
    • Zeile 11: fn new(width: f64, height: f64) -> Result<Self, String> – Da diese Funktion kein self als ersten Parameter besitzt, ist sie eine assoziierte Funktion (vergleichbar mit einer statischen Methode in Java oder C++). Sie gibt ein Result zurück. Self (großgeschrieben) ist ein Alias für den Typ, für den der Block implementiert wird – in diesem Fall Rectangle.
    • Zeilen 12–16: Hier prüfen wir, ob die übergebenen Maße physikalisch sinnvoll sind. Ist einer der Werte kleiner oder gleich null, geben wir einen Fehler Err zurück. Das verhindert, dass wir im System mit physikalisch unmöglichen Rechtecken rechnen.
    • Zeile 17: Ok(Self { width, height }) – Wenn alles in Ordnung ist, erstellen wir die Instanz. Da die Parameter der Funktion exakt so heißen wie die Felder der Struktur (width und height), nutzen wir die praktische Field-Init-Shorthand-Syntax von Rust und schreiben einfach width statt width: width.
  • Zeilen 21–23: Die Methode area.
    • Zeile 21: fn area(&self) -> f64 – Die Methode nimmt &self (eine unveränderliche Referenz). Wir lesen nur die Werte.
    • Zeile 22: self.width * self.height – Wir greifen über das Schlüsselwort self und den Punkt-Operator auf die Felder der Struktur zu. Da dies der letzte Ausdruck der Funktion ist und kein Semikolon am Ende steht, wird das Ergebnis dieses Produkts implizit zurückgegeben (implicit return).
  • Zeilen 31–40: Die Methode scale.
    • Zeile 31: fn scale(&mut self, factor: f64) -> Result<(), String> – Da diese Methode den Zustand des Rechtecks ändert, erfordert sie &mut self.
    • Zeilen 32–36: Wir prüfen, ob der Skalierungsfaktor gültig ist. Ein negativer Faktor würde die Seitenlängen negativ machen, was wir verhindern müssen.
    • Zeilen 37–38: self.width *= factor; – Über die veränderliche Referenz modifizieren wir die Heap- oder Stack-Werte des Objekts direkt.
  • Zeilen 43–45: Die Methode can_hold.
    • Zeile 43: fn can_hold(&self, other: &Rectangle) -> bool – Hier nehmen wir zusätzlich zum eigenen Objekt (&self) auch eine unveränderliche Referenz auf ein anderes Rechteck (other: &Rectangle) entgegen. Dadurch vermeiden wir, dass das andere Rechteck beim Vergleich zerstört (moved) wird.
  • Zeilen 50–56: In main() rufen wir den Konstruktor mit Rectangle::new(10.0, 5.0) auf. Wir nutzen match, um das Result sauber zu entpacken. Da wir rect1 später skalieren möchten, müssen wir es mit let mut deklarieren.
  • Zeile 72: rect1.can_hold(&rect2) – Wir übergeben eine Referenz auf rect2 mittels &rect2. rect1 leiht sich die Maße von rect2 kurz aus, um den Vergleich durchzuführen.
  • Zeile 80: rect1.scale(1.5) – Wir rufen die Skalierungsmethode auf. Da rect1 als mut deklariert ist und der Compiler weiß, dass scale eine veränderliche Referenz benötigt, wandelt er diesen Aufruf implizit in (&mut rect1).scale(1.5) um (Auto-Deref-Coercion).