Zum Inhalt springen

Cargo-Features und bedingte Kompilierung (#[cfg]) in Rust

Stell dir vor, du bestellst eine Pizza. Jede Pizza bekommt den Teig und die Tomatensoße. Dann wählst du Extras: Pilze, extra Käse, Oliven. Die Küche macht nur, was du bestellt hast. Du zahlst (und wartest) also nicht für Beläge, die du gar nicht willst.

Rust-Code funktioniert genauso. Mit bedingter Kompilierung (#[cfg]) sagst du dem Compiler: „Bau diesen Teil nur unter Windows“ oder „nur in Tests“. Mit Cargo-Features (optionalen Funktionen) suchen sich die Leute, die dein Crate nutzen, die Extras aus, die sie brauchen – zum Beispiel JSON-Unterstützung. Code, den niemand auswählt, wird gar nicht erst kompiliert. Er bremst den Build nicht aus und landet nicht im fertigen Programm.

Diese Seite baut auf Cargo und Crates (Cargo.toml, cargo add) und Rust-Werkzeuge und Einrichtung auf. Wenn dir diese Begriffe neu sind, schau dort zuerst kurz vorbei.

#[cfg(...)] ist ein Attribut. Du setzt es über eine Funktion, ein Struct, eine use-Zeile, ein Modul oder sogar eine einzelne Anweisung. Ist die Bedingung wahr, bleibt der Code. Ist sie falsch, entfernt der Compiler ihn komplett – so, als hättest du ihn nie geschrieben.

#[cfg(target_os = "linux")]
fn os_name() -> &'static str {
"Linux"
}
#[cfg(target_os = "windows")]
fn os_name() -> &'static str {
"Windows"
}
#[cfg(target_os = "macos")]
fn os_name() -> &'static str {
"macOS"
}
fn main() {
println!("Hallo von {}!", os_name());
}

Ausgabe:

Hallo von Linux!
▶ Selbst ausprobieren

Der Rust Playground läuft unter Linux, deshalb gibt er Hallo von Linux! aus. Auf deinem eigenen Windows-Laptop gibt derselbe Code Hallo von Windows! aus. Drei Funktionen haben denselben Namen, aber das ist in Ordnung: Auf jedem Rechner bleibt nur eine davon übrig.

Hier lauert eine kleine Falle. Unter FreeBSD ist keine der drei Bedingungen wahr. Dann gibt es os_name gar nicht, und der Build bricht mit E0425 ab: „cannot find function os_name in this scope“ (die Funktion os_name wurde in diesem Gültigkeitsbereich nicht gefunden). In den nächsten Abschnitten siehst du, wie du einen Fallback einbaust.

Manchmal willst du keinen Code entfernen. Du willst nur eine Frage stellen. Das Makro cfg!() nimmt dieselben Bedingungen, gibt dir aber ein ganz normales bool zurück:

fn main() {
let bits = if cfg!(target_pointer_width = "64") { 64 } else { 32 };
println!("Das ist ein {bits}-Bit-Programm");
println!("Der größte usize ist {}", usize::MAX);
if cfg!(target_os = "windows") {
println!("Pfade sehen so aus: C:\\Users\\ana");
} else {
println!("Pfade sehen so aus: /home/ana");
}
}

Ausgabe:

Das ist ein 64-Bit-Programm
Der größte usize ist 18446744073709551615
Pfade sehen so aus: /home/ana
▶ Selbst ausprobieren

target_pointer_width ist die Größe einer Speicheradresse: "64" auf fast jedem modernen Rechner, "32" auf manchen kleinen Geräten. Das ist auch die Größe von usize. Deshalb ist usize::MAX hier so riesig.

Wann nimmst du also was?

#[cfg(...)] cfg!(...)
Was es macht Entfernt den Code, wenn die Bedingung falsch ist Wird zu true oder false
Wird der „falsche“ Code trotzdem kompiliert? Nein, er ist weg Ja, beide Zweige müssen überall kompilieren
Nimm es für Code, der woanders nicht kompilieren kann (APIs für nur ein Betriebssystem, optionale Crates) Kleine Unterschiede im Verhalten

Die mittlere Zeile ist wichtig. Dinge, die es nur unter Unix gibt, wie std::os::unix::fs::PermissionsExt, existieren unter Windows nicht. Code, der sie nutzt, muss deshalb hinter #[cfg(unix)] stehen. Ein if cfg!(unix) reicht nicht, denn der Compiler unter Windows würde diesen Zweig trotzdem kompilieren wollen.

Aus kleinen Bedingungen kannst du größere bauen:

  • not(a): wahr, wenn a falsch ist
  • any(a, b, ...): wahr, wenn mindestens eine wahr ist (wie „oder“)
  • all(a, b, ...): wahr, wenn alle wahr sind (wie „und“)

Hier ist noch einmal das Beispiel mit den Betriebssystemen, diesmal mit einem Fallback, damit es überall kompiliert:

#[cfg(any(target_os = "linux", target_os = "macos"))]
fn shell() -> &'static str {
"sh"
}
#[cfg(target_os = "windows")]
fn shell() -> &'static str {
"cmd"
}
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
fn shell() -> &'static str {
"unbekannt"
}
fn main() {
println!("Standard-Shell: {}", shell());
if cfg!(all(unix, target_pointer_width = "64")) {
println!("Ein 64-Bit-Unix-System");
}
}

Ausgabe:

Standard-Shell: sh
Ein 64-Bit-Unix-System
▶ Selbst ausprobieren

unix und windows sind praktische Kurznamen. unix ist wahr unter Linux, macOS, den BSDs und ähnlichen Systemen. Diesen Bedingungen begegnest du am häufigsten:

Bedingung Wahr, wenn …
target_os = "linux" / "windows" / "macos" du für dieses Betriebssystem baust
unix / windows du für ein Unix-artiges System / für Windows baust
target_arch = "x86_64" / "aarch64" / "wasm32" du für diese Art von Prozessor baust (WebAssembly ist wasm32)
target_pointer_width = "64" Adressen (und usize) 64 Bit groß sind
debug_assertions es ein Debug-Build ist (cargo build), kein --release
test du gerade cargo test ausführst
feature = "json" das Cargo-Feature json eingeschaltet ist (mehr dazu weiter unten)

Du willst die komplette Liste für deinen Rechner sehen? Führ rustc --print cfg aus. Auf einem 64-Bit-Linux-PC gibt das Zeilen wie target_os="linux", target_family="unix" und debug_assertions aus. #[cfg(windows)] und #[cfg(unix)] siehst du oft in FFI-Code, weil sich C-Bibliotheken zwischen den Systemen häufig unterscheiden.

Eine Bedingung hast du schon benutzt, ohne darüber nachzudenken. Jedes Testmodul auf der Seite Testen beginnt mit #[cfg(test)]:

pub fn add_points(total: u32, points: u32) -> u32 {
total + points
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn adds_points() {
assert_eq!(add_points(10, 5), 15);
}
}

cargo test schaltet test ein. Dann wird das Modul gebaut und seine Tests laufen. Ein normales cargo build lässt das ganze Modul weg, dein echtes Programm enthält also keinen Testcode. #[cfg] funktioniert so bei jedem Modul (siehe Module), zum Beispiel #[cfg(windows)] mod windows_paths;.

#[cfg_attr(bedingung, attribut)] bedeutet: „Wenn die Bedingung wahr ist, füge dieses Attribut hinzu.“ Ein typischer Fall ist ein Derive, das du nur manchmal haben willst:

#[cfg_attr(debug_assertions, derive(Debug))]
struct Player {
name: String,
score: u32,
}
fn main() {
let player = Player { name: "Ana".to_string(), score: 120 };
// nur in Debug-Builds; landet auf stderr, nicht auf stdout
#[cfg(debug_assertions)]
eprintln!("debug: {:?}", player);
println!("{} hat {} Punkte", player.name, player.score);
}

Ausgabe:

Ana hat 120 Punkte
▶ Selbst ausprobieren

In einem Debug-Build (der Standard im Playground) siehst du zusätzlich debug: Player { name: "Ana", score: 120 } im Bereich „Standard Error“ der Ausgabe. Stell den Playground auf „Release“ um, dann verschwindet diese Zeile – und mit ihr der Debug-Code für Player. Beachte, dass das eprintln! ein eigenes #[cfg(debug_assertions)] braucht. Ohne das schlägt ein Release-Build fehl, weil Player dort Debug nicht implementiert. Mehr über Debug- und Release-Builds findest du unter Cargo und Crates.

Jetzt zur Speisekarte. Ein Feature ist ein Schalter mit Namen, den du in Cargo.toml in einer Tabelle [features] festlegst. Bauen wir ein kleines Programm, pizza_order, mit zwei Features:

  • extra_cheese: ein kostenloses Extra, standardmäßig eingeschaltet.
  • json: gibt die Bestellung als JSON aus. Dafür braucht es die Crates serde und serde_json. Sie werden also nur heruntergeladen und kompiliert, wenn jemand json anfordert.
[package]
name = "pizza_order"
version = "0.1.0"
edition = "2024"
[dependencies]
serde = { version = "1", features = ["derive"], optional = true }
serde_json = { version = "1", optional = true }
[features]
default = ["extra_cheese"]
extra_cheese = []
json = ["dep:serde", "dep:serde_json"]

Zeile für Zeile:

  • optional = true heißt: „Nutze dieses Crate nur, wenn ein Feature danach fragt.“
  • extra_cheese = [] legt ein Feature an, das sonst nichts einschaltet. Es ist nur ein Schalter, den dein Code abfragen kann.
  • json = ["dep:serde", "dep:serde_json"] sagt: Wenn json eingeschaltet ist, schalte auch diese beiden optionalen Abhängigkeiten ein. dep: bedeutet: „Das ist eine Abhängigkeit, kein Feature-Name.“
  • default = [...] listet die Features auf, die eingeschaltet sind, solange niemand etwas anderes sagt.

Im Code fragst du ein Feature mit feature = "name" ab:

src/main.rs
#[cfg_attr(feature = "json", derive(serde::Serialize))]
struct Order {
size: String,
toppings: Vec<String>,
}
fn main() {
let mut toppings = vec!["Tomate".to_string(), "Basilikum".to_string()];
if cfg!(feature = "extra_cheese") {
toppings.push("extra Käse".to_string());
}
let order = Order { size: "große".to_string(), toppings };
println!("Bestellung: {} Pizza mit {}", order.size, order.toppings.join(", "));
#[cfg(feature = "json")]
println!("Als JSON: {}", serde_json::to_string(&order).unwrap());
}

Die JSON-Zeile nutzt #[cfg], nicht cfg!. Ohne das Feature json ist serde_json überhaupt keine Abhängigkeit, also muss diese Zeile komplett verschwinden. Die Käse-Abfrage kompiliert in jedem Fall, da reicht cfg!.

Jetzt bestellst du deine Pizza über die Kommandozeile. (-q blendet nur Cargos „Compiling…“-Zeilen aus.)

Terminal window
cargo run -q # nur die Standard-Features
cargo run -q --features json # Standard-Features + json
cargo run -q --no-default-features # keine Extras, nicht mal die Standard-Features
cargo run -q --all-features # alle Features

Das ist die echte Ausgabe:

$ cargo run -q
Bestellung: große Pizza mit Tomate, Basilikum, extra Käse
$ cargo run -q --features json
Bestellung: große Pizza mit Tomate, Basilikum, extra Käse
Als JSON: {"size":"große","toppings":["Tomate","Basilikum","extra Käse"]}
$ cargo run -q --no-default-features
Bestellung: große Pizza mit Tomate, Basilikum
$ cargo run -q --all-features
Bestellung: große Pizza mit Tomate, Basilikum, extra Käse
Als JSON: {"size":"große","toppings":["Tomate","Basilikum","extra Käse"]}

Du kannst die Optionen kombinieren. --no-default-features --features json gibt dir JSON ohne den Käse. Mehrere Features trennst du mit Kommas: --features json,extra_cheese. Dieselben Optionen funktionieren auch bei cargo build, cargo check und cargo test.

Meistens stehst du auf der anderen Seite der Theke: Du nutzt Crates, die Features haben. Das hast du schon unter Cargo und Crates gesehen:

[dependencies]
serde = { version = "1", features = ["derive"] }
tokio = { version = "1", features = ["full"] }
serde_json = { version = "1", features = ["preserve_order"] }
  • Das Feature derive von serde gibt dir #[derive(Serialize, Deserialize)].
  • tokio hat keine Standard-Features. Für sich allein bringt es dir fast nichts. full schaltet alles ein: die Runtime, #[tokio::main], Timer, Netzwerk, Dateien und mehr. Zum Lernen ist das bequem. In einem echten Projekt kannst du weniger anfordern, zum Beispiel features = ["rt-multi-thread", "macros"].
  • preserve_order von serde_json behält die Schlüssel eines Objekts in der Reihenfolge, in der du sie eingefügt hast.

cargo add schreibt diese Zeilen für dich: cargo add tokio --features full.

Warum teilen Crates sich so auf? Weil jeder Teil, den du nicht nutzt, Code ist, den du nicht herunterladen, kompilieren oder ausliefern musst. Ein großes Crate wie tokio würde jedes kleine Projekt langsam bauen lassen, wenn es immer alles kompilieren würde. Features halten Kompilierzeiten und Programmgrößen klein.

Du kannst die Standard-Features eines Crates auch abschalten und dir nur das aussuchen, was du brauchst – mit default-features = false:

[dependencies]
serde_json = { version = "1", default-features = false, features = ["alloc"] }

Welche Features in deinem Projekt eingeschaltet sind, verrät dir Cargo:

Terminal window
cargo tree -e features --features json --depth 1

Für pizza_order gibt das aus:

pizza_order v0.1.0 (/home/ana/pizza_order)
├── serde feature "default"
├── serde feature "derive"
└── serde_json feature "default"

Ohne --depth 1 siehst du den ganzen Baum, auch die Features, die deine Abhängigkeiten in ihren Abhängigkeiten einschalten. Das ist lang, aber das beste Werkzeug, wenn du dich fragst: „Warum wird dieses Crate überhaupt kompiliert?“

Features sollten nur etwas hinzufügen. Ein Feature einzuschalten sollte nie eine Funktion entfernen oder ändern, was eine bestehende Funktion tut. Der Grund: Wenn zwei Crates in deinem Projekt serde_json nutzen und eines preserve_order anfordert, das andere aber nicht, kompiliert Cargo serde_json nur einmal – mit den Features von beiden. Jedes Crate muss also weiter funktionieren, wenn jemand anderes zusätzliche Features einschaltet.

Vermeide Features, die nicht gleichzeitig an sein dürfen. Ein Design wie „Feature postgres ODER Feature sqlite, aber nie beide“ geht kaputt, sobald zwei Crates unterschiedliche wählen. Und --all-features funktioniert dann auch nicht mehr. Besser sind Features, die alle gleichzeitig an sein können.

Dokumentiere deine Features. Schreib eine kurze Liste in deine README oder in die Doku deines Crates: den Namen des Features, was es hinzufügt und ob es standardmäßig an ist. Auf docs.rs hat jedes Crate eine Seite „Feature flags“ (zum Beispiel die von tokio), und viele Crates markieren Elemente mit „Available on crate feature … only“ (nur mit dem Feature … verfügbar).

Prüf jede Kombination in der CI. Code hinter einem Feature wird nur kompiliert, wenn dieses Feature an ist. Nimm diese Befehle in deine CI auf, damit sich dort nichts versteckt:

Terminal window
cargo check --all-features
cargo check --no-default-features
cargo test --all-features

Bei größeren Crates kann das Tool cargo-hack jedes Feature einzeln prüfen (cargo hack check --each-feature).

Ein Tippfehler im Feature-Namen. Wenn du #[cfg(feature = "jsno")] schreibst, kompiliert der Code trotzdem. Die JSON-Zeile wird nur nie ausgegeben, selbst mit --features json nicht. Zum Glück warnt dich aktuelles Rust:

warning: unexpected `cfg` condition value: `jsno`
--> src/main.rs:17:11
|
17 | #[cfg(feature = "jsno")]
| ^^^^^^^^^^------
| |
| help: there is a expected value with a similar name: `"json"`
|
= note: expected values for `feature` are: `default`, `extra_cheese`, and `json`
= help: consider adding `jsno` as a feature in `Cargo.toml`
= note: see <https://doc.rust-lang.org/nightly/rustc/check-cfg/cargo-specifics.html> for more information about checking conditional configuration
= note: `#[warn(unexpected_cfgs)]` on by default

Die Warnung sagt: Der Wert jsno ist unerwartet, es gibt einen mit ähnlichem Namen ("json"), und sie zählt die Features auf, die es wirklich gibt. Ignorier sie nicht. Auf der Kommandozeile ist Cargo strenger: cargo run --features jsno bricht mit error: the package 'pizza_order' does not contain this feature: jsno ab (das Paket hat dieses Feature nicht) und schlägt json vor.

Den Namen der Abhängigkeit statt des Features abfragen. Wegen dep:serde gibt es in pizza_order kein Feature namens serde. Schreibst du #[cfg_attr(feature = "serde", ...)], bekommst du dieselbe Warnung „unexpected cfg condition value“, diesmal für serde. Und dann schlägt cargo run --features json mit E0277 fehl: „the trait bound Order: serde::Serialize is not satisfied“ (Order implementiert serde::Serialize nicht), weil das Derive nie hinzugefügt wurde. Nimm den Namen aus [features]: feature = "json".

dep: vergessen. Sobald irgendein Feature dep:serde sagt, musst du für serde überall dep: verwenden. Füg ein neues Feature yaml = ["serde"] hinzu, und Cargo weigert sich, die Datei zu lesen:

error: failed to parse manifest at `/home/ana/pizza_order/Cargo.toml`
Caused by:
feature `yaml` includes `serde`, but `serde` is an optional dependency without an implicit feature
Use `dep:serde` to enable the dependency.

Cargo erklärt dir, dass serde eine optionale Abhängigkeit ohne implizites Feature ist, und bittet dich, dep:serde zu verwenden. Die Lösung ist yaml = ["dep:serde"]. Und wenn du dep: überall weglässt, legt Cargo stillschweigend für jede optionale Abhängigkeit ein öffentliches Feature an (hier würde dann --features serde funktionieren). Das ist der alte Stil. Mit dep: bleibt deine Feature-Liste sauber und bewusst gewählt.

Code, der nie kompiliert wird. Angenommen, du vertippst dich in der Zeile mit #[cfg(feature = "json")] und schreibst serde_json::to_strng. cargo check meldet, dass alles in Ordnung ist, denn diese Zeile wird standardmäßig nicht kompiliert. Erst cargo check --all-features findet den Fehler:

error[E0425]: cannot find function `to_strng` in crate `serde_json`
--> src/main.rs:18:42
|
18 | println!("Als JSON: {}", serde_json::to_strng(&order).unwrap());
| ^^^^^^^^
|
::: /home/ana/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/serde_json-1.0.154/src/ser.rs:2245:1
|
2245 | / pub fn to_string<T>(value: &T) -> Result<String>
2246 | | where
2247 | | T: ?Sized + Serialize,
| |__________________________- similarly named function `to_string` defined here
|
help: a function with a similar name exists
|
18 | println!("Als JSON: {}", serde_json::to_string(&order).unwrap());
| +

Der Compiler findet to_strng in serde_json nicht. Er zeigt dir, wo die ähnlich benannte Funktion to_string definiert ist, und schlägt sie als Korrektur vor. Genau deshalb gehört --all-features in deine CI.

1. Ein Konfigurationsordner für jedes Betriebssystem

Abschnitt betitelt „1. Ein Konfigurationsordner für jedes Betriebssystem“

Schreib eine Funktion config_dir(), die zurückgibt, wo eine Notizen-App ihre Einstellungen speichert: "~/.config/notes" unter Linux, "~/Library/Application Support/notes" unter macOS, r"%APPDATA%\notes" unter Windows und "./notes" auf allen anderen Systemen. Gib das Ergebnis in main aus. Tipp: eine Funktion pro Fall mit #[cfg(target_os = "...")] und not(any(...)) für den Fallback.

Lösung anzeigen
#[cfg(target_os = "linux")]
fn config_dir() -> &'static str {
"~/.config/notes"
}
#[cfg(target_os = "macos")]
fn config_dir() -> &'static str {
"~/Library/Application Support/notes"
}
#[cfg(target_os = "windows")]
fn config_dir() -> &'static str {
r"%APPDATA%\notes"
}
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
fn config_dir() -> &'static str {
"./notes"
}
fn main() {
println!("Die Einstellungen liegen in {}", config_dir());
}

Ausgabe:

Die Einstellungen liegen in ~/.config/notes
▶ Selbst ausprobieren

Auf jedem Rechner bleibt genau eine der vier Funktionen übrig, also findet main immer ein config_dir. Der Playground läuft unter Linux, deshalb siehst du den Linux-Pfad. Eine echte App würde ein Crate wie dirs nutzen, um diese Ordner zuverlässig zu finden.

Du hast ein kleines Programm leaderboard, das Punktestände ausgibt. Füg ein Feature export hinzu (standardmäßig aus), das serde_json einbindet und eine Funktion export_json bereitstellt. Mit --features export soll das Programm die Punktestände zusätzlich als JSON ausgeben. Tipp: Mach serde_json optional, nutze dep:serde_json im Feature und setz #[cfg(feature = "export")] sowohl über die Funktion als auch über die Zeile, die sie aufruft. Im Playground kannst du keine Cargo-Features einstellen. Probier diese Übung deshalb mit cargo new leaderboard auf deinem Rechner aus.

Lösung anzeigen
[package]
name = "leaderboard"
version = "0.1.0"
edition = "2024"
[dependencies]
serde_json = { version = "1", optional = true }
[features]
export = ["dep:serde_json"]
src/main.rs
struct Score {
name: &'static str,
points: u32,
}
#[cfg(feature = "export")]
fn export_json(scores: &[Score]) -> String {
let list: Vec<serde_json::Value> = scores
.iter()
.map(|s| serde_json::json!({ "name": s.name, "points": s.points }))
.collect();
serde_json::Value::Array(list).to_string()
}
fn main() {
let scores = [
Score { name: "Ana", points: 120 },
Score { name: "Bo", points: 95 },
];
for s in &scores {
println!("{}: {} Punkte", s.name, s.points);
}
#[cfg(feature = "export")]
println!("Export: {}", export_json(&scores));
}
$ cargo run -q
Ana: 120 Punkte
Bo: 95 Punkte
$ cargo run -q --features export
Ana: 120 Punkte
Bo: 95 Punkte
Export: [{"name":"Ana","points":120},{"name":"Bo","points":95}]

Es gibt keine Zeile default = [...], also ist export aus, solange du es nicht anforderst. Ohne das Feature wird serde_json nicht einmal heruntergeladen. Die Funktion und der Aufruf brauchen beide #[cfg], weil beide serde_json erwähnen.

  • #[cfg(bedingung)] behält Code nur, wenn die Bedingung wahr ist. Sonst wird er vor dem Kompilieren entfernt.
  • cfg!(bedingung) ist einfach ein bool. Beide Zweige müssen trotzdem auf jeder Plattform kompilieren.
  • Bedingungen kombinierst du mit not(..), any(..) und all(..). Häufig sind target_os, unix/windows, debug_assertions und test.
  • #[cfg_attr(bedingung, attribut)] fügt ein Attribut nur hinzu, wenn die Bedingung wahr ist.
  • Cargo-Features stehen in [features]. Nutze optional = true zusammen mit dep:name für optionale Crates und default = [...] für das, was standardmäßig an ist.
  • Features sollten nur etwas hinzufügen. Lass cargo check --all-features in der CI laufen und achte auf unexpected_cfgs-Warnungen.

Kurzer Check

1. Was passiert mit einer Funktion, die mit #[cfg(target_os = "windows")] markiert ist, wenn du unter Linux baust?

2. Warum reicht if cfg!(unix) { ... } nicht für Code, der eine API nutzt, die es nur unter Unix gibt?

3. Was macht json = ["dep:serde_json"] in Cargo.toml?

4. Du hast aus Versehen #[cfg(feature = "jsno")] geschrieben. Was passiert?

Punkte: 0 / 4