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 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:

  1. &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 Methode gib_laut oder zeige_status).

  2. &mut self (Veränderliches Ausleihen): Die Methode darf die Daten der Struktur verändern (z. B. den Hunger auf false setzen oder die Lebenspunkte verringern).

  3. 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 name statt name: 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 nicht name: name schreiben. Ein einfaches name reicht 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:

  1. Eine assoziierte Funktion neu(titel: String, autor: String, seitenanzahl: u32), die ein neues Buch erstellt. Das Feld ausgeliehen soll standardmäßig false sein.
  2. Eine Methode ausleihen(&mut self), die den Status von ausgeliehen auf true setzt und eine Nachricht auf dem Bildschirm ausgibt. Falls das Buch bereits ausgeliehen war, soll eine Warnung ausgegeben werden.
  3. Eine Methode zurueckgeben(&mut self), die den Status von ausgeliehen auf false setzt.

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, &self or &mut self verwenden 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!