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 13: Module, Pfade und das Cargo-Ökosystem

Wenn Programme wachsen, wird es zunehmend schwieriger, den Überblick über den gesamten Quellcode zu behalten. Ein einzelnes Dokument mit tausenden Zeilen Code führt schnell zu Verwirrung, erschwert die Fehlersuche und macht die Zusammenarbeit im Team fast unmöglich. Rust bietet hierfür ein hochentwickeltes, dreistufiges System zur Code-Organisation: Module, Crates (Kisten) und Packages (Pakete). Zudem steuert das integrierte Werkzeug Cargo das gesamte Ökosystem von der Abhängigkeitsverwaltung bis hin zu komplexen Projektstrukturen (Workspaces).

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

  • Für Anfänger (Einfach): Konzentriert sich auf das Aufteilen von Code in Module, Sichtbarkeit mit pub und die Grundlagen von Cargo.
  • für Profis (Architektur): Behandelt fortgeschrittene Sichtbarkeits-Modifikatoren, Cargo Workspaces, bedingte Kompilierung, Feature-Flags und Build-Skripte.
  • Hardware-Sicht (CPU/RAM): Analysiert, wie der Compiler aus Crates Maschinencode baut, das Verhalten von Generics über Crate-Grenzen hinweg und die Struktur des target/-Ordners.

Begleitvideo zu Kapitel 13: Module, Pfade und das Cargo-Ökosystem


Kapitel 13: Module, Pfade und das Cargo-Ökosystem – Ordnung im Code-Universum

Stell dir vor, du eröffnest dein eigenes großes Restaurant. Am ersten Tag steht nur ein einziger Koch in einer leeren Küche. Dieser Koch macht alles selbst: Er schneidet das Gemüse, brät das Fleisch, rührt die Sauce an, backt den Kuchen für den Nachtisch und wäscht am Ende das Geschirr ab. Da nur wenige Gäste da sind, klappt das prima.

Doch dein Restaurant wird ein Riesenhit! Schon bald hast du Hunderte von Gästen jeden Abend. Wenn dieser eine Koch immer noch versucht, alles allein auf einer einzigen Arbeitsplatte zu erledigen, bricht sofort Chaos aus. Er stolpert über Töpfe, verwechselt Salz mit Zucker und schneidet sich im Stress in den Finger.

Die Lösung? Du strukturierst die Küche um! Du unterteilst sie in klare Stationen:

  1. Die Gemüsestation (Entremetier): Nur für das Vorbereiten und Kochen von Gemüse.
  2. Die Fleisch- und Fischstation (Saucier): Nur für Fleischgerichte und Saucen.
  3. Die Patisserie: Nur für Desserts und Kuchen.
  4. Die Spülküche: Nur für den Abwasch.

Jede Station hat ihre eigenen Werkzeuge und Zutaten. Der Patissier braucht nicht zu wissen, wo der Fisch gelagert wird, und der Gemüseschneider muss sich nicht für die Vanilleschoten interessieren. Jede Station arbeitet eigenständig, und sie kommunizieren nur über klar definierte Übergabepunkte miteinander.

In der Softwareentwicklung ist das nicht anders. Wenn dein Programm wächst, wird eine einzige Datei (main.rs) mit Tausenden Zeilen Code unübersichtlich. Wir müssen unseren Code in logische Einheiten aufteilen. In Rust nennen wir diese Stationen Module.


1. Lernziele – Das wirst du heute lernen

  • Was ein Modul ist: Du verstehst das Konzept der Kapselung anhand einfacher Alltagsbeispiele.
  • Inline-Module erstellen: Du lernst, wie du Module direkt in einer Datei deklarierst.
  • Dateimodule nutzen: Du erfährst, wie du Code in separate Dateien auslagerst und wie der Compiler diese findet.
  • Sichtbarkeit steuern: Du begreifst das Prinzip der standardmäßigen Privatheit und wie du Elemente mit pub veröffentlichst.
  • Pfade navigieren: Du lernst, wie du Funktionen über absolute (crate::) und relative (super::) Pfade aufrufst.
  • Importe vereinfachen: Du nutzt use und as (Aliasing), um deinen Code sauber und lesbar zu halten.
  • Cargo-Grundlagen: Du verstehst die Rolle von Cargo.toml und Cargo.lock und fügst deine ersten externen Bibliotheken hinzu.

2. Warum Module? Das Prinzip der Kapselung

Ein Hauptgrund für den Einsatz von Modulen ist die Kapselung (Encapsulation). Das bedeutet, dass wir Details der Funktionsweise verbergen und nur eine einfache Schnittstelle nach außen zeigen.

Wenn du ein Auto fährst, drückst du einfach das Gaspedal (die Schnittstelle). Du musst nicht wissen, wie viel Benzin exakt in den Brennraum eingespritzt wird oder wie die Nockenwelle geformt ist. Diese Details sind im Motorraum “gekapselt”.

Ein weiterer Vorteil ist der Namensraum (Namespace). Wenn zwei Entwickler im selben Projekt eine Funktion namens verbinden() schreiben wollen (einer für die Datenbank, einer für das Internet), gäbe es ohne Module einen Namenskonflikt. Mit Modulen schreiben wir einfach datenbank::verbinden() und netzwerk::verbinden(). Beide Funktionen können friedlich nebeneinander existieren!


3. Inline-Module: Module in derselben Datei definieren

Der einfachste Weg, mit Modulen zu starten, sind sogenannte Inline-Module. Sie werden direkt in deiner bestehenden Datei mit dem Schlüsselwort mod deklariert.

Schauen wir uns ein einfaches, lauffähiges Beispiel an:

// In src/main.rs

// Wir definieren ein Inline-Modul namens "temperatur_rechner"
mod temperatur_rechner {
    // Diese Funktion ist öffentlich (pub) und kann von außen aufgerufen werden
    pub fn celsius_zu_fahrenheit(celsius: f64) -> f64 {
        (celsius * 9.0 / 5.0) + 32.0
    }

    // Diese Funktion hat kein "pub". Sie ist privat!
    // Sie kann NUR innerhalb dieses Moduls aufgerufen werden.
    fn absolut_nullpunkt_celsius() -> f64 {
        -273.15
    }
}

fn main() {
    let zimmertemperatur = 20.0;
    
    // Um auf die Funktion im Modul zuzugreifen, nutzen wir den doppelten Doppelpunkt "::"
    let fahrenheit = temperatur_rechner::celsius_zu_fahrenheit(zimmertemperatur);
    
    println!("{}°C entsprechen {}°F", zimmertemperatur, fahrenheit);
}

Code-Erklärung Zeile für Zeile:

  • mod temperatur_rechner { ... }: Hier erstellen wir das Modul. Alles, was sich innerhalb der geschweiften Klammern befindet, gehört zu diesem Modul.
  • pub fn celsius_zu_fahrenheit(...): Das Wort pub (kurz für public, also öffentlich) ist die Eintrittskarte für die Außenwelt. Ohne pub könnten wir diese Funktion in unserer main-Funktion nicht aufrufen.
  • fn absolut_nullpunkt_celsius(): Da hier kein pub steht, ist die Funktion privat. Sie dient als internes Hilfsmittel für das Modul selbst.
  • temperatur_rechner::celsius_zu_fahrenheit(...): Der Pfad zum Ziel. Wir sagen dem Compiler: “Gehe in das Modul temperatur_rechner und rufe dort die Funktion celsius_zu_fahrenheit auf.”

4. Dateimodule: Code in separate Dateien auslagern

Inline-Module sind toll für kleine Experimente, aber wenn das Projekt wächst, wollen wir den Code lieber auf der Festplatte verteilen.

Wichtig für Umsteiger von Python, Java oder JavaScript: In vielen Sprachen entspricht eine Datei auf der Festplatte automatisch einem Modul, das man einfach importieren kann. In Rust ist das anders! Der Rust-Compiler baut einen Modulbaum (Module Tree) auf. Der Einstiegspunkt ist immer src/main.rs (oder src/lib.rs). Eine andere Datei wird vom Compiler ignoriert, es sei denn, wir tragen sie explizit in den Modulbaum ein!

Lass uns ein Beispiel durchgehen. Wir möchten ein Modul für Netzwerkhilfen erstellen.

Schritt 1: Die neue Datei anlegen

Erstelle eine Datei mit dem Namen src/netzwerk.rs und schreibe folgenden Code hinein:

#![allow(unused)]
fn main() {
// In src/netzwerk.rs

// Diese Funktion soll von außen aufrufbar sein
pub fn verbindung_aufbauen() {
    println!("Verbindung zum Server erfolgreich hergestellt!");
}
}

Schritt 2: Das Modul in der Hauptdatei anmelden

Wenn wir jetzt versuchen, das Programm zu kompilieren, weiß der Compiler noch nichts von netzwerk.rs. Wir müssen die Datei in src/main.rs mit dem Schlüsselwort mod deklarieren:

// In src/main.rs

// Hier teilen wir dem Compiler mit: "Suche nach einer Datei namens netzwerk.rs
// und binde sie als Modul in den Modulbaum ein."
mod netzwerk;

fn main() {
    // Jetzt können wir die Funktion aufrufen!
    netzwerk::verbindung_aufbauen();
}

Wie sucht der Compiler nach den Dateien?

Wenn du mod netzwerk; schreibst, sucht der Compiler an zwei Stellen auf deiner Festplatte:

  1. Als Datei: src/netzwerk.rs (Modernes Layout, empfohlen!)
  2. Als Unterordner mit einer mod.rs-Datei: src/netzwerk/mod.rs (Klassisches Layout, wird weiterhin unterstützt).

Wenn du Untermodule erstellen willst (z. B. netzwerk::protokoll), legst du einen Ordner namens src/netzwerk/ an und erstellst darin eine Datei protokoll.rs. In src/netzwerk.rs schreibst du dann mod protokoll;.


5. Sichtbarkeit (Visibility) und das Prinzip der Kapselung

In Rust gilt das Prinzip der maximalen Kapselung: Standardmäßig ist absolut jedes Element in einem Modul privat. Das ist Absicht! So kann dir nicht aus Versehen jemand in deine internen Daten hineinfuschen.

Struktur-Felder sind standardmäßig privat!

Ein häufiger Fehler bei Einsteigern betrifft Strukturen (structs). Wenn du eine Struktur als öffentlich (pub) deklarierst, bedeutet das nicht, dass ihre Felder ebenfalls öffentlich sind! Jedes Feld must einzeln als pub deklariert werden.

Schauen wir uns das an einem Beispiel an:

mod shop {
    // Die Struktur selbst ist öffentlich
    pub struct Produkt {
        pub name: String, // Dieses Feld ist öffentlich
        preis: f64,       // Dieses Feld ist PRIVAT!
    }

    impl Produkt {
        // Ein öffentlicher Konstruktor, um das Produkt zu erstellen
        pub fn neu(name: &str, preis: f64) -> Self {
            Produkt {
                name: name.to_string(),
                preis,
            }
        }

        // Eine öffentliche Methode, um den Preis zu lesen
        pub fn preis_anzeigen(&self) -> f64 {
            self.preis
        }
    }
}

fn main() {
    // Wir erstellen ein Produkt über den Konstruktor
    let buch = shop::Produkt::neu("Rust-Handbuch", 39.90);

    // Das funktioniert: name ist öffentlich
    println!("Produkt: {}", buch.name);

    // DAS FUNKTIONIERT NICHT (Compilerfehler!):
    // println!("Preis: {}", buch.preis);

    // Stattdessen müssen wir die öffentliche Methode nutzen:
    println!("Preis: {} €", buch.preis_anzeigen());
}

Warum macht Rust das so?

Stell dir vor, du änderst später die interne Berechnung des Preises (z. B. indem du Steuern dynamisch aufschlägst). Wenn der Code außerhalb des Moduls direkt auf buch.preis zugreifen dürfte, müsstest du bei jeder Änderung den gesamten Code der Anwendung anpassen. Da der Zugriff aber nur über die Methode preis_anzeigen() erlaubt ist, kannst du die interne Berechnung im Modul anpassen, ohne dass der Rest des Programms davon etwas mitbekommt!

Enums sind anders!

Bei Enumerationen (enums) verhält es sich umgekehrt: Wenn ein Enum als pub deklariert wird, sind alle seine Varianten automatisch ebenfalls öffentlich. Das ist logisch, da ein Enum ohne seine Varianten nutzlos wäre.


6. Compilerfehler-Show: Typische Fehler verstehen und beheben

Der Rust-Compiler ist berühmt für seine hilfreichen Fehlermeldungen. Lass uns zwei typische Fehler provozieren, damit du sie in freier Wildbahn sofort erkennst.

Fehler 1: Zugriff auf private Funktionen

Wir versuchen, eine private Funktion aufzurufen:

mod geheimnis {
    fn zaubertrick() {
        println!("Simsalabim!");
    }
}

fn main() {
    geheimnis::zaubertrick();
}

Die Fehlermeldung des Compilers:

error[E0603]: function `zaubertrick` is private
 --> src/main.rs:8:16
  |
8 |     geheimnis::zaubertrick();
  |                ^^^^^^^^^^^ private function

Die Lösung: Füge das Wörtchen pub vor fn zaubertrick() hinzu!

Fehler 2: Deklaration vergessen

Du hast eine Datei src/helfer.rs erstellt, aber vergessen, mod helfer; in src/main.rs zu schreiben. Du versuchst, sie in main.rs aufzurufen:

fn main() {
    helfer::mach_etwas();
}

Die Fehlermeldung des Compilers:

error[E0433]: failed to resolve: use of undeclared crate or module `helfer`
 --> src/main.rs:2:5
  |
2 |     helfer::mach_etwas();
  |     ^^^^^^ use of undeclared crate or module `helfer`

Die Lösung: Schreibe ganz oben in deine src/main.rs die Zeile mod helfer;.


7. Pfade & Importe: Wegbeschreibungen im Modulbaum

Um ein Element im Modulbaum zu finden, nutzen wir Pfade.

  • Absoluter Pfad: Startet immer ganz oben an der Wurzel deines eigenen Projekts mit dem Wort crate::. Das entspricht der Pfadangabe ab der Festplatte (z. B. /home/user/dokumente/datei.txt).
  • Relativer Pfad: Startet im aktuellen Modul. Wir können mit super:: eine Ebene nach oben gehen (wie .. im Terminal) oder mit self:: im aktuellen Modul suchen.

Ein Beispiel zur Navigation:

#![allow(unused)]
fn main() {
mod kueche {
    pub mod herd {
        pub fn anmachen() {
            println!("Herdplatte glüht.");
        }
    }

    pub mod zubereitung {
        pub fn suppe_kochen() {
            // Wir wollen den Herd anmachen. Er liegt im Nachbarmodul "herd".
            // Mit "super" gehen wir hoch in die "kueche" und von dort in den "herd".
            super::herd::anmachen();
            println!("Suppe kocht.");
        }
    }
}
}

Der Import-Retter: Das Schlüsselwort use

Wenn wir im Code zehnmal kueche::herd::anmachen() schreiben müssen, wird das schnell lästig. Mit use können wir einen Pfad in unseren aktuellen Gültigkeitsbereich einladen:

// Wir importieren die Funktion direkt in den Scope
use kueche::herd::anmachen;

fn main() {
    // Jetzt können wir sie direkt aufrufen!
    anmachen();
}

Namenskonflikte lösen mit as (Aliasing)

Manchmal importiert man zwei Dinge mit dem gleichen Namen. Um den Compiler nicht zu verwirren, können wir sie beim Importieren umbenennen:

#![allow(unused)]
fn main() {
// Wir benennen den Typ mit "as" lokal um
use std::fmt::Result as FmtResult;
use std::io::Result as IoResult;

fn schreiben() -> IoResult<()> {
    // Hier ist std::io::Result gemeint
    Ok(())
}
}

8. Cargo für Anfänger: Abhängigkeiten hinzufügen

Bisher haben wir nur Code geschrieben, den wir selbst erstellt haben. Die wahre Stärke von Rust liegt aber auch in seinem riesigen Ökosystem auf crates.io – einer Website, auf der Entwickler ihre Bibliotheken teilen.

Um eine solche externe Bibliothek (in Rust nennen wir das ein Crate) zu verwenden, nutzen wir Cargo.

Öffne die Datei Cargo.toml in deinem Projekt. Sie sieht ungefähr so aus:

[package]
name = "mein_projekt"
version = "0.1.0"
edition = "2021"

[dependencies]
# Hier tragen wir unsere Wünsche ein!

Wenn wir zum Beispiel Zufallszahlen generieren wollen, können wir das Crate rand hinzufügen. Wir schreiben einfach unter [dependencies]:

[dependencies]
rand = "0.8.5"

Wenn wir jetzt das nächste Mal cargo build oder cargo run im Terminal ausführen, erledigt Cargo die gesamte Arbeit im Hintergrund:

  1. Es lädt das Crate rand aus dem Internet herunter.
  2. Es lädt alle Hilfsbibliotheken herunter, die rand benötigt.
  3. Es kompiliert das alles und verlinkt es mit unserem Code.

Der Unterschied zwischen Cargo.toml und Cargo.lock

  • Cargo.toml (Konfiguration): Hier schreibst du deine Wünsche auf. Du sagst: “Ich möchte rand in Version 0.8 haben.”
  • Cargo.lock (Sperrdatei): Wird automatisch von Cargo generiert. Sie speichert die exakten Versionsnummern ab, die beim ersten erfolgreichen Kompilieren heruntergeladen wurden (z. B. rand v0.8.5). Das garantiert: Wenn du dein Projekt in fünf Jahren auf einem anderen Computer öffnest, wird exakt derselbe Code heruntergeladen. Es kann nicht passieren, dass ein unangekündigtes Update der Bibliothek dein Programm plötzlich unbrauchbar macht!

9. Zusammenfassung

Ordnung ist das halbe Leben – das gilt besonders für Programmiercode!

  1. Module strukturieren deinen Code und verhindern Namenskonflikte.
  2. In Rust ist das Dateisystem vom Modulbaum entkoppelt. Du musst jede Datei mit mod in main.rs oder lib.rs anmelden.
  3. Alles in Rust ist standardmäßig privat. Nutze pub, um Dinge öffentlich zu machen.
  4. Struktur-Felder bleiben privat, selbst wenn das Struct pub ist. Enums hingegen geben alle ihre Varianten frei.
  5. Mit use und as sparst du Schreibarbeit und löst Namenskonflikte auf.
  6. Cargo nimmt dir die lästige Arbeit ab, Bibliotheken aus dem Internet herunterzuladen und zu verwalten.

Jetzt bist du bereit, Ordnung in deine Projekte zu bringen. Auf ins nächste Kapitel!


Kapitel 13: Module, Pfade und das Cargo-Ökosystem – Software-Architektur und professionelles Cargo-Management

In größeren Softwareprojekten reicht ein einfaches Verständnis von “öffentlich” und “privat” oft nicht aus. Wenn Sie eine Bibliothek entwickeln, die von Hunderten anderen Entwicklern genutzt wird, möchten Sie bestimmte Implementierungsdetails vielleicht innerhalb Ihres eigenen Projekts teilen, diese aber keinesfalls in der öffentlichen API der Bibliothek freigeben. Zudem erfordern komplexe Systemarchitekturen eine feingranulare Steuerung des Build-Prozesses, die Verwaltung mehrerer Repositories in einem gemeinsamen Workspace und die optionale Kompilierung je nach Zielplattform oder Feature-Wunsch.

In diesem fortgeschrittenen Abschnitt betrachten wir das Modul- und Cargo-System aus der Perspektive des Software-Architekten.


1. Lernziele – Das wirst du heute lernen

  • Feingranulare Sichtbarkeiten: Sie steuern die Sichtbarkeit von APIs präzise mit pub(crate), pub(super) und pub(in pfad).
  • Crates vs. Packages vs. Workspaces: Sie kennen die genauen Unterschiede und organisieren große Multi-Projekt-Strukturen.
  • Erweiterte Cargo.toml-Konfiguration: Sie binden Abhängigkeiten über Git-Pfade oder lokale Verzeichnisse ein und nutzen dev-dependencies.
  • Build-Skripte (build.rs): Sie generieren Code oder verknüpfen C-Bibliotheken vor dem eigentlichen Build-Vorgang.
  • Bedingte Kompilierung: Sie steuern plattformspezifischen Code mittels #[cfg(...)].
  • Feature-Flags: Sie machen optionale Features konfigurierbar, um Kompilierzeit und Binärgröße zu optimieren.

2. Feingranulare Sichtbarkeiten: Präzise API-Kontrolle

Die einfache binäre Unterscheidung zwischen privat (standardmäßig) und öffentlich (pub) stößt in großen Projekten schnell an Grenzen. Rust bietet daher erweiterte Sichtbarkeits-Modifikatoren an, mit denen Sie den Zugriff auf Klassen, Funktionen und Strukturen exakt einschränken können:

  • pub(crate): Das Element ist innerhalb des gesamten aktuellen Crates (des eigenen Projekts) sichtbar, wird aber nicht nach außen (für andere Crates, die diese Bibliothek einbinden) exportiert.
  • pub(super): Das Element ist nur im direkt übergeordneten Modul (dem Elternmodul) sichtbar.
  • pub(in pfad): Das Element ist nur innerhalb des angegebenen Modulpfads sichtbar. Der Pfad muss ein Vorfahr des aktuellen Moduls sein.

Ein Architekturbeispiel:

// Ein Crate für eine Datenbank-Engine
pub mod datenbank {
    pub struct Verbindung {
        // Diese URL darf nur innerhalb dieses Crates verwendet werden.
        // Externe Nutzer der Bibliothek dürfen sie nicht sehen oder ändern!
        pub(crate) verbindungs_url: String,
    }

    mod intern {
        // Diese Hilfsfunktion ist nur für das Modul "datenbank" sichtbar
        pub(super) fn ping_pruefen() {
            println!("Datenbank antwortet.");
        }
        
        // Diese Funktion ist nur im Modul "datenbank::intern" und seinen Kindern sichtbar
        pub(self) fn geheimes_logging() {
            println!("Schreibe geheimes Log.");
        }
    }

    pub fn verbindung_aufbauen(url: &str) -> Verbindung {
        intern::ping_pruefen(); // Erlaubt wegen pub(super)
        Verbindung {
            verbindungs_url: url.to_string(),
        }
    }
}

fn main() {
    let verb = datenbank::verbindung_aufbauen("postgres://localhost");
    
    // Das funktioniert NICHT, da "verbindungs_url" nur projektintern (pub(crate)) ist:
    // println!("URL: {}", verb.verbindungs_url);
}

3. Die Konzepte im Detail: Crates, Packages und Workspaces

Um die Paketverwaltung und das Build-System in Rust zu verstehen, müssen wir drei Begriffe exakt voneinander abgrenzen:

  1. Crate (Übersetzungseinheit): Die kleinste Compilationseinheit, die der Compiler (rustc) verarbeitet. Ein Crate besteht aus einem Modulbaum und wird entweder zu einer ausführbaren Binärdatei (Binary Crate) oder zu einer wiederverwendbaren Bibliothek (Library Crate) übersetzt.
  2. Package (Paket): Ein Cargo-Projekt, das durch eine Cargo.toml-Datei beschrieben wird. Ein Package enthält Metadaten, Konfigurationen und kann:
    • Maximal ein Library Crate enthalten (src/lib.rs).
    • Beliebig viele Binary Crates enthalten (src/main.rs oder zusätzliche Dateien im Ordner src/bin/).
  3. Workspace (Arbeitsbereich): Eine Zusammenfassung mehrerer Packages in einer gemeinsamen Projektmappe. Ein Workspace ermöglicht es verschiedenen Packages, sich denselben Ausgabeordner (target/) und dieselbe Sperrdatei (Cargo.lock) zu teilen, was Speicherplatz spart und die Kompilierzeit drastisch verringert.

4. Fortgeschrittene Cargo.toml-Konfiguration

Neben den Standardabhängigkeiten von crates.io erlaubt Cargo feingranulare Einstellungen in der Cargo.toml.

Git- und lokale Pfad-Abhängigkeiten

Während der Entwicklung einer Bibliothek ist es oft unpraktisch, jede Änderung erst auf crates.io hochzuladen. Sie können stattdessen direkt lokale Verzeichnisse oder Git-Repositories referenzieren:

[dependencies]
# Lokale Abhängigkeit auf der Festplatte
datenbank_treiber = { path = "../datenbank_treiber" }

# Abhängigkeit direkt aus einem Git-Repository
crypt_helper = { git = "https://github.com/beispiel/crypt.git", branch = "main" }

Entwicklungsabhängigkeiten ([dev-dependencies])

Einige Bibliotheken werden ausschließlich für Tests, Beispiele oder Leistungsbenchmarks benötigt (z. B. spezielle Assertions-Bibliotheken). Damit diese im finalen Release-Build keine unnötige Größe verursachen und die Kompilierzeit der Anwender nicht belasten, deklarieren Sie sie unter [dev-dependencies]:

[dev-dependencies]
pretty_assertions = "1.4.0" # Verbessert die Lesbarkeit von Testfehlern

5. Build-Skripte (build.rs) und Build-Abhängigkeiten

Manchmal müssen vor dem eigentlichen Kompilieren des Rust-Codes Aufgaben auf Betriebssystemebene ausgeführt werden. Typische Beispiele sind:

  • Das automatische Generieren von Rust-Code aus anderen Formaten (z. B. Protocol Buffers oder SQL-Dateien).
  • Das Kompilieren und Verlinken einer alten C/C++-Bibliothek über FFI (Foreign Function Interface).
  • Das Auslesen von Umgebungsvariablen zur Compilezeit.

Dazu platzieren Sie eine Datei namens build.rs im Wurzelverzeichnis Ihres Packages (neben der Cargo.toml). Cargo kompiliert und führt dieses Skript aus, bevor der eigentliche Rust-Code übersetzt wird. Bibliotheken, die nur von diesem Build-Skript benötigt werden, tragen Sie unter [build-dependencies] ein:

# Cargo.toml
[build-dependencies]
cc = "1.0" # C-Compiler-Wrapper für Rust

Ein einfaches Beispiel für ein build.rs Skript:

// build.rs (wird vor dem eigentlichen Projekt ausgeführt)
fn main() {
    // Teilt Cargo mit: "Führe dieses Skript nur erneut aus, wenn sich src/api.proto ändert."
    println!("cargo:rerun-if-changed=src/api.proto");
    
    // Code-Generierung oder Linker-Anweisungen hier...
}

6. Bedingte Kompilierung und Feature-Flags

Rust ermöglicht es Ihnen, Teile des Codes je nach Zielplattform oder Anwenderkonfiguration ein- oder auszuschließen.

Das #[cfg(...)]-Attribut vs. das cfg!(...)-Makro

  • #[cfg(target_os = "windows")] (Attribut): Der markierte Code wird vom Compiler vollständig ignoriert, wenn das Zielbetriebssystem kein Windows ist. Dies ist zwingend erforderlich, wenn Sie Windows-spezifische APIs aufrufen, die auf Linux gar nicht existieren.
  • cfg!(target_os = "windows") (Makro): Liefert zur Laufzeit einen booleschen Wert (true/false). Der gesamte Code wird jedoch auf allen Plattformen kompiliert. Nutzen Sie das Makro nur für plattformübergreifende Pfade, die auf allen Systemen syntaktisch valide sind.

Feature-Flags zur modularen Code-Steuerung

Feature-Flags erlauben es Bibliotheksautoren, optionale Funktionalitäten anzubieten. Anwender aktivieren nur die Features, die sie tatsächlich benötigen.

Definition in der Cargo.toml:

[features]
# Standardmäßig aktive Features
default = ["json"]

# Feature-Definitionen
json = []
pdf_export = ["dep:pdf_writer"] # Aktiviert ein optionales Crate

[dependencies]
pdf_writer = { version = "0.7", optional = true }

Im Rust-Code nutzen Sie das cfg-Attribut:

#![allow(unused)]
fn main() {
#[cfg(feature = "pdf_export")]
pub fn dokument_exportieren() {
    println!("PDF wird generiert...");
}
}

7. Cargo Workspaces für Multi-Projekt-Architekturen

Bei sehr großen Applikationen (z. B. einer Web-App bestehend aus einem API-Server, einem CLI-Client und einer gemeinsamen Logik-Bibliothek) ist es Best Practice, das Projekt in einen Workspace aufzuteilen.

Die Struktur eines Workspaces sieht typischerweise so aus:

mein_workspace/
├── Cargo.toml       <-- Workspace-Konfiguration
├── Cargo.lock       <-- Geteilte Sperrdatei
├── target/          <-- Geteiltes Ausgabeverzeichnis
├── api_server/      <-- Eigenständiges Package (mit eigener Cargo.toml)
├── cli_client/      <-- Eigenständiges Package (mit eigener Cargo.toml)
└── core_lib/        <-- Logik-Bibliothek (mit eigener Cargo.toml)

In der Haupt-Cargo.toml deklarieren Sie den Workspace:

[workspace]
members = [
    "api_server",
    "cli_client",
    "core_lib",
]
resolver = "2"

Die Vorteile:

  • Geteilter Build-Cache: Wenn sowohl api_server als auch cli_client die Bibliothek serde verwenden, wird diese nur ein einziges Mal kompiliert. Das spart massiv Zeit und Festplattenplatz.
  • Einheitliche Versionen: Die Cargo.lock sorgt dafür, dass alle Packages im Workspace exakt dieselben Versionen ihrer externen Abhängigkeiten nutzen.

Kapitel 13 - Hardware-Sicht: Module, Pfade und das Cargo-Ökosystem unter der Lupe von Compiler und RAM

Hallo Thorsten! Nachdem wir die logischen Strukturen und die fortgeschrittenen Architekturkonzepte der Code-Kapselung in Rust besprochen haben, werfen wir jetzt einen Blick hinter die Kulissen.

Als Systemprogrammierer gibst du dich nicht mit der abstrakten Vorstellung zufrieden, dass Code “in Schubladen sortiert” wird. Du willst wissen: Wie sieht der Modulbaum für den Compiler aus? Wo findet die Kompilierung generischer Schnittstellen über Crate-Grenzen hinweg statt? Und warum wächst der target/-Ordner im RAM und auf der Festplatte so rasant an?

Schnapp dir einen Kaffee – wir steigen tief in die Hardware- und Compiler-Ebene ab!


1. Die Sicht des Compilers auf Module: Translation Units

Für einen Entwickler ist die Aufteilung in verschiedene Dateien und Ordner auf der Festplatte eine der wichtigsten Hilfen zur Strukturierung. Für den Compiler hingegen sind Dateien fast völlig bedeutungslos.

In vielen älteren Programmiersprachen (wie C oder C++) kompiliert der Compiler jede Quelldatei einzeln zu einer Objektdatei (.o oder .obj) und verknüpft diese später über den Linker. Dies hat den Nachteil, dass der Compiler beim Übersetzen einer Datei keine Details über die Implementierung in einer anderen Quelldatei kennt (was Optimierungen wie Inlining erschwert).

Rust geht einen anderen Weg:

  • Das Crate als kleinste Übersetzungseinheit (Translation Unit): Für den Compiler existiert nur das gesamte Crate als eine einzige, gigantische Einheit.
  • Der Modulbaum-Kollaps: Wenn du das Projekt kompilierst, liest der Compiler den Einstiegspunkt (src/main.rs oder src/lib.rs). Er folgt allen mod-Deklarationen und baut daraus einen einzigen, riesigen abstrakten Syntaxbaum (AST) auf. Die Dateigrenzen werden dabei vollständig aufgelöst.
  • Vorteil für die Hardware-Optimierung: Da der Compiler das gesamte Crate im Speicher vorliegen hat, kann er Optimierungen wie Inlining (das direkte Ersetzen eines Funktionsaufrufs durch den eigentlichen Funktionscode) problemlos und extrem effizient durchführen. Es gibt keine Barrieren zwischen den Modulgrenzen.

2. Monomorphisierung an Crate-Grenzen: Wer generiert den Maschinencode?

Wenn Sie generischen Code schreiben (z. B. eine Funktion fn verarbeiten<T>(daten: T)), wendet Rust die Monomorphisierung an. Das bedeutet, dass der Compiler den generischen Code für jeden konkreten Typ, mit dem die Funktion aufgerufen wird, kopiert und spezifischen Maschinencode erzeugt (ausführlich erklärt in Kapitel 14).

Spannend wird dies an den Grenzen von Crates: Stellen Sie sich vor, Sie nutzen das Crate std (die Standardbibliothek, die als vorkompiliertes Bibliotheks-Crate vorliegt) und verwenden dort einen Vec<MyStruct>, wobei MyStruct in Ihrem eigenen Programm definiert ist.

// In Ihrem Crate definiert
struct MyStruct {
    id: u64,
}

fn main() {
    // Vec ist im Crate "std" definiert.
    // MyStruct ist in Ihrem Crate definiert.
    let mut liste = Vec::new();
    liste.push(MyStruct { id: 42 });
}

Wo findet die Monomorphisierung statt?

Da die Standardbibliothek std bereits fertig kompiliert auf Ihrem System vorliegt, konnte der Compiler zur Compilezeit von std noch gar nichts von Ihrer Struktur MyStruct wissen. Er konnte also keinen Maschinencode für Vec<MyStruct> vorbereiten.

Die Monomorphisierung findet daher vollständig im aufrufenden Crate (Ihrem Crate) statt:

  1. Der Compiler liest die generischen Definitionen (den AST und die Metadaten) aus dem Bibliotheks-Crate std ein.
  2. Er erzeugt den spezifischen Maschinencode für Vec<MyStruct> direkt in Ihrem Projekt.
  3. Dies erklärt, warum Projekte mit vielen generischen Abhängigkeiten (z. B. Parser-Bibliotheken oder Serialisierer wie serde) beim ersten Kompilieren sehr rechenintensiv sind und die CPU stark belasten: Der gesamte Code der Abhängigkeiten muss für Ihre spezifischen Typen neu generiert werden!

3. Der Cargo-Build-Cache: Warum der target/-Ordner explodiert

Jeder Rust-Entwickler stolpert früher oder her über die immense Größe des target/-Verzeichnisses. Bei größeren Projekten kann dieser Ordner leicht mehrere Gigabyte groß werden.

Was speichert Cargo im target/-Ordner?

Rust nutzt ein hochentwickeltes System der inkrementellen Kompilierung. Um bei einer kleinen Änderung nicht jedes Mal den gesamten Code neu übersetzen zu müssen, speichert der Compiler enorme Mengen an Zwischenergebnissen im Build-Cache ab:

  1. Metadaten (.rmeta): Beschreibungen der Schnittstellen und Typdefinitionen der Crates. Diese werden benötigt, damit andere Crates wissen, wie sie mit der Bibliothek kommunizieren können.
  2. LLVM-Bitcode (.bc): Eine plattformunabhängige Zwischenstufe des Codes vor der Generierung des eigentlichen Maschinencodes.
  3. Objektdateien (.o): Die fertig kompilierten Maschinencode-Blöcke der einzelnen Module.
  4. Abhängigkeitsgraphen (.d): Genaue Beschreibungen, welches Modul von welchem anderen Modul abhängt.

Inkrementelle Kompilierung im Detail:

Wenn Sie eine Zeile Code in einem Modul ändern, analysiert der Compiler den Abhängigkeitsgraphen im Cache. Er identifiziert die exakten Pfade, die von Ihrer Änderung betroffen sind, und übersetzt nur diese neu. Die unberührten Teile werden einfach als fertige Objektdateien aus dem Cache geladen und am Ende miteinander verlinkt.

  • Der Preis dafür: Extrem schneller Entwicklungszyklus (cargo check / cargo run nach kleinen Änderungen dauert oft nur Millisekunden), aber ein gigantischer Speicherbedarf auf der Festplatte.
  • Der Befehl cargo clean: Leert diesen gesamten Cache. Der Speicherplatz wird sofort freigegeben, aber der nächste Build-Vorgang muss wieder von ganz vorne anfangen (Full Build).

4. Statische vs. Dynamische Verknüpfung (Linking)

Wenn Ihr Code fertig kompiliert ist, müssen alle Teile zu einer ausführbaren Datei zusammengefügt werden. Rust setzt hier standardmäßig auf statisches Linking.

Was bedeutet das für die Hardware?

  • Statisches Linking (Standard): Der Linker kopiert alle benötigten Bibliotheken (einschließlich der Standardbibliothek std und aller externen Crates) direkt in die fertige Binärdatei.
    • Hardware-Auswirkung: Die ausführbare Datei wird relativ groß (oft mehrere Megabytes für ein einfaches Programm). Dafür ist sie vollständig portabel: Sie können die Datei auf einen anderen Computer kopieren, und sie wird dort sofort und ohne Installation von Laufzeitumgebungen ausgeführt. Zudem ermöglicht statisches Verlinken die Link-Time-Optimization (LTO), bei der der Compiler ungenutzten Code aus Bibliotheken komplett aus der finalen Binärdatei entfernt (Dead Code Elimination).
  • Dynamisches Linking: Das Programm verweist zur Laufzeit auf geteilte Systembibliotheken (.so unter Linux, .dll unter Windows).
    • Hardware-Auswirkung: Die Binärdatei ist winzig (nur wenige Kilobytes). Es besteht jedoch das Risiko von Versionskonflikten (“Dependency Hell”), und das Laden des Programms dauert beim Start minimal länger, da das Betriebssystem die Bibliotheken erst im RAM suchen und verlinken muss.

5. Verweis auf Übungen

Sie haben nun gelernt, wie Sie Code kapseln, Pfade nutzen und Ihr Projekt mit Cargo organisieren. Jetzt ist es an der Zeit, diese Konzepte in der Praxis anzuwenden.

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

Dort finden Sie praktische Aufgaben, bei denen Sie:

  1. Ein Modul in mehrere Dateien aufteilen müssen.
  2. Sichtbarkeiten korrigieren müssen, um Compiler-Fehler zu beheben.
  3. Pfade mithilfe von super und crate reparieren müssen.
  4. Externe Abhängigkeiten in eine Cargo.toml einbinden sollen.