Module, Crates, Sichtbarkeit und Tests

In diesem Abschnitt werden wir Wege zur Organisation wachsender Codeprojekte behandeln. Wir behandeln diese Themen nicht in aller Ausführlichkeit und verweisen auf das Rust-Book für weitere Informationen.

Crates

Die Codemenge, die der Compiler zu einer Zeit übersetzt, nennt sich Crate. Ein Crate kann eine einzelne Datei sein. Ein Crate kann auch ein Projekt mit vielen Dateien und Abhängigkeiten zu anderen Crates sein. Ein Crate ist entweder ein ausführbares Programm (engl. binary crate) mit einer main-Funktion oder eine Bibliothek (engl. library crate), die von anderen Crates verwendet werden kann. Bibliotheks-Crates haben keine main-Funktion. Der Begriff Crate wird von Rustaceans1 üblicherweise als Synonym für Bibliotheks-Crate verwendet. Jedes Crate hat eine Datei, die als Startpunkt verwendet wird. Diese Datei heißt Crate Root. Bei Verwendung von cargo new bekommen wir nicht nur ein Crate sondern auch ein Paket (engl. package), das aus einem oder mehreren Crates bestehen kann und manuell zu versionieren ist. Der Befehl

cargo new my_package

erstellt ein ausführbares Crate. Der Ordner beinhaltet neben der Cargo.toml einen .git-Ordner, eine .gitignore-Datei2 und eine Datei src/main.rs. Letztgenannte Datei ist die Crate Root des einzigen ausführbaren Crates unseres Pakets mit dem Namen my_package. Die .gitignore-Datei beinhaltet lediglich den Ordner target, in dem die Kompilate abgelegt werden. Wenn wir einen Bibliotheks-Crate erstellen wollen, ist die Option --lib hilfreich. Die Crate Root heißt in diesem Fall src/lib.rs und beinhaltet keine main-Funktion. Daneben gibt es noch einen Unterschied. Die .gitignore-Datei beinhaltet neben dem Ordner target auch eine Datei mit dem Namen Cargo.lock. Die Dateien Cargo.toml und Cargo.lock beinhalten teilweise ähnliche Informationen, haben aber unterschiedliche Verwendungszwecke.

  • Cargo.toml wird von der Rustacean mit Abhängigkeiten ihres Pakets und weiterer Konfiguration wie der Version des Pakets befüllt.
  • Cargo.lock wird von Cargo automatisch mit den exakten Abhängigkeiten und Abhängigkeiten der Abhängigkeiten, die für den letzten erfolgreichen Build verwendet wurden, befüllt. Cargo.lock sollte nicht manuell editiert werden.

Mit der Datei Cargo.lock kann man also den letzten Build exakt reproduzieren. Das ist hilfreich für Anwendungen um Versionskonflikte in den Abhängigkeiten auszuschließen, da alle Abhängigkeiten dieser Anwendung in einer für den letzten Build funktionierenden Konfiguration verwendet werden. Verwender des Bibliotheks-Crates haben aber üblicherweise weitere Bibliotheken und Abhängigkeiten. Daher ist es für Bibliotheksnutzer gut einen minimalen Satz an Abhängigkeiten definiert zu haben, so dass die Wahrscheinlichkeit für Versionskonflikte minimiert wird.

Wenn ein Paket sowohl eine Datei src/main.rs und src/lib.rs beinhaltet, dann gibt es im Paket 2 Crates mit gleichem Namen. Ein Paket kann mehrere ausführbare Crates haben. Dazu werden weitere .rs-Dateien mit eigener main-Funktion unter src/bin abgelegt. Die Anzahl der Bibliotheks-Crates pro Paket ist auf 1 beschränkt.

Um Crates zu verwenden, die andere zur Verfügung gestellt haben, ist Crates.io die erste Anlaufstelle. Wenn wir beispielsweise den Crate Exmex in unserem Crate verwenden wollen, geben wir

cargo add exmex

ein. In der Cargo.toml taucht exmex nun als Abhängigkeit auf.

[dependencies]
exmex = "0.17.3"

Jeder kann Crates auf Crates.io zur Verfügung stellen.

Module und Sichtbarkeit

Crates sind in Module organisiert. Immer. Die Create Root ist ein Modul. Weitere Module können innerhalb der Crate Root definiert werden. Per

mod my_submodule {
    fn g() {
        println!("g");
    }
}
fn main() {
    g();
}

wird ein Submodul in einer Datei definiert. Module verhindern den direkten Zugriff auf ihre Elemente aus anderen Modulen. Dementsprechend funktioniert der obere Schnipsel nicht, da g in main aufgerufen wird, aber g in einem anderen Modul lebt. Wir müssen entweder den kompletten Pfad zu g angeben oder g per use importieren.

mod my_submodule {
    fn g() {
        println!("g");
    }
}
fn main() {
    my_submodule::g();
}

Auch dieser Schnipsel funktioniert nicht, denn Funktionen und Typen in Modulen sind erstmal private Elemente des Moduls. Wenn sie von außerhalb des Moduls verwendet werden sollen, müssen sie mit dem Keyword pub annotiert werden.

mod my_submodule {
    pub fn g() {
        println!("g");
    }
}
fn main() {
    my_submodule::g();
}

Umgekehrt können wir aus Submodulen auf andere Module zugreifen, wenn sie über den richtigen Pfad angesprochen oder importiert werden.

mod my_submodule {
    use f;
    pub fn g() {
        f();
        println!("g");
    }
}
fn f() {
    println!("f");
}
fn main() {
    my_submodule::g();
}

Bemerke, dass f nicht pub ist. Submodule sind nicht außerhalb ihrer Module und können daher direkt auf private Funktionen und Typen zugreifen.

Des Weiteren sind andere .rs-Dateien Module, wenn sie per mod Erwähnung im Crate Root finden. Nehmen wir beispielsweise folgende Struktur unseres Pakets an.

my_package
├── Cargo.lock
├── Cargo.toml
└── src
    ├── module_2
    │   └── mod.rs
    │   └── module_2_a.rs
    │   └── module_2_b.rs
    ├── module_1.rs
    └── main.rs

Die Crate Root main.rs bindet die Module module_1 und module_2 per mod ein.

#![allow(unused)]
fn main() {
// main.rs
mod module_1;
mod module_2;
}

Das Modul module_2 besteht nicht aus einer einzelnen Datei, sondern aus einem Ordner. Die Datei mod.rs definiert die Sichtbarkeit dieses Moduls nach außen. Bei folgender Konfiguration ist module_2_a von main erreichbar und module_2_b nicht.

#![allow(unused)]
fn main() {
// mod.rs
pub mod module_2_a;
mod module_2_b;
}

Module externer Crates können ebenfalls über Pfade erreicht werden, nachdem wir sie der Cargo.toml hinzugefügt haben. Wir können in unserem Crate Exmex-Funktionalität über Pfade beginnend mit exmex:: verwenden, nachdem wir Exmex manuell oder cargo add exmex der Cargo.toml hinzugefügt haben.

Auch der Zugriff auf Methoden und Felder von Struktur- und Aufzählungstypen außerhalb des Moduls lässt sich per pub steuern. Varianten von Aufzählungstypen sind dagegen immer pub. Das heißt die Felder a und b von

#![allow(unused)]
fn main() {
struct X {
    a: u8,
    b: u8,
}
}

können überall innerhalb des Moduls, in dem X definiert wurde, direkt verwendet werden.

struct X {
    a: u8,
    b: u8,
}
fn main() {
    let x = X{ a: 2, b: 1 };
    println!("{}", x.a);
}

Über Modulgrenzen hinweg lassen sich nur pub-Felder eines pub-Typen verwenden.

// kompiliert nicht
mod detail {
    struct X {
        a: u8,
        b: u8,
    }
}
use detail::X;
fn main() {
    let x = X{ a: 2, b: 1 };
    println!("{}", x.a);
}

Der Compiler weist uns auf die private Sichtbarkeit von X und ihrer Felder a und b hin. Wir können durch das Schlüsselwort pub Zugriff ermöglichen.

mod detail {
    pub struct X {
        pub a: u8,
        pub b: u8,
    }
}
use detail::X;
fn main() {
    let x = X{ a: 2, b: 1 };
    println!("{}", x.a);
}

Das use-Statement haben wir schon oft in Aktion gesehene. Mit

#![allow(unused)]
fn main() {
use crate_name;
}

binden wir das Crate-Root ein. Dann lassen sich die Elemente des Crates z.B. per

#![allow(unused)]
fn main() {
use crate_name::ein_crate_modul:EinCrateTyp;
}

einbinden und wir können EinCrateTyp verwenden. Wenn wir nicht den ganzen Pfad einbinden können wir den Typen

#![allow(unused)]
fn main() {
let x = crate_name::ein_crate_modul::EinCrateTyp::new();
}

instanziieren, falls er die Methode new mitbringt. Um ein Supermodul einzubinden können wir entweder den Pfad vom Crate-Root ausgehend verwenden oder use super; einsetzen.

Tests

Wenn wir einen Blick in die Datei src/lib.rs werfen, die durch

cargo new --lib

erstellt wurde, finden wir folgenden Inhalt.

#![allow(unused)]
fn main() {
pub fn add(left: usize, right: usize) -> usize {
    left + right
}
#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn it_works() {
        let result = add(2, 2);
        assert_eq!(result, 4);
    }
}
}

Mit dem Attribut #[cfg(test)] annotieren wir Code, der nur für Tests relevant ist. Das Attribut #[test] annotiert den eigentlichen Test. cargo run ignoriert alle entsprechend annotierte Bereiche. Zum Ausführen der Tests können wir einfach cargo test eingeben. Cargo hat die Tests in einem Modul separiert, womit die eigentliche Funktion add getestet wird. Innerhalb einer Testfunktion wird die Richtigkeit üblicherweise mit asserts geprüft. Das Makro assert_eq!, überprüft die Gleichheit zweier Audrücke, die als Argumente übergeben werden. Vorsicht ist bei den Typen f32 und f64 geboten. Daneben gibt es assert!, dass fehlschlägt, wenn der eine Ausdruck in den folgenden Klammern false zurück gibt, und assert_ne!, das auf Ungleichheit prüft. Es ist möglich, assert als weiteres Argument Nachrichten zu übergeben, die bei Fehlschlagen ausgegeben werden. Diese folgen dem Format, das wir bei println! bereits kennen gelernt haben.

Testen durch das public Interface

Der Test aus der vorherigen Sektion hat Zugriff auf die Interna des Moduls. Um Tests zu schreiben, die ein Crate nur durch sein public Interface testen können, bietet Cargo an, den separaten Ordner tests zu durchforsten.

my_package
├── Cargo.lock
├── Cargo.toml
└── src
└── tests

Darin können wir auf unseren Crate zugreifen, als sei es ein externer Crate. Damit die Tests durch cargo test ausgeführt werden, verwenden wir weiterhin die #[test] oder #[cfg(test)] Attribute.

Der Ordner tests wird allerdings nur bei Bibliotheks-Crates verwendet. Daher werden oft Rust Anwendungen in einen Bibliotheks-Crate und eine minimale main.rs aufgeteilt.

Doc-Tests und Dokumentation

Die Beschreibung von Dokumentation, die automatisch aus Kommentaren extrahiert wird und auf docs.rs beim Upload des eigenen Crates veröffentlicht wird, findet sich im Rustdoc Book beschrieben. Dort findet man auch einen Abschnitt zu Doc-Tests. Damit kann man Code Beispiele aus der Dokumentation direkt als Tests verwenden, was äußerst praktisch ist. Wenn beispielsweise einer Funktion ein Kommentar mit drei Slashes zu Beginn jeder Zeile voransteht, wird dieser von Rustdoc extrahiert. Mit cargo doc lässt sich die Dokumentation extrahieren. Es können sich auch Code-Beispiele darin befinden.

#![allow(unused)]
fn main() {
/// Adds two numbers
///
/// Example
/// ```rust
/// use my_package::add;
/// let sum = add(2, 4);
/// assert_eq!(sum, 6);
/// ```
pub fn add(left: usize, right: usize) -> usize {
    left + right
}
}

Die Zeichenketten ```rust und ``` beginnen bzw. beenden einen Block der als Rust-Code formattiert wird. Zusätzlich führt cargo test jetzt einen Doc-Test aus, der diesen genau diesen Code ausführt. Dabei muss das public Interface des Crates verwendet werden, was uns dazu zwingt use my_package::add hinzuzufügen. Wenn wir den Import nicht in der finalen Dokumentation sehen möchten, da er beispielsweise eh kloar ist, können wir eine Raute # davor schreiben. Der Doc-Test funktioniert weiterhin, die extrahierte Dokumentation verfügt dann über einen Nebekriegsschauplatz weniger.

#![allow(unused)]
fn main() {
/// Adds two numbers
///
/// Example
/// ```rust
/// # use my_package::add;
/// let sum = add(2, 4);
/// assert_eq!(sum, 6);
/// ```
pub fn add(left: usize, right: usize) -> usize {
    left + right
}
}

Quiz

Fragen

  • Was ist ein Crate streng genommen und was wird oft unter einem Crate verstanden?
  • Wie viele Module hat ein Crate mindestens?
  • Wie viele Module befinden sich mindestens in einer Quelltextdatei?
  • Welchen Nachteil haben Tests, die im gleichen Modul leben, wie der zu testende Code?
  • Unter welchen Umständen kann ich auf das Feld x in struct MyStruct { x: i32 } zugreifen?
  • Unter welchen Umständen kann ich auf das Feld x in struct MyStruct { pub x: i32 } zugreifen?

Aufgabe

Dokumentiere den Typen VecMap, der im Quiz von Abschnitt 3 erstellt wurde und füge sinnvolle Doc-Tests hinzu, die sowohl die Funktionalität erklären als auch testen.


1: gemeine Rust Programmierer

2: .gitignore gibt an welche Dateien von Git ignoriert werden.