Kapitel 10: Strukturen (Structs) zur Datenkapselung
In der realen Softwareentwicklung reicht es selten aus, nur mit einzelnen, losen Variablen oder einfachen Standard-Kollektionen zu arbeiten. Wenn wir ein System bauen, wollen wir die Konzepte der echten Welt – wie einen Benutzer, ein Produkt oder eine Restaurantbestellung – als Einheit im Code abbilden. Hier kommen Strukturen (Structs) ins Spiel. Sie erlauben es uns, zusammengehörende Daten unterschiedlicher Typen zu einem neuen, benutzerdefinierten Typ zu bündeln und diesen logisch zu kapseln.
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 den Lego-Bauplan (Structs), die drei Typen von Strukturen (Classic, Tuple und Unit-like Structs) und
impl-Blöcke als Fähigkeiten-Bücher für Methoden. - für Profis (Architektur): Behandelt Kapselung von Invarianten, das Newtype-Pattern, das Typ-Zustands-Pattern (Type State) für compile-time Zustandsmaschinen, und die Update-Syntax samt Move-Semantik.
- Hardware-Sicht (CPU/RAM): Analysiert Alignment, Padding, Field Reordering,
#[repr(C)]/#[repr(packed)]und den Speicherbedarf von Classic, Tuple und Zero Sized Unit-like Structs.
Begleitvideo zu Kapitel 10: Strukturen (Structs) zur Datenkapselung
Kapitel 10: Strukturen (Structs) zur Datenkapselung – Für Anfänger
Herzlich willkommen zu Kapitel 10! Wenn du bisher den Lernpfad aufmerksam verfolgt hast, kennst du bereits einfache Variablen wie Zahlen, Texte und Wahrheitswerte. Aber in der echten Welt (und in echten Computerprogrammen) haben die Dinge, mit denen wir arbeiten wollen, viele verschiedene Eigenschaften gleichzeitig.
Stell dir vor, du möchtest ein Videospiel programmieren. Ein Spieler in deinem Spiel hat einen Namen, Lebenspunkte, eine Position auf dem Bildschirm und vielleicht eine Anzahl gesammelter Münzen. Bisher müsstest du für jede dieser Eigenschaften eine eigene, lose Variable anlegen. Das wird unglaublich schnell unübersichtlich und fehleranfällig!
Hier kommen Strukturen (engl. Structs) ins Spiel. Sie erlauben es uns, zusammengehörende Daten unterschiedlicher Typen zu einem einzigen Paket zu schnüren. In diesem Kapitel lernst du Schritt für Schritt, wie das funktioniert.
1. Die Alltagsanalogie: Der Lego-Bauplan
Bevor wir uns den Code anschauen, lass uns eine einfache Analogie aus dem Alltag nutzen: Ein Lego-Bauplan.
Wenn du eine Packung Lego kaufst (zum Beispiel für ein rotes Feuerwehrauto), bekommst du eine Bauanleitung.
- Der Bauplan selbst ist noch kein echtes Spielzeugauto. Er liegt flach auf dem Tisch und beschreibt nur ganz genau, welche Steine wohin gehören: Vier Räder, eine Leiter, ein Blaulicht und eine Fahrerkabine.
- Wenn du den Schritten folgst und die Steine zusammensteckst, erschaffst du eine Instanz (oder ein Objekt) des Feuerwehrautos. Das ist das echte, physische Spielzeug, mit dem du auf dem Teppich herumfahren kannst. Du kannst es anfassen, die Leiter hochklappen oder eine Lego-Figur hineinsetzen.
In Rust ist ein Struct genau dieser Bauplan. Wir beschreiben dem Computer einmalig, wie unser neuer Datentyp aussehen soll. Danach können wir beliebig viele “echte” Exemplare (Instanzen) nach diesem Plan bauen und sie mit echten Werten befüllen.
2. Die drei Struktur-Typen in Rust
Rust ist sehr flexibel und bietet uns drei verschiedene Arten von Bauplänen an, je nachdem, was wir abbilden möchten. Wir schauen uns alle drei im Detail an.
A. Klassische Strukturen (Classic Structs) – Der Steckbrief
Die am häufigsten genutzte Art ist das klassische Struct. Du kannst es dir wie einen ausgefüllten Steckbrief oder einen Personalausweis vorstellen. Jedes Feld im Struct hat ein festes Etikett (einen Namen) und einen bestimmten Datentyp.
Lass uns einen Steckbrief für ein Haustier entwerfen. Wir definieren zuerst den Bauplan. Das machen wir außerhalb der main-Funktion:
#![allow(unused)]
fn main() {
// Der Bauplan für unser Haustier
struct Haustier {
name: String, // Das Feld 'name' speichert einen Text
alter: u32, // Das Feld 'alter' speichert eine positive Ganzzahl (Jahre)
ist_hungrig: bool, // Das Feld 'ist_hungrig' speichert einen Wahrheitswert (ja/nein)
}
}
Note
Was bedeuten die Symbole?
struct: Dieses Schlüsselwort sagt dem Compiler: “Achtung, jetzt definiere ich einen neuen Bauplan!”Haustier: Das ist der Name unseres neuen Typs. Im Rust-Stil schreiben wir diesen Namen in der sogenannten CamelCase-Schreibweise (jeder Wortanfang ist ein Großbuchstabe, keine Unterstriche).- Die geschweiften Klammern
{ ... }umschließen die einzelnen Felder. Jedes Feld besteht aus einem Namen (z. B.name), gefolgt von einem Doppelpunkt und dem Typ (z. B.String). Die Felder werden durch Kommas getrennt.
Jetzt haben wir den Bauplan erstellt. Aber wie bauen wir nun ein echtes Haustier daraus? Das machen wir in der main-Funktion, indem wir die Struktur instanziieren:
fn main() {
// Hier erschaffen wir ein konkretes Haustier aus unserem Bauplan
let mein_hund = Haustier {
name: String::from("Bello"),
alter: 3,
ist_hungrig: true,
};
// Wir können auf die einzelnen Eigenschaften mit dem Punkt-Operator zugreifen
println!("Mein Hund heißt {}.", mein_hund.name);
println!("Er ist {} Jahre alt.", mein_hund.alter);
if mein_hund.ist_hungrig {
println!("Bello wedelt mit dem Schwanz und wartet auf Futter!");
} else {
println!("Bello schläft zufrieden in seinem Körbchen.");
}
}
Der Punkt-Operator (.)
Um an die Daten im Inneren unseres Structs heranzukommen, nutzen wir den Punkt ..
Schreibst du mein_hund.name, sagst du dem Computer: “Gehe zur Variable mein_hund, suche das Fach mit der Aufschrift name und gib mir den Inhalt.”
Wie machen wir ein Struct veränderlich?
Standardmäßig sind alle Variablen in Rust unveränderlich (immutable). Das gilt natürlich auch für Strukturen. Wenn wir versuchen, Bellos Alter zu ändern, schlägt der Compiler sofort Alarm.
Lass uns das an einem bewussten Compilerfehler ausprobieren. Stell dir vor, du schreibst folgenden Code:
fn main() {
let mein_hund = Haustier {
name: String::from("Bello"),
alter: 3,
ist_hungrig: true,
};
// Fehler-Versuch: Bello hat Geburtstag und wird 4!
mein_hund.alter = 4;
}
Wenn du versuchst, diesen Code zu kompilieren, wird dir der Rust-Compiler eine Fehlermeldung präsentieren, die ungefähr so aussieht:
error[E0594]: cannot assign to `mein_hund.alter`, as `mein_hund` is not declared as mutable
--> src/main.rs:10:5
|
5 | let mein_hund = Haustier {
| --------- help: consider making this binding mutable: `mut mein_hund`
...
10 | mein_hund.alter = 4;
| ^^^^^^^^^^^^^^^^^^^ cannot assign
Die Erklärung des Compilers:
Der Compiler verbietet uns die Änderung, weil mein_hund nicht als veränderlich (mut) deklariert wurde. Rust erlaubt es uns nicht, einzelne Felder im Bauplan als veränderlich zu markieren (z. B. struct Haustier { mut alter: u32 } gibt einen Syntaxfehler). Stattdessen müssen wir die gesamte Variable beim Erstellen veränderlich machen:
fn main() {
// Durch das 'mut' wird das gesamte Objekt veränderlich
let mut mein_hund = Haustier {
name: String::from("Bello"),
alter: 3,
ist_hungrig: true,
};
// Jetzt klappt es! Bello feiert Geburtstag
mein_hund.alter = 4;
println!("Bello ist jetzt {} Jahre alt!", mein_hund.alter);
}
B. Tupel-Strukturen (Tuple Structs) – Die Koordinaten
Manchmal brauchst du ein Struct, bei dem die einzelnen Felder gar keine komplizierten Namen haben müssen. Stell dir vor, du möchtest eine Farbe auf dem Bildschirm im RGB-Format (Rot, Grün, Blau) speichern. Jeder dieser Werte ist einfach eine Zahl zwischen 0 und 255. Hier wäre es unnötig lang, immer rot: 255, gruen: 0, blau: 0 zu schreiben.
Hierfür gibt es Tupel-Strukturen. Sie haben zwar einen Namen für den Gesamttyp, aber ihre inneren Felder sind unbenannt und werden nur durch ihre Position (ihren Index) unterschieden.
// Wir definieren eine Tupel-Struktur für eine RGB-Farbe
// Jedes der drei Felder ist ein u8 (Zahlen von 0 bis 255)
struct Farbe(u8, u8, u8);
// Wir definieren eine Tupel-Struktur für einen Punkt im 2D-Raum
struct Punkt2D(i32, i32);
fn main() {
// Instanziierung: Wir übergeben die Werte einfach in Klammern
let rot = Farbe(255, 0, 0);
let startpunkt = Punkt2D(10, -5);
// Zugriff erfolgt über den Punkt-Operator und den Index (startend bei 0)
println!("Roter Farbwert: (R: {}, G: {}, B: {})", rot.0, rot.1, rot.2);
println!("Der Startpunkt liegt bei X: {} und Y: {}", startpunkt.0, startpunkt.1);
}
Tip
Wann benutze ich was?
- Verwende klassische Structs, wenn die Felder unterschiedliche Bedeutungen haben und der Code lesbarer wird, wenn jedes Feld einen Namen hat (z. B.
Benutzer { name, email, alter }).- Verwende Tupel-Structs, wenn es sich um einfache mathematische Werte, Koordinaten oder Farbwerte handelt, bei denen die Position der Werte selbsterklärend ist (z. B.
Punkt3D(x, y, z)).
C. Unit-ähnliche Strukturen (Unit-like Structs) – Der Stempel
Die dritte und ungewöhnlichste Art sind die Unit-ähnlichen Strukturen. Sie heißen so, weil sie dem leeren Typ () (in Rust als “Unit” bezeichnet) ähneln: Sie haben überhaupt keine Felder und speichern somit keinerlei Daten!
Du fragst dich vielleicht: “Warum sollte ich einen Bauplan für etwas erstellen, das gar keine Daten enthält?”
Die Alltagsanalogie hierzu ist ein Stempel auf der Hand oder eine Eintrittskarte. Der Stempel selbst enthält keine komplizierten Daten über dich (kein Name, kein Alter). Aber die Tatsache, dass du den Stempel trägst, signalisiert dem Türsteher: “Diese Person hat bezahlt und darf rein.”
In Rust nutzen wir Unit-like Structs oft als Signal für den Compiler, um Eigenschaften (sogenannte Traits) zu implementieren, ohne dass wir dafür Speicherplatz verbrauchen müssen.
// Eine Struktur ohne Felder. Keine Klammern, einfach ein Semikolon!
struct AdminBerechtigung;
fn main() {
// Wir können eine Instanz davon erstellen
let berechtigung = AdminBerechtigung;
// 'berechtigung' belegt 0 Byte Speicher, existiert aber als Typ im System!
println!("Berechtigung erfolgreich erstellt!");
}
3. Dem Lego-Stein Leben einhauchen: impl-Blöcke und Methoden
Bisher haben unsere Strukturen nur Daten stumm in sich getragen. Sie waren wie Lego-Steine, die regungslos auf dem Teppich liegen. Aber in der Programmierung wollen wir, dass Daten auch Dinge tun können.
Stell dir vor, wir möchten, dass unser Haustier bellen oder fressen kann. Im klassischen Programmierstil müsste man dazu eine separate Funktion schreiben, die das Haustier als Argument übergeben bekommt:
#![allow(unused)]
fn main() {
// Klassische Funktion außerhalb des Structs:
fn fuettere_haustier(tier: &mut Haustier) {
tier.ist_hungrig = false;
}
}
Das funktioniert zwar, ist aber nicht besonders elegant. Schön wäre es, wenn das Haustier die Fähigkeit zu fressen direkt “in sich” trägt.
Dazu nutzen wir einen impl-Block (kurz für Implementation, also Umsetzung). Du kannst dir den impl-Block wie das Fähigkeiten-Buch unserer Struktur vorstellen. Alles, was wir in diesen Block hineinschreiben, sind Funktionen, die fest mit unserer Struktur verknüpft sind. Wir nennen sie dann Methoden.
Lass uns das Fähigkeiten-Buch für unser Haustier schreiben:
#![allow(unused)]
fn main() {
struct Haustier {
name: String,
alter: u32,
ist_hungrig: bool,
}
// Hier beginnt das Fähigkeiten-Buch (impl-Block) für 'Haustier'
impl Haustier {
// Fähigkeit 1: Laut geben (nur lesen)
// Weil wir die Daten nur lesen, leihen wir uns das Tier unveränderlich aus: &self
fn gib_laut(&self) {
println!("{} sagt: Wuff! Wuff!", self.name);
}
// Fähigkeit 2: Fressen (Daten verändern)
// Weil wir das Feld 'ist_hungrig' ändern wollen, brauchen wir veränderliches Ausleihen: &mut self
fn friss(&mut self) {
if self.ist_hungrig {
self.ist_hungrig = false;
println!("{} frisst den Napf leer. Mampf, mampf!", self.name);
} else {
println!("{} schnuppert nur am Futter. Keinen Hunger!", self.name);
}
}
}
}
Was bedeuten self, &self und &mut self?
Das Wichtigste in einer Methode ist der erste Parameter. Er heißt immer self (auf Deutsch: “selbst”). Damit weiß Rust, dass diese Methode auf einer konkreten Instanz aufgerufen wird. Es gibt drei Varianten davon:
-
&self(Unveränderliches Ausleihen): Die Methode darf die Daten der Struktur lesen, aber nicht verändern. Das ist die am häufigsten genutzte Variante (z. B. für eine Methodegib_lautoderzeige_status). -
&mut self(Veränderliches Ausleihen): Die Methode darf die Daten der Struktur verändern (z. B. den Hunger auffalsesetzen oder die Lebenspunkte verringern). -
self(Besitz übernehmen / Ownership): Die Methode übernimmt den vollständigen Besitz des Objekts und “konsumiert” es. Nach dem Aufruf ist das Objekt gelöscht und kann im restlichen Programm nicht mehr benutzt werden. Das ist so, als ob du eine Eintrittskarte entwertest: Danach ist sie zerrissen und unbrauchbar. Dies verwendet man nur in sehr speziellen Fällen.
Wie ruft man Methoden auf?
Das Aufrufen von Methoden ist kinderleicht. Wir nutzen wieder unseren altbekannten Punkt-Operator .:
fn main() {
let mut mein_hund = Haustier {
name: String::from("Bello"),
alter: 3,
ist_hungrig: true,
};
// Wir rufen die Methoden auf!
mein_hund.gib_laut(); // Gibt aus: Bello sagt: Wuff! Wuff!
mein_hund.friss(); // Bello frisst, 'ist_hungrig' wird zu false
mein_hund.friss(); // Bello hat keinen Hunger mehr und schnuppert nur
}
4. Assoziierte Funktionen – Die Geburtshelfer (Konstruktoren)
Vielleicht ist dir aufgefallen, dass das manuelle Erstellen einer Struktur über die geschweiften Klammern recht viel Schreibarbeit erfordert:
#![allow(unused)]
fn main() {
let mein_hund = Haustier { name: String::from("Bello"), alter: 3, ist_hungrig: true };
}
In vielen anderen Programmiersprachen gibt es dafür spezielle “Konstruktoren” (wie new). Rust hat kein eigenes Schlüsselwort dafür, erlaubt es uns aber, ganz normale Funktionen in den impl-Block zu schreiben, die keinen self-Parameter besitzen.
Da sie kein self haben, arbeiten sie nicht auf einem bereits existierenden Objekt, sondern sind an den Typ selbst gekoppelt. Wir nennen sie assoziierte Funktionen (oder statische Methoden). Wir nutzen sie meistens, um neue Instanzen bequem zu erstellen:
#![allow(unused)]
fn main() {
impl Haustier {
// Eine assoziierte Funktion zum Erstellen eines neuen, jungen, hungrigen Tiers
// Sie bekommt den Namen übergeben und gibt ein fertiges 'Haustier' zurück
fn neu(name: String) -> Haustier {
Haustier {
name, // Feld-Initialisierungs-Kurzschreibweise
alter: 0, // Standardwert: frisch geboren
ist_hungrig: true, // Standardwert: Babys haben immer Hunger!
}
}
}
}
Note
Was bedeutet
namestattname: name? Rust bietet uns eine tolle Abkürzung: Wenn der Name des Parameters (name) exakt mit dem Namen des Feldes in der Struktur übereinstimmt, müssen wir nichtname: nameschreiben. Ein einfachesnamereicht völlig aus! Das nennt man Field Init Shorthand.
Um eine solche assoziierte Funktion aufzurufen, nutzen wir nicht den Punkt, sondern den doppelten Doppelpunkt :::
fn main() {
// Wir erstellen ein neues Haustier mit der assoziierten Funktion
let mut welpe = Haustier::neu(String::from("Strolchi"));
println!("Welpe {} wurde geboren und ist {} Jahre alt.", welpe.name, welpe.alter);
welpe.friss();
}
Der doppelte Doppelpunkt :: sagt dem Computer: “Suche im Namensraum von Haustier nach der Funktion neu.” Das kennst du vielleicht schon von String::from(...) – auch das ist nichts anderes als eine solche assoziierte Funktion!
5. Ein komplettes Praxisbeispiel zum Mitmachen
Lass uns nun alles, was wir gelernt haben, in einem echten, lauffähigen Programm zusammenführen. Wir bauen ein kleines Tamagotchi-Spiel. Du kannst diesen Code kopieren, in deinem Cargo-Projekt in die src/main.rs einfügen und mit cargo run ausführen.
// Definition des Bauplans für das Tamagotchi
struct Tamagotchi {
name: String,
energie: i32,
laune: i32,
}
impl Tamagotchi {
// Unser Konstruktor: Erschafft ein neues, glückliches Tamagotchi
fn neu(name: String) -> Tamagotchi {
Tamagotchi {
name,
energie: 100, // Volle Energie am Anfang
laune: 100, // Beste Laune am Anfang
}
}
// Zeigt den aktuellen Zustand an (nur lesend: &self)
fn status_anzeigen(&self) {
println!("\n--- Status von {} ---", self.name);
println!("Energie: {}/100", self.energie);
println!("Laune: {}/100", self.laune);
println!("----------------------");
}
// Mit dem Tamagotchi spielen (verändernd: &mut self)
// Spielen verbessert die Laune, verbraucht aber Energie
fn spielen(&mut self) {
if self.energie < 20 {
println!("{} ist zu müde zum Spielen! Bitte erst schlafen legen.", self.name);
} else {
self.laune = (self.laune + 20).min(100); // Laune kann maximal 100 sein
self.energie -= 15;
println!("Du spielst mit {}. Das macht Spaß! (+20 Laune, -15 Energie)", self.name);
}
}
// Das Tamagotchi schlafen legen (verändernd: &mut self)
// Schlafen lädt die Energie wieder auf
fn schlafen(&mut self) {
self.energie = 100;
self.laune = (self.laune - 10).max(0); // Laune sinkt leicht durch Langeweile im Schlaf
println!("{} schläft tief und fest... Zzz... Energie ist wieder voll!", self.name);
}
}
fn main() {
// 1. Wir erschaffen unser virtuelles Haustier
let mut mein_pet = Tamagotchi::neu(String::from("Kiko"));
// 2. Wir schauen uns den Anfangsstatus an
mein_pet.status_anzeigen();
// 3. Wir spielen eine Runde
mein_pet.spielen();
mein_pet.status_anzeigen();
// 4. Wir spielen noch mehr, bis Kiko müde wird
mein_pet.spielen();
mein_pet.spielen();
mein_pet.spielen();
mein_pet.spielen();
mein_pet.spielen();
mein_pet.spielen();
// 5. Zeit fürs Bett
mein_pet.schlafen();
mein_pet.status_anzeigen();
}
6. Übungsaufgaben
Jetzt bist du an der Reihe! Versuche, das gelernte Wissen anzuwenden.
Aufgabe 1: Der Bibliotheks-Buch-Katalog
Erstelle eine klassische Struktur namens Buch mit folgenden Feldern:
titel(Typ:String)autor(Typ:String)seitenanzahl(Typ:u32)ausgeliehen(Typ:bool)
Schreibe im impl-Block:
- Eine assoziierte Funktion
neu(titel: String, autor: String, seitenanzahl: u32), die ein neues Buch erstellt. Das Feldausgeliehensoll standardmäßigfalsesein. - Eine Methode
ausleihen(&mut self), die den Status vonausgeliehenauftruesetzt und eine Nachricht auf dem Bildschirm ausgibt. Falls das Buch bereits ausgeliehen war, soll eine Warnung ausgegeben werden. - Eine Methode
zurueckgeben(&mut self), die den Status vonausgeliehenauffalsesetzt.
Aufgabe 2: Unit-Tests für deine Lösung
Füge am Ende deines Codes die folgenden Unit-Tests hinzu und prüfe mit cargo test, ob dein Code alle Anforderungen erfüllt!
#![allow(unused)]
fn main() {
// Wir deklarieren hier den Buch-Typ und die Tests in derselben Datei.
// In echten Projekten liegen Tests oft am Ende der Datei.
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_buch_erstellung() {
let buch = Buch::neu(String::from("Der Hobbit"), String::from("J.R.R. Tolkien"), 312);
assert_eq!(buch.titel, "Der Hobbit");
assert_eq!(buch.autor, "J.R.R. Tolkien");
assert_eq!(buch.seitenanzahl, 312);
assert_eq!(buch.ausgeliehen, false);
}
#[test]
fn test_buch_ausleihen_und_zurueckgeben() {
let mut buch = Buch::neu(String::from("Rust für Einsteiger"), String::from("Ein Tutor"), 250);
// Erstes Mal ausleihen
buch.ausleihen();
assert_eq!(buch.ausgeliehen, true);
// Zurückgeben
buch.zurueckgeben();
assert_eq!(buch.ausgeliehen, false);
}
}
}
7. Zusammenfassung
Du hast in diesem Kapitel einen riesigen Meilenstein erreicht! Du weißt nun:
- Dass ein Struct wie ein Lego-Bauplan ist, mit dem wir eigene Datentypen entwerfen können.
- Was der Unterschied zwischen Classic Structs (Steckbriefe mit Feldnamen), Tuple Structs (Koordinaten ohne Feldnamen) und Unit-like Structs (Datenlose Marker) ist.
- Wie wir im
impl-Block das Fähigkeiten-Buch einer Struktur schreiben und darin Methoden definieren. - Wann wir
self,&selfor&mut selfverwenden müssen, um auf die Daten des Structs zuzugreifen. - Wie wir mit assoziierten Funktionen (wie
::neu()) Konstruktoren für unsere Typen schreiben.
Im nächsten Kapitel werden wir sehen, wie wir Strukturen und das mächtige Konzept der Enums (Enumerationen) kombinieren können, um noch flexibleren Code zu schreiben. Viel Spaß beim Weiterlernen!
Fortgeschrittene Datenkapselung und API-Design mit Strukturen (Structs)
Dieses Kapitel richtet sich an Entwickler, die Rust auf professionellem Niveau einsetzen wollen. Bei größeren Codebasen reicht es nicht mehr aus, Daten einfach nur in Strukturen zusammenzufassen. Wir müssen uns fragen: Wie können wir unsere Programmier-Schnittstellen (APIs) so gestalten, dass sie robust gegen Fehlbenutzung sind, Invarianten zur Laufzeit garantieren und das mächtige Typ-System von Rust nutzen, um logische Fehler bereits zur Kompilierzeit auszuschließen?
In der Software-Architektur spricht man oft vom Prinzip “Make illegal states unrepresentable” (Mach ungültige Zustände undarstellbar). Strukturen sind in Rust das primäre Werkzeug, um dieses Prinzip in die Praxis umzusetzen.
Item 31: Kapsle Invarianten konsequent durch Sichtbarkeitsgrenzen (pub vs. private Felder)
Die Alltagsanalogie: Die Kaffeemaschine
Stellen Sie sich eine moderne Kaffeemaschine vor. Als Benutzer interagieren Sie ausschließlich mit den Knöpfen auf der Außenseite: “Espresso”, “Lungo” oder “Ausschalten”. Dies ist die öffentliche Schnittstelle (die Public API). Das Innenleben der Maschine – die Wassertemperatur, der Druck der Pumpe und die Position des Mahlwerks – ist für Sie unzugänglich hinter einem Gehäuse verborgen (Kapselung).
Würde der Hersteller das Gehäuse weglassen und Ihnen erlauben, während des Brühvorgangs direkt an den Drähten oder dem Druckventil zu drehen, könnten Sie die Maschine leicht beschädigen oder sich verletzen. Die Maschine hat eine Invariante: Der Druck während des Brühvorgangs muss exakt 9 Bar betragen, um optimalen Kaffee zu extrahieren und eine Explosion zu verhindern. Durch das Gehäuse (Sichtbarkeitsgrenze) wird diese Invariante sichergestellt.
Theorie und Konzepte
In vielen objektorientierten Sprachen wie Java oder C++ ist die Klasse die grundlegende Kapselungseinheit. In Rust hingegen ist das Modul (mod) die Kapselungseinheit. Das bedeutet:
- Eine Struktur, die in einem Modul definiert ist, hat vollen Zugriff auf alle privaten Felder aller anderen Strukturen im selben Modul.
- Code außerhalb des definierenden Moduls kann auf private Felder einer Struktur weder lesend noch schreibend zugreifen.
Wenn wir alle Felder einer Struktur mit pub versehen, geben wir jegliche Kontrolle über unsere Daten auf. Jeder externe Code kann die Felder nach Belieben verändern. Das mag für einfache Datencontainer (wie ein rein mathematischer Punkt { pub x: f64, pub y: f64 } ohne logische Invarianten) vollkommen in Ordnung sein. Sobald jedoch Logik im Spiel ist – beispielsweise, dass ein Text nicht leer sein darf, ein Wert in einem bestimmten Bereich liegen muss oder zwei Felder zueinander synchron sein müssen –, müssen die Felder privat bleiben.
Um ein solches Objekt sicher zu erzeugen, verwenden wir einen Konstruktor (konventionell eine assoziierte Funktion namens new, die manchmal ein Result\<T, E\> oder Option\<T\> zurückgibt) und kontrollieren den Zugriff über Getter- und Setter-Methoden im impl-Block.
Praxisbeispiel: Das Benutzerkonto
Wir wollen ein Benutzerkonto modellieren. Unsere Invarianten lauten:
- Der Benutzername darf nicht leer sein.
- Der Aktivierungsstatus und die Bonuspunkte müssen kontrolliert verändert werden. Bonuspunkte dürfen niemals negativ sein.
Schlechter Stil (Alle Felder öffentlich):
#![allow(unused)]
fn main() {
// In einem externen Modul oder einer anderen Datei
pub struct Benutzerkonto {
pub name: String,
pub punkte: i32,
pub aktiv: bool,
}
}
Bei dieser Struktur kann ein externer Aufrufer problemlos folgenden Code schreiben:
#![allow(unused)]
fn main() {
let mut konto = Benutzerkonto {
name: String::new(), // Invariante verletzt: leerer Name!
punkte: -999, // Invariante verletzt: negative Punkte!
aktiv: true,
};
konto.punkte = -5000; // Beliebige Manipulation zur Laufzeit möglich!
}
Es gibt keine Möglichkeit, diesen Missbrauch zur Laufzeit oder durch die Struktur selbst zu verhindern.
Guter Stil (Kapselung durch Sichtbarkeitsgrenzen):
Wir verschieben die Struktur in ein eigenes Modul (oder betrachten sie aus Sicht eines externen Moduls) und machen die Felder privat.
pub mod benutzer {
/// Ein Benutzerkonto mit gekapselten Invarianten.
#[derive(Debug)]
pub struct Benutzerkonto {
name: String, // Privat! Kein `pub` davor.
punkte: u32, // Privat! Verhindert negative Werte durch vorzeichenlosen Typ `u32`.
aktiv: bool, // Privat!
}
impl Benutzerkonto {
/// Der Konstruktor erzwingt die Invarianten bei der Erstellung.
/// Gibt `Result`, da die Erstellung bei ungültigen Eingaben scheitern kann.
pub fn new(name: &str) -> Result<Self, &'static str> {
if name.trim().is_empty() {
return Err("Der Benutzername darf nicht leer sein.");
}
Ok(Self {
name: name.to_string(),
punkte: 0, // Standardmäßig startet jeder Benutzer mit 0 Punkten
aktiv: true,
})
}
/// Ein kontrollierter "Getter" für den Namen.
/// Gibt eine Referenz zurück, um ein Kopieren des Strings zu vermeiden.
pub fn name(&self) -> &str {
&self.name
}
/// Ein Getter für die Punkte.
pub fn punkte(&self) -> u32 {
self.punkte
}
/// Eine kontrollierte Methode zur Erhöhung der Punkte (Invariante bleibt geschützt).
pub fn punkte_hinzufuegen(&mut self, wert: u32) {
self.punkte = self.punkte.saturating_add(wert);
}
/// Kontrolliertes Deaktivieren des Kontos.
pub fn deaktivieren(&mut self) {
self.aktiv = false;
}
/// Getter für den Aktivierungsstatus.
pub fn ist_aktiv(&self) -> bool {
self.aktiv
}
}
}
fn main() {
// Versuch, ein ungültiges Konto anzulegen:
let fehlgeschlagen = benutzer::Benutzerkonto::new(" ");
assert!(fehlgeschlagen.is_err());
println!("Erstellung blockiert: {:?}", fehlgeschlagen.err().unwrap());
// Erfolgreiche Erstellung:
let mut konto = benutzer::Benutzerkonto::new("Thorsten").unwrap();
println!("Konto erfolgreich erstellt für: {}", konto.name());
// Punkte hinzufügen über die kontrollierte Schnittstelle:
konto.punkte_hinzufuegen(150);
println!("Aktuelle Punkte: {}", konto.punkte());
// Folgender Code würde zu einem Compilerfehler führen, da die Felder privat sind:
// konto.name = String::new(); // Fehler: field `name` of struct `Benutzerkonto` is private
// konto.punkte = 100; // Fehler: field `punkte` is private
}
Zeilenweise Erklärung des Codes:
- Zeile 4-6: Die Felder
name,punkteundaktivhaben kein vorangestelltespub. Sie sind somit außerhalb des Modulsbenutzerunsichtbar und unveränderbar. - Zeile 10:
pub fn new(...) -> Result\<Self, &'static str\>: Dies ist die einzige Möglichkeit, eine Instanz vonBenutzerkontoaußerhalb des Moduls zu erstellen. Sie gibt einResultzurück, um dem Aufrufer mitzuteilen, ob die Erstellung erfolgreich war. - Zeile 11-13: Hier wird die Invariante geprüft. Wenn der Name leer ist, bricht die Funktion sofort ab und gibt einen Fehler zurück. Es ist unmöglich, eine Instanz mit leerem Namen zu erhalten.
- Zeile 24:
pub fn name(&self) -> &str: Ein typischer Rust-Getter. Statt denStringper Move zu übergeben (was das Struct zerstört würde), leihen wir uns den Inhalt als temporäre Referenz (&str) aus. - Zeile 34:
self.punkte.saturating_add(wert): Verhindert einen arithmetischen Überlauf (Overflow) zur Laufzeit. Sollte die maximale Zahl überschritten werden, verbleibt der Wert beim Maximum vonu32.
Item 32: Nutze das Newtype-Pattern zur Absicherung von Typsicherheit auf API-Ebene
Die Alltagsanalogie: Der Tankstellen-Unfall
Stellen Sie sich vor, Sie fahren an eine Tankstelle. Sie haben einen Benzinkanister und einen Dieselkanister. Beide Kanister bestehen aus dem gleichen Material (Kunststoff) und fassen beide exakt 10 Liter (primitiver Datentyp f64). Wenn Sie die Kanister nicht beschriften, ist es extrem leicht, sie zu verwechseln und Diesel in ein Benzinauto zu füllen – mit katastrophalen Folgen für den Motor.
Das Newtype-Pattern ist der physikalische Schutz: Es ist so, als hätte der Benzin-Einfüllstutzen eine völlig andere Form als der Diesel-Einfüllstutzen. Selbst wenn Sie blind versuchen, den falschen Treibstoff einzufüllen, scheitern Sie mechanisch. Der Compiler ist in diesem Fall der Einfüllstutzen, der den Fehler verhindert.
Theorie und Konzepte
Viele Programmierer neigen zur sogenannten “Primitive Obsession” (Primitiven-Besessenheit). Sie verwenden grundlegende Datentypen wie i32, f64 oder String für fachlich völlig unterschiedliche Konzepte.
Beispiel:
#![allow(unused)]
fn main() {
fn berechne_geschwindigkeit(strecke: f64, zeit: f64) -> f64 {
strecke / zeit
}
}
Hier kann man beim Aufruf kinderleicht zeit und strecke vertauschen: berechne_geschwindigkeit(10.0, 120.0) statt berechne_geschwindigkeit(120.0, 10.0). Da beide Parameter f64 sind, merkt der Compiler nichts.
Das Newtype-Pattern löst dies, indem es den primitiven Typ in eine einwertige Tuple-Struktur (ein sogenanntes Tuple Struct) einpackt:
#![allow(unused)]
fn main() {
struct Meter(f64);
struct Sekunden(f64);
}
Zur Laufzeit gibt es hierbei keinen Performance-Overhead. Der Rust-Compiler optimiert die umschließende Struktur komplett weg, sodass im Maschinencode nur noch die reine Fließkommazahl steht (Zero-Cost Abstraction). Doch zur Kompilierzeit sind Meter und Sekunden zwei völlig inkompatible Typen.
Praxisbeispiel: Celsius vs. Fahrenheit
Wir wollen Temperatur-Berechnungen durchführen und verhindern, dass Celsius- und Fahrenheit-Werte versehentlich vertauscht oder miteinander addiert werden.
use std::ops::Add;
/// Eine Temperatur in Grad Celsius.
#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
pub struct Celsius(pub f64); // Das innere Feld ist öffentlich lesbar, aber als Typ isoliert.
/// Eine Temperatur in Grad Fahrenheit.
#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
pub struct Fahrenheit(pub f64);
impl Celsius {
/// Konvertiert Celsius in Fahrenheit.
pub fn to_fahrenheit(self) -> Fahrenheit {
Fahrenheit(self.0 * 1.8 + 32.0)
}
}
impl Fahrenheit {
/// Konvertiert Fahrenheit in Celsius.
pub fn to_celsius(self) -> Celsius {
Celsius((self.0 - 32.0) / 1.8)
}
}
// Wir können Traits wie `Add` implementieren, um das Rechnen komfortabel zu machen.
impl Add for Celsius {
type Output = Self;
fn add(self, other: Self) -> Self::Output {
Celsius(self.0 + other.0)
}
}
/// Diese Funktion akzeptiert ausschließlich Celsius.
/// Es ist unmöglich, ihr versehentlich Fahrenheit zu übergeben!
pub fn pruefe_hitzewarnung(temp: Celsius) {
if temp.0 >= 38.0 {
println!("WARNUNG: Extreme Hitze! ({:.1}°C)", temp.0);
} else {
println!("Temperatur im normalen Bereich ({:.1}°C)", temp.0);
}
}
fn main() {
let t_celsius = Celsius(36.5);
let t_fahrenheit = Fahrenheit(100.0);
// Wir können Celsius-Werte miteinander addieren:
let waermer = t_celsius + Celsius(2.0);
pruefe_hitzewarnung(waermer);
// Folgender Code führt zu einem klaren Compilerfehler:
// pruefe_hitzewarnung(t_fahrenheit);
// Fehler: expected `Celsius`, found `Fahrenheit`
// Richtige Vorgehensweise: Explizite Konvertierung aufrufen
let konvertiert = t_fahrenheit.to_celsius();
pruefe_hitzewarnung(konvertiert);
}
Zeilenweise Erklärung des Codes:
- Zeile 5 & 9:
pub struct Celsius(pub f64);definiert das Tuple-Struct. Daspub f64im Inneren erlaubt es dem Aufrufer, über.0direkt auf den Wert zuzugreifen. Möchte man das verhindern, lässt man das innerepubweg und stellt stattdessen eine Methode.value()bereit. - Zeile 13-15:
to_fahrenheitkonsumiertself(was billig ist, daCelsiusdasCopy-Trait implementiert) und gibt die umgerechnete StrukturFahrenheitzurück. - Zeile 25:
impl Add for Celsiuserlaubt die Verwendung des+-Operators für Celsius-Strukturen untereinander. Rust verbietet es jedoch standardmäßig,Celsius + Fahrenheitzu rechnen, da für diese Kombination keinAdd-Trait implementiert ist.
Item 33: Beherrsche das Typ-Zustands-Pattern (Type State Pattern) für compile-time verifizierte Zustandsmaschinen
Die Alltagsanalogie: Der Briefversand
Denken Sie an den Lebenszyklus eines physischen Briefes:
- Entwurf: Sie schreiben den Text auf ein Blatt Papier. In diesem Zustand können Sie den Text noch beliebig ändern und korrigieren.
- Versiegelt: Sie legen das Blatt in einen Umschlag und kleben ihn zu. Jetzt können Sie den Inhalt nicht mehr ändern, ohne den Umschlag zu zerstören. Der Brief ist bereit für den Versand.
- Gesendet: Der Brief befindet sich im Postkasten oder im Transit. Sie können ihn nicht mehr zurückholen, nicht mehr ändern und auch nicht noch einmal versiegeln.
Wenn ein Softwaresystem diesen Ablauf abbildet, prüfen klassische Programme zur Laufzeit: if status == Status::Gesendet { panic!("Fehler: Gesendete Briefe dürfen nicht geändert werden!"); }.
Das Typ-Zustands-Pattern verlegt diese Prüfung in die Kompilierzeit. Es sorgt dafür, dass die Methode aendern() auf einem gesendeten Brief gar nicht erst existiert.
Theorie und Konzepte
Zustandsmaschinen (State Machines) sind allgegenwärtig. Traditionell speichert man den Zustand in einem Enum-Feld innerhalb einer Struktur:
#![allow(unused)]
fn main() {
enum Status { Entwurf, Versiegelt, Gesendet }
struct Brief { inhalt: String, status: Status }
}
Das Problem dabei: Jede Methode auf Brief muss zur Laufzeit prüfen, in welchem Zustand sich das Objekt befindet. Vergisst man eine solche Prüfung, entstehen logische Programmierfehler.
Beim Type State Pattern (Typ-Zustands-Pattern) repräsentieren wir jeden Zustand durch einen eigenen Typ (meist ein leeres Unit-Struct). Die eigentliche Struktur wird über Generics mit diesem Zustand verknüpft.
Um zu verhindern, dass Rust den Zustand als Feld im Speicher anlegt (was ungenutzten Platz kosten würde), nutzen wir std::marker::PhantomData\<T\>. Dies ist ein spezieller Typ mit einer Größe von 0 Bytes, der dem Compiler signalisiert: “Diese Struktur verhält sich so, als ob sie einen Wert vom Typ T besitzt, obwohl zur Laufzeit nichts davon existiert.”
Durch die Definition von Methoden in spezifischen impl-Blöcken wie impl Brief\<Entwurf\> legen wir fest, dass bestimmte Aktionen nur in exakt diesem Zustand erlaubt sind. Ein Zustandsübergang wird vollzogen, indem die Methode das alte Objekt konsumiert (self) und ein neues Objekt mit dem neuen Zustandstyp zurückgibt.
Praxisbeispiel: Die E-Mail-Pipeline
use std::marker::PhantomData;
// 1. Wir definieren die Zustände als leere Strukturen.
// Sie dienen ausschließlich als Markierungen für den Compiler.
#[derive(Debug)]
pub struct Entwurf;
#[derive(Debug)]
pub struct Bereit;
#[derive(Debug)]
pub struct Gesendet;
// 2. Die Hauptstruktur ist generisch über den Zustand `State`.
#[derive(Debug)]
pub struct Email<State> {
empfaenger: String,
inhalt: String,
// PhantomData teilt dem Compiler mit, dass `State` logisch genutzt wird,
// belegt aber zur Laufzeit 0 Byte Speicherplatz.
zustand: PhantomData<State>,
}
// 3. Methoden, die in JEDEM Zustand verfügbar sein sollen.
impl<State> Email<State> {
pub fn empfaenger(&self) -> &str {
&self.empfaenger
}
}
// 4. Methoden, die NUR im Zustand `Entwurf` existieren.
impl Email<Entwurf> {
/// Konstruktor startet immer als Entwurf.
pub fn neu(empfaenger: &str) -> Self {
Self {
empfaenger: empfaenger.to_string(),
inhalt: String::new(),
zustand: PhantomData,
}
}
/// Im Entwurf darf der Inhalt editiert werden.
pub fn inhalt_schreiben(&mut self, text: &str) {
self.inhalt.push_str(text);
}
/// Der Übergang von `Entwurf` zu `Bereit`.
/// Wir konsumieren `self` (Move-Semantik) und geben einen neuen Typ zurück.
pub fn vorbereiten(self) -> Email<Bereit> {
Email {
empfaenger: self.empfaenger,
inhalt: self.inhalt,
zustand: PhantomData, // Zustand wechselt im Typ-System!
}
}
}
// 5. Methoden, die NUR im Zustand `Bereit` existieren.
impl Email<Bereit> {
/// Der Übergang von `Bereit` zu `Gesendet`.
/// Auch hier wird das alte Objekt durch `self` unbrauchbar gemacht.
pub fn senden(self) -> Email<Gesendet> {
println!("Sende E-Mail an {}...", self.empfaenger);
println!("Inhalt: \"{}\"", self.inhalt);
Email {
empfaenger: self.empfaenger,
inhalt: self.inhalt,
zustand: PhantomData,
}
}
}
// 6. Im Zustand `Gesendet` gibt es keine verändernden Methoden mehr.
// Die E-Mail is "eingefroren".
fn main() {
// Phase 1: Entwurf erstellen und schreiben
let mut email = Email::neu("thorsten@example.com");
email.inhalt_schreiben("Hallo Thorsten, willkommen in Rust!");
// Folgender Code würde nicht kompilieren:
// email.senden(); // Fehler: no method named `senden` found for struct `Email<Entwurf>`
// Phase 2: E-Mail für den Versand vorbereiten
// Die alte Variable `email` ist danach nicht mehr nutzbar.
let email_bereit = email.vorbereiten();
// Folgender Code würde nicht kompilieren:
// email_bereit.inhalt_schreiben("Noch ein Text...");
// Fehler: no method named `inhalt_schreiben` found for struct `Email<Bereit>`
// Phase 3: E-Mail senden
let _email_gesendet = email_bereit.senden();
// Die E-Mail ist nun im Endzustand. Es können keine ungültigen Aktionen mehr ausgeführt werden.
}
Zeilenweise Erklärung des Codes:
- Zeile 17:
pub struct Email\<State\>deklariert die Struktur mit dem TypparameterState. Dieser Parameter bestimmt, in welchem Zustand sich die E-Mail befindet. - Zeile 21:
zustand: PhantomData\<State\>bindet den Typparameter an die Struktur. Ohne dieses Feld würde der Compiler sich beschweren: parameterStateis never used. - Zeile 25-29:
impl\<State\> Email\<State\>zeigt, wie man Methoden schreibt, die für alle Zustände gleichermaßen gelten. Hier kann man unabhängig vom aktuellen Zustand den Empfänger abfragen. - Zeile 32:
impl Email\<Entwurf\>schränkt alle folgenden Methoden auf E-Mails im ZustandEntwurfein. - Zeile 47:
pub fn vorbereiten(self) -> Email\<Bereit\>nimmtselfper Ownership (Wertübergabe) entgegen. Dadurch wird die ursprünglicheEmail\<Entwurf\>-Instanz im Aufrufer zerstört bzw. ungültig gemacht. Zurückgegeben wird eine frisch konstruierteEmail\<Bereit\>. Das verhindert, dass man mit der alten Entwurfs-Instanz weiterarbeitet.
Die Struktur-Update-Syntax (..) und ihre Move-Semantik
Rust bietet eine sehr elegante Möglichkeit, eine neue Instanz einer Struktur zu erstellen, indem man die Werte einer bereits existierenden Instanz kopiert oder verschiebt. Dies geschieht mithilfe der Struktur-Update-Syntax (..).
#![allow(unused)]
fn main() {
struct Benutzer {
id: u64,
name: String,
aktiv: bool,
}
}
Wenn wir nun einen neuen Benutzer erstellen wollen, der dieselben Daten wie ein bestehender Benutzer hat, aber mit einer neuen ID, schreiben wir:
#![allow(unused)]
fn main() {
let benutzer1 = Benutzer {
id: 1,
name: String::from("Thorsten"),
aktiv: true,
};
let benutzer2 = Benutzer {
id: 2,
..benutzer1 // Alle anderen Felder aus benutzer1 übernehmen
};
}
Die Move-Semantik bei nicht-Copy-Feldern
Was auf den ersten Blick wie ein bequemes Kopieren aussieht, birgt ein wichtiges Detail bezüglich Rusts Speichersicherheits-Modell: Die Move-Semantik.
Wenn der Compiler die Zeile ..benutzer1 verarbeitet, verhält er sich so, als ob die Felder einzeln zugewiesen würden:
#![allow(unused)]
fn main() {
let benutzer2 = Benutzer {
id: 2,
name: benutzer1.name, // String wird VERSCHOBEN (Move)!
aktiv: benutzer1.aktiv, // bool wird KOPIERT (Copy)!
};
}
Da das Feld name vom Typ String ist und String nicht das Copy-Trait implementiert (weil es Heap-Speicher verwaltet), wird der Besitz (Ownership) des Strings von benutzer1 auf benutzer2 übertragen.
Das hat fundamentale Auswirkungen auf die Gültigkeit von benutzer1:
- Teilweise Verschiebung (Partial Move): Da
benutzer1.namewegbewegt wurde, ist die Strukturbenutzer1als Ganzes ab diesem Zeitpunkt ungültig und zerstört. - Sie können
benutzer1nicht mehr als Funktionsargument übergeben oder ausgeben. - Der Zugriff auf unbeschädigte Felder wie
benutzer1.id(das einu64is und somit kopiert wurde) wäre theoretisch noch erlaubt, ist aber in der Praxis unidiomatisch und wird vom Compiler streng überwacht.
Visualisierung des Speicherzustands nach dem Update:
Vor dem Update:
benutzer1 [ id: 1, name: "Thorsten" (Zeiger auf Heap), aktiv: true ]
│
└───► [T][h][o][r][s][t][e][n] (Heap-Speicher)
Nach dem Update:
benutzer1 [ id: 1, name: UNGÜLTIG (Verschoben!), aktiv: true ]
benutzer2 [ id: 2, name: ──────────────────────────────────────────┐
▼
[T][h][o][r][s][t][e][n]
Der Compilerfehler im Detail
Lass uns ansehen, was passiert, wenn wir versuchen, benutzer1 nach dem Update weiterzuverwenden:
struct Benutzer {
id: u64,
name: String,
aktiv: bool,
}
fn main() {
let benutzer1 = Benutzer {
id: 1,
name: String::from("Thorsten"),
aktiv: true,
};
let benutzer2 = Benutzer {
id: 2,
..benutzer1
};
// Dieser Aufruf führt zu einem Compilerfehler!
println!("Benutzer 1 Name: {}", benutzer1.name);
}
Wenn Sie versuchen, diesen Code zu kompilieren, bricht der Compiler mit folgender Meldung ab:
error[E0382]: borrow of partially moved value: `benutzer1`
--> src/main.rs:20:38
|
15 | ..benutzer1
| --------- value moved here
...
20 | println!("Benutzer 1 Name: {}", benutzer1.name);
| ^^^^^^^^^^^^^^ value borrowed here after move
|
= note: move occurs because `benutzer1.name` has type `String`, which does not implement the `Copy` trait
Wie man den Fehler behebt
Sollte die ursprüngliche Instanz nach dem Update weiterhin benötigt werden, haben Sie zwei Möglichkeiten:
-
Explizites Klonen der nicht-
Copy-Felder: Sie überlassen das Feld nicht der automatischen Update-Syntax, sondern klonen es manuell. Dadurch bleibt der Besitz bei der alten Struktur erhalten.#![allow(unused)] fn main() { let benutzer2 = Benutzer { id: 2, name: benutzer1.name.clone(), // Klon erzeugen, Original behalten ..benutzer1 // Kopiert nun nur noch das `aktiv`-Feld (das Copy ist) }; // Jetzt sind sowohl benutzer1 als auch benutzer2 voll einsatzbereit! println!("B1: {}, B2: {}", benutzer1.name, benutzer2.name); } -
Implementierung des
Clone-Traits für die gesamte Struktur: Wenn Sie die gesamte Struktur klonen können, können Sie zuerst ein Duplikat erstellen und dieses verändern.#![allow(unused)] fn main() { #[derive(Clone)] struct Benutzer { id: u64, name: String, aktiv: bool, } let benutzer2 = Benutzer { id: 2, ..benutzer1.clone() // Klon der gesamten Struktur als Basis nutzen }; }
Zusammenfassung und Best Practices für Strukturen
- Geheimnisprinzip wahren: Deklarieren Sie Felder standardmäßig immer als privat. Machen Sie Felder nur dann öffentlich (
pub), wenn es sich um reine, invariantenfreie Datenbehälter handelt. - Typen statt Fehlerprüfungen: Verwenden Sie das Newtype-Pattern, um Verwechslungen von physikalischen Einheiten, Datenbank-IDs oder Währungen bereits beim Kompilieren unmöglich zu machen.
- Zustände über Typen sichern: Nutzen Sie das Typ-Zustands-Pattern mit
PhantomData\<T\>, um sicherzustellen, dass Methoden nur aufgerufen werden können, wenn sich das Objekt im logisch korrekten Zustand befindet. - Vorsicht bei
..: Denken Sie daran, dass die Struct-Update-Syntax Ownership transferiert, wenn Felder nichtCopyimplementieren. Nutzen Sie.clone(), falls die Quellstruktur intakt bleiben muss.
Kapitel 10 - Hardware-Sicht: Strukturen unter der Lupe von CPU und RAM
Willkommen im Maschinenraum! Nachdem wir uns im Hauptkapitel damit beschäftigt haben, wie wir Daten logisch in Strukturen (Structs) kapseln und mit Methoden versehen, werfen wir nun den Blaumann über und steigen hinab in die physikalische Reality.
Für den Compiler ist eine Struktur nämlich kein schickes Konzept zur Kapselung, sondern schlicht ein Rezept dafür, wie eine Reihe von Variablen hintereinander im Arbeitsspeicher (RAM) angeordnet werden soll. Wie genau dieses Rezept in Bytes übersetzt wird, hat drastische Auswirkungen auf den Speicherbedarf und die Ausführungsgeschwindigkeit deines Programms.
In diesem Abschnitt klären wir die Fragen, die Systemprogrammierer nachts wachhalten:
- Wie liegen die Felder einer Struktur tatsächlich im RAM?
- Warum verschwendet der Compiler absichtlich Speicherplatz mit Füllbytes (Padding)?
- Wie spart uns Rust durch Field Reordering automatisch bares Geld (in Form von RAM)?
- Wie zwingen wir Rust mit
#[repr(C)]oder#[repr(packed)]zu einem bestimmten Speicherlayout? - Und warum verbrauchen manche Strukturen auf Prozessorebene exakt null Bytes?
1. Das Speicherlayout: Wie liegen Felder im RAM?
Wenn wir eine klassische Struktur definieren, könnte man naiv annehmen, dass die Felder einfach wie Perlen auf einer Schnur direkt hintereinander im Speicher abgelegt werden. Das stimmt – allerdings mit einer Einschränkung, die durch die physikalische Architektur moderner Prozessoren bedingt ist.
Die Analogie: Das Logistikzentrum und die Ladezonen
Stell dir ein riesiges Logistikzentrum vor. Die Ladebuchten für LKWs sind genau nummeriert, und der Gabelstapler kann Waren am effizientesten bewegen, wenn sie auf standardisierten Paletten liegen, die genau an den Rastergrenzen (z. B. alle 4 oder 8 Meter) ausgerichtet sind.
Wenn du nun eine Kiste hast, die 8 Meter lang ist, kann der Gabelstapler sie mit einem einzigen Hub aufladen, wenn sie exakt an einer 8-Meter-Markierung (z. B. bei Meter 0, 8, 16, 24) beginnt. Liegt die Kiste aber schief – sagen wir, sie beginnt bei Meter 3 und geht bis Meter 11 –, ragt sie über die Rastergrenzen hinaus. Der Gabelstaplerfahrer muss nun zweimal ansetzen: Einmal, um den Teil im ersten Rasterabschnitt anzuheben, und ein zweites Mal für den Rest im zweiten Abschnitt. Das kostet Zeit und nervt den Fahrer gewaltig.
Genau so arbeitet eine CPU! Sie liest Daten nicht byteweise aus dem RAM, sondern in sogenannten Wortbreiten (Word Size) – bei modernen 64-Bit-CPUs sind das meist Blöcke von 8 Bytes (64 Bit).
- Ein ausgerichteter Speicherzugriff (aligned access) bedeutet, dass ein Wert von der Größe $N$ Bytes an einer Speicheradresse liegt, die ohne Rest durch $N$ teilbar ist. Ein 8-Byte-Pointer muss also an einer Adresse liegen, die durch 8 teilbar ist (z. B.
0x1000,0x1008). - Ein nicht ausgerichteter Speicherzugriff (unaligned access) zwingt die CPU, zwei Speicherzyklen durchzuführen, um die Daten zusammenzusuchen. Auf manchen eingebetteten Systemen (z. B. älteren ARM-Prozessoren) führt ein unaligned access sogar zu einem sofortigen Programmabsturz (Bus Error).
Data Alignment und Padding (Füllbytes)
Um der CPU diese Mehrarbeit zu ersparen, sorgt der Compiler beim Übersetzen des Codes für das sogenannte Data Alignment (Daten-Ausrichtung). Wenn ein Feld nicht an einer für seinen Typ passenden Adresse starten kann, fügt der Compiler ungenutzte Füllbytes – das sogenannte Padding – ein.
Schauen wir uns das an einem konkreten Beispiel an. Angenommen, wir haben folgende Struktur:
#![allow(unused)]
fn main() {
struct SensorDaten {
aktiv: bool, // Typische Größe: 1 Byte
temperatur: f64, // Typische Größe: 8 Bytes
id: u16, // Typische Größe: 2 Bytes
}
}
Würde der Compiler die Felder starr in dieser Reihenfolge ablegen, sähe das Speicherlayout (ohne Optimierung) so aus:
aktivbelegt das erste Byte (Offset 0).- Das nächste Feld
temperaturist einf64(8 Bytes). Es erfordert ein Alignment von 8 Bytes. Die nächste freie Adresse ist jedoch Offset 1. Da 1 nicht durch 8 teilbar ist, muss der Compiler 7 Bytes Padding einfügen!temperaturbeginnt erst bei Offset 8 und geht bis Offset 15. - Das Feld
idist einu16(2 Bytes). Es erfordert ein Alignment von 2 Bytes. Offset 16 ist durch 2 teilbar, also kanniddirekt bei Offset 16 abgelegt werden (bis Offset 17). - Nun ist die Struktur eigentlich zu Ende. Allerdings muss die Gesamtgröße einer Struktur immer ein Vielfaches ihres größten Alignments sein (damit Arrays dieser Struktur ebenfalls korrekt ausgerichtet sind). Das größte Alignment ist das von
f64(8 Bytes). Die aktuelle Größe ist 18 Bytes. Das nächste Vielfache von 8 ist 24. Der Compiler muss also am Ende noch einmal 6 Bytes Padding anhängen.
Ohne Optimierung würde diese Struktur also 24 Bytes im RAM belegen, obwohl die eigentlichen Nutzdaten nur 11 Bytes ($1 + 8 + 2$) groß sind! Über 50 % des Speichers wären nutzlose Luftlöcher.
2. Field Reordering: Der schlaue Packmeister Rust
Im Gegensatz zu Programmiersprachen wie C oder C++ macht der Rust-Compiler standardmäßig keine Versprechen darüber, in welcher Reihenfolge die Felder einer Struktur im Arbeitsspeicher landen. Rust behält sich das Recht vor, die Felder im Speicher komplett umzusortieren (Field Reordering), um Padding-Bytes zu minimieren.
Bleiben wir bei unserer Analogie des Umzugskartons: Ein sturer Packmeister (der C-Compiler) packt die Gegenstände starr in der Reihenfolge ein, wie sie auf dem Zettel stehen. Ein cleverer Packmeister (der Rust-Compiler) sortiert die Gegenstände um, damit sie kompakter in den Karton passen.
Wenn wir unsere Struktur SensorDaten in Rust kompilieren, analysiert der Compiler die Typen und ordnet sie im Speicher so an, dass das Alignment gewahrt bleibt, aber möglichst wenig Füllbytes entstehen. Er sortiert die Felder nach abfallendem Alignment:
- Zuerst kommt das größte Feld:
temperatur(f64, 8 Bytes) bei Offset 0 bis 7. - Danach folgt
id(u16, 2 Bytes) bei Offset 8 und 9. - Zuletzt kommt
aktiv(bool, 1 Byte) bei Offset 10. - Nun sind wir bei 11 Bytes. Das maximale Alignment der Struktur ist weiterhin 8 Bytes (wegen
f64). Die nächste durch 8 teilbare Zahl ist 16. Der Compiler fügt am Ende also 5 Bytes Padding hinzu.
Durch dieses einfache Umsortieren schrumpft der Speicherbedarf von 24 Bytes auf 16 Bytes! Rust spart uns hier völlig automatisch 33 % des RAM-Bedarfs ein.
Lass uns das in einem echten, ausführlich kommentierten und kompilierbaren Rust-Programm überprüfen. Wir nutzen dafür die Funktionen std::mem::size_of und std::mem::align_of aus der Standardbibliothek.
use std::mem::{align_of, size_of};
// Wir definieren unsere Struktur.
// Der Rust-Compiler wird die Felder im Speicher automatisch umsortieren.
struct SensorDaten {
aktiv: bool, // 1 Byte, Alignment 1
temperatur: f64, // 8 Bytes, Alignment 8
id: u16, // 2 Bytes, Alignment 2
}
fn main() {
// Da wir spitze Klammern in Fließtext vermeiden wollen, nutzen wir den
// Turbofisch-Operator ::<T> beim Aufruf der mem-Funktionen.
let groese = size_of::<SensorDaten>();
let ausrichtung = align_of::<SensorDaten>();
println!("--- SensorDaten Layout-Analyse ---");
println!("Gesamtgröße im RAM: {} Bytes", groese);
println!("Erforderliches Alignment: {} Bytes", ausrichtung);
// Wir können auch die Größe der einzelnen Felder ausgeben
println!("Nutzdaten-Größe: {} Bytes (1 bool + 8 f64 + 2 u16)",
size_of::<bool>() + size_of::<f64>() + size_of::<u16>());
println!("Verschwendeter Platz: {} Bytes (Padding)",
groese - (size_of::<bool>() + size_of::<f64>() + size_of::<u16>()));
}
Wenn du dieses Programm ausführst, siehst du auf der Konsole:
--- SensorDaten Layout-Analyse ---
Gesamtgröße im RAM: 16 Bytes
Erforderliches Alignment: 8 Bytes
Nutzdaten-Größe: 11 Bytes (1 bool + 8 f64 + 2 u16)
Verschwendeter Platz: 5 Bytes (Padding)
3. Die Attribute #[repr(C)] und #[repr(packed)]
Obwohl die automatische Optimierung von Rust fantastisch ist, gibt es Situationen, in denen wir die volle Kontrolle über das Speicherlayout benötigen. Das ist vor allem dann der Fall, wenn:
- Wir über das Foreign Function Interface (FFI) mit C-Bibliotheken kommunizieren wollen. C-Bibliotheken erwarten, dass die Felder exakt in der Reihenfolge liegen, in der sie deklariert wurden.
- Wir Daten direkt über das Netzwerk senden oder aus einer Datei lesen wollen (Binärprotokolle), bei denen jedes Byte eine vordefinierte Bedeutung hat.
Das Attribut #[repr(C)] (C-Kompatibilität)
Mit dem Attribut #[repr(C)] zwingst du den Rust-Compiler, das standardisierte Layout der Sprache C zu verwenden. Das bedeutet:
- Die Felder werden exakt in der Reihenfolge deklariert, in der sie im Quellcode stehen.
- Es findet kein Field Reordering statt.
- Padding-Bytes werden eingefügt, um die Alignment-Regeln der Zielarchitektur einzuhalten.
Lass uns eine Struktur mit #[repr(C)] ausstatten und den Unterschied sehen:
use std::mem::size_of;
#[repr(C)]
struct SensorDatenC {
aktiv: bool, // 1 Byte
// Hier entstehen 7 Bytes Padding!
temperatur: f64, // 8 Bytes
id: u16, // 2 Bytes
// Hier entstehen 6 Bytes Padding am Ende!
}
fn main() {
println!("Größe der repr(C)-Struktur: {} Bytes", size_of::<SensorDatenC>());
}
Ausgabe dieses Programms:
Größe der repr(C)-Struktur: 24 Bytes
Wie vorhergesagt, wächst die Struktur auf 24 Bytes an, da der Compiler die Felder nicht mehr umsortieren darf.
Das Attribut #[repr(packed)] (Kompressions-Modus)
Was aber, wenn wir extremen Speichermangel haben (z. B. auf einem winzigen Mikrocontroller) und uns das Alignment der CPU völlig egal ist? Wir wollen einfach absolut kein Padding haben.
Dafür gibt es das Attribut #[repr(packed)]. Es weist den Compiler an:
- Ignoriere alle Alignment-Regeln der Felder.
- Füge absolut keine Padding-Bytes ein.
- Die Ausrichtung (Alignment) der gesamten Struktur sinkt auf 1 Byte.
use std::mem::{align_of, size_of};
#[repr(packed)]
struct SensorDatenPacked {
aktiv: bool, // 1 Byte
temperatur: f64, // 8 Bytes
id: u16, // 2 Bytes
}
fn main() {
println!("--- SensorDaten Packed Analyse ---");
println!("Gesamtgröße: {} Bytes", size_of::<SensorDatenPacked>());
println!("Alignment: {} Byte", align_of::<SensorDatenPacked>());
}
Ausgabe:
--- SensorDaten Packed Analyse ---
Gesamtgröße: 11 Bytes
Alignment: 1 Byte
Die Struktur belegt nun exakt 11 Bytes – kein einziges Byte geht verloren.
Caution
Die Gefahren von
#[repr(packed)]Das Eliminieren von Padding hat einen hohen Preis. Da die Felder nun an unaligned Speicheradressen liegen können, muss die CPU bei jedem Zugriff tief in die Trickkiste greifen, was die Performance deines Programms spürbar verschlechtert.
Noch gefährlicher ist das Erzeugen von Referenzen auf unaligned Felder. Rust verbietet es standardmäßig, eine normale Referenz (z. B.
&sensor.temperatur) auf ein unaligned Feld einer gepackten Struktur zu erstellen, da Referenzen in Rust immer korrekt ausgerichtet sein müssen. Versuchst du es dennoch, wirft dir der Compiler einen Fehler an den Kopf oder warnt dich eindringlich vor undefiniertem Verhalten (Undefined Behavior).
4. Der Speicherbedarf der drei Struct-Arten
Rust bietet uns drei verschiedene Arten von Strukturen an. Auf logischer Ebene erfüllen sie unterschiedliche Zwecke – aber wie sieht es auf der Ebene der Hardware aus?
1. Classic Structs und Tuple Structs
Für die Hardware macht es absolut keinen Unterschied, ob du eine klassische Struktur mit benannten Feldern (struct Point { x: i32, y: i32 }) oder eine Tupel-Struktur (struct Point(i32, i32)) verwendest. Beide werden identisch im RAM abgelegt. Die Feldnamen sind reine syntaktische Hilfen für uns Programmierer und werden vom Compiler komplett wegradiert. Der Speicherbedarf ist in beiden Fällen die Summe der Feldgrößen plus das nötige Padding.
2. Unit-like Structs: Die 0-Byte-Magie (Zero Sized Types - ZST)
Jetzt wird es richtig faszinierend. Was passiert, wenn wir eine Struktur ohne Felder definieren?
#![allow(unused)]
fn main() {
struct EinheitsTyp; // Ein Unit-like Struct
}
Logisch betrachtet besitzt diese Struktur keine Daten. Und auf Hardware-Ebene?
- Ihr Speicherbedarf beträgt exakt 0 Bytes!
- Sie wird in der Fachsprache als Zero Sized Type (ZST) bezeichnet.
Vielleicht fragst du dich jetzt: „Wozu soll eine Struktur gut sein, die überhaupt keine Daten speichern kann? Ist das nicht nutzlos?“ Keineswegs! Rust nutzt ZSTs für extrem elegante Compilezeit-Garantien:
- Typ-Marker: Du kannst sie verwenden, um Zustände im Typ-System abzubilden (z. B. im State Pattern). Der Compiler prüft zur Compilezeit, ob deine Zustandsübergänge korrekt sind, erzeugt im finalen Maschinencode aber keinen einzigen Byte-Zugriff.
- Träger von Funktionalität: Du kannst Methoden auf einem Unit-like Struct implementieren. Das ist nützlich für mathematische Hilfsfunktionen oder zustandslose Schnittstellen.
- Optimierte Kollektionen: Ein
HashSet<T>in Rust ist unter der Haube einfach eineHashMap<T, ()>. Da der Unit-Typ()ebenfalls ein Zero Sized Type mit 0 Bytes Größe ist, belegt das Set keinen zusätzlichen Speicherplatz für die Werte – nur für die Schlüssel. Das ist maximale Effizienz ohne Overhead!
Der Compiler optimiert Instanzen von ZSTs komplett weg. Wenn du eine Variable von einem Unit-like Struct erstellst, wird dafür auf Prozessorebene kein Speicher reserviert, kein Stack-Pointer verschoben und kein Register belegt.
5. Vollständiges Hardware-Demoprogramm
Zum Abschluss dieses Ausflugs in den Maschinenraum lassen wir ein umfassendes Demo-Programm laufen, das all diese Aspekte auf deinem Bildschirm sichtbar macht. Du kannst diesen Code direkt in eine Datei kopieren und mit cargo run ausführen.
use std::mem::{align_of, size_of};
// 1. Ein klassisches, vom Rust-Compiler optimiertes Struct
struct Optimiert {
a: u8,
b: u64,
c: u16,
}
// 2. Das gleiche Struct im C-kompatiblen Layout (kein Reordering)
#[repr(C)]
struct KompatibelC {
a: u8,
b: u64,
c: u16,
}
// 3. Das gleiche Struct komplett komprimiert (kein Padding)
#[repr(packed)]
struct Gepackt {
a: u8,
b: u64,
c: u16,
}
// 4. Ein Unit-like Struct (Zero Sized Type)
struct Leer;
fn main() {
println!("==================================================");
println!(" RUST STRUCT LAYOUT INSPECTOR (CPU/RAM) ");
println!("==================================================");
println!();
println!("--- 1. Rust Default (Field Reordering aktiv) ---");
println!("Größe: {:>2} Bytes (Erwartet: 16)", size_of::<Optimiert>());
println!("Alignment: {:>2} Bytes", align_of::<Optimiert>());
println!();
println!("--- 2. C-Kompatibel (#[repr(C)]) ---");
println!("Größe: {:>2} Bytes (Erwartet: 24)", size_of::<KompatibelC>());
println!("Alignment: {:>2} Bytes", align_of::<KompatibelC>());
println!();
println!("--- 3. Gepackt (#[repr(packed)]) ---");
println!("Größe: {:>2} Bytes (Erwartet: 11)", size_of::<Gepackt>());
println!("Alignment: {:>2} Bytes (Erwartet: 1)", align_of::<Gepackt>());
println!();
println!("--- 4. Unit-like Struct (Zero Sized Type) ---");
println!("Größe: {:>2} Bytes (Erwartet: 0)", size_of::<Leer>());
println!("Alignment: {:>2} Bytes (Erwartet: 1)", align_of::<Leer>());
println!();
println!("==================================================");
println!("Erkenntnis: Rust schützt dich standardmäßig vor ");
println!("unnötigem Speicherverbrauch, gibt dir aber die ");
println!("Kontrolle zurück, wenn du sie wirklich brauchst!");
println!("==================================================");
}
Mit diesem Wissen im Hinterkopf bist du bestens gerüstet, um Strukturen zu schreiben, die nicht nur logisch elegant, sondern auch auf Hardware-Ebene blitzschnell und speichereffizient sind. Viel Spaß beim Optimieren!