Aller au contenu

Features Cargo et compilation conditionnelle (#[cfg]) en Rust

Imaginez que vous commandez une pizza. Tout le monde a la pâte et la sauce tomate. Ensuite, vous choisissez les suppléments : champignons, fromage en plus, olives. La cuisine ne prépare que ce que vous avez demandé, donc vous ne payez pas (et n’attendez pas) pour des garnitures dont vous ne voulez pas.

Le code Rust fonctionne de la même façon. Avec la compilation conditionnelle (#[cfg]), vous dites au compilateur « ne compile cette partie que sous Windows » ou « seulement pendant les tests ». Avec les features de Cargo (fonctionnalités optionnelles), les personnes qui utilisent votre crate choisissent les suppléments dont elles ont besoin, comme la prise en charge du JSON. Le code qui n’est pas choisi n’est pas compilé du tout. Il ne ralentit pas la compilation et il n’est pas dans le programme final.

Cette page s’appuie sur Cargo et les crates (Cargo.toml, cargo add) et sur Les outils de Rust. Si ces mots sont nouveaux pour vous, jetez-y d’abord un coup d’œil rapide.

#[cfg(...)] est un attribut que vous placez sur une fonction, une struct, une ligne use, un module ou même une seule instruction. Si la condition est vraie, le code reste. Si elle est fausse, le compilateur le retire complètement, comme si vous ne l’aviez jamais écrit.

#[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!("Bonjour depuis {} !", os_name());
}

Sortie :

Bonjour depuis Linux !
▶ Essayez par vous-même

Le Rust Playground tourne sous Linux, il affiche donc Bonjour depuis Linux !. Sur votre propre ordinateur portable sous Windows, le même code affiche Bonjour depuis Windows !. Trois fonctions portent le même nom, mais ce n’est pas un problème : sur un ordinateur donné, une seule d’entre elles survit.

Attention, il y a un petit piège. Sous FreeBSD, aucune des trois conditions n’est vraie, donc os_name n’existe pas et la compilation s’arrête avec E0425 : « cannot find function os_name in this scope » (la fonction os_name est introuvable). Les sections suivantes montrent comment ajouter une solution de repli.

Parfois, vous ne voulez pas retirer de code. Vous voulez juste poser une question. La macro cfg!() accepte les mêmes conditions, mais elle vous renvoie un simple bool :

fn main() {
let bits = if cfg!(target_pointer_width = "64") { 64 } else { 32 };
println!("Ceci est un programme {bits} bits");
println!("Le plus grand usize vaut {}", usize::MAX);
if cfg!(target_os = "windows") {
println!("Les chemins ressemblent à C:\\Users\\ana");
} else {
println!("Les chemins ressemblent à /home/ana");
}
}

Sortie :

Ceci est un programme 64 bits
Le plus grand usize vaut 18446744073709551615
Les chemins ressemblent à /home/ana
▶ Essayez par vous-même

target_pointer_width est la taille d’une adresse mémoire : "64" sur presque tous les ordinateurs modernes, "32" sur certains petits appareils. C’est aussi la taille de usize, ce qui explique pourquoi usize::MAX est si grand ici.

Alors, quand utiliser l’un ou l’autre ?

#[cfg(...)] cfg!(...)
Ce qu’il fait Retire le code quand la condition est fausse Devient true ou false
Le code « faux » est-il encore compilé ? Non, il a disparu Oui, les deux branches doivent compiler partout
À utiliser pour Du code qui ne peut pas compiler ailleurs (API propres à un système, crates optionnelles) De petites différences de comportement

La ligne du milieu est importante. Les éléments propres à Unix, comme std::os::unix::fs::PermissionsExt, n’existent pas sous Windows. Le code qui les utilise doit donc se trouver derrière #[cfg(unix)]. Un if cfg!(unix) ne suffit pas, car le compilateur sous Windows essaierait quand même de compiler cette branche.

Vous pouvez construire de grandes conditions à partir de petites :

  • not(a) : vrai quand a est faux
  • any(a, b, ...) : vrai quand au moins une est vraie (comme « ou »)
  • all(a, b, ...) : vrai quand toutes sont vraies (comme « et »)

Voici de nouveau l’exemple des systèmes, cette fois avec une solution de repli pour qu’il compile partout :

#[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 {
"inconnu"
}
fn main() {
println!("Shell par défaut : {}", shell());
if cfg!(all(unix, target_pointer_width = "64")) {
println!("Un système Unix 64 bits");
}
}

Sortie :

Shell par défaut : sh
Un système Unix 64 bits
▶ Essayez par vous-même

unix et windows sont des raccourcis pratiques. unix est vrai sous Linux, macOS, les BSD et les systèmes similaires. Voici les conditions que vous rencontrerez le plus souvent :

Condition Vraie quand…
target_os = "linux" / "windows" / "macos" Vous compilez pour ce système d’exploitation
unix / windows Vous compilez pour n’importe quel système de type Unix / pour Windows
target_arch = "x86_64" / "aarch64" / "wasm32" Vous compilez pour ce type de processeur (WebAssembly correspond à wasm32)
target_pointer_width = "64" Les adresses (et usize) font 64 bits
debug_assertions C’est une compilation debug (cargo build), pas --release
test Vous lancez cargo test
feature = "json" La feature Cargo json est activée (on y revient plus bas)

Vous voulez voir la liste complète pour votre machine ? Lancez rustc --print cfg. Sur un PC Linux 64 bits, elle affiche des lignes comme target_os="linux", target_family="unix" et debug_assertions. Vous verrez souvent #[cfg(windows)] et #[cfg(unix)] dans le code FFI, car les bibliothèques C diffèrent souvent d’un système à l’autre.

Vous avez déjà utilisé une condition sans y penser. Chaque module de tests de la page Les tests commence par #[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 active test, donc le module est compilé et ses tests s’exécutent. Un cargo build normal laisse tout le module de côté, si bien que votre vrai programme ne contient aucun code de test. #[cfg] fonctionne de la même façon sur n’importe quel module (voir Les modules), par exemple #[cfg(windows)] mod windows_paths;.

#[cfg_attr(condition, attribut)] signifie : « si la condition est vraie, ajoute cet attribut ». Un usage courant est un derive que vous ne voulez que dans certains cas :

#[cfg_attr(debug_assertions, derive(Debug))]
struct Player {
name: String,
score: u32,
}
fn main() {
let player = Player { name: "Ana".to_string(), score: 120 };
// seulement en mode debug ; va vers stderr, pas vers stdout
#[cfg(debug_assertions)]
eprintln!("debug: {:?}", player);
println!("{} a {} points", player.name, player.score);
}

Sortie :

Ana a 120 points
▶ Essayez par vous-même

En mode debug (le mode par défaut du Playground), vous voyez aussi debug: Player { name: "Ana", score: 120 } dans la partie « Standard Error » de la sortie. Passez le Playground en « Release » et cette ligne disparaît, avec le code Debug de Player. Remarquez que le eprintln! a besoin de son propre #[cfg(debug_assertions)]. Sans lui, la compilation release échoue, car Player n’implémente pas Debug dans ce cas. Vous trouverez plus de détails sur les compilations debug et release dans Cargo et les crates.

Passons au menu des pizzas. Une feature est un interrupteur nommé que vous définissez dans Cargo.toml, dans une table [features]. Construisons un petit programme, pizza_order, avec deux features :

  • extra_cheese : un supplément gratuit, activé par défaut.
  • json : affiche la commande en JSON. Elle a besoin des crates serde et serde_json, qui ne sont donc téléchargées et compilées que si quelqu’un demande json.
[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"]

Ligne par ligne :

  • optional = true signifie « n’utilise pas cette crate, sauf si une feature la demande ».
  • extra_cheese = [] crée une feature qui n’active rien d’autre. C’est juste un interrupteur que votre code peut consulter.
  • json = ["dep:serde", "dep:serde_json"] dit : quand json est activée, active aussi ces deux dépendances optionnelles. dep: signifie « ceci est une dépendance, pas un nom de feature ».
  • default = [...] liste les features activées quand personne ne précise rien.

Dans le code, vous testez une feature avec feature = "nom" :

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(), "basilic".to_string()];
if cfg!(feature = "extra_cheese") {
toppings.push("fromage en plus".to_string());
}
let order = Order { size: "grande".to_string(), toppings };
println!("Commande : une {} pizza avec {}", order.size, order.toppings.join(", "));
#[cfg(feature = "json")]
println!("En JSON : {}", serde_json::to_string(&order).unwrap());
}

La ligne JSON utilise #[cfg], pas cfg!. Sans la feature json, serde_json n’est même pas une dépendance, donc cette ligne doit disparaître complètement. Le test du fromage compile sans problème dans tous les cas, donc cfg! suffit.

Maintenant, commandez votre pizza depuis la ligne de commande. (-q masque simplement les lignes « Compiling… » de Cargo.)

Terminal window
cargo run -q # features par défaut uniquement
cargo run -q --features json # par défaut + json
cargo run -q --no-default-features # rien en plus, pas même celles par défaut
cargo run -q --all-features # toutes les features

Voici la vraie sortie :

$ cargo run -q
Commande : une grande pizza avec tomate, basilic, fromage en plus
$ cargo run -q --features json
Commande : une grande pizza avec tomate, basilic, fromage en plus
En JSON : {"size":"grande","toppings":["tomate","basilic","fromage en plus"]}
$ cargo run -q --no-default-features
Commande : une grande pizza avec tomate, basilic
$ cargo run -q --all-features
Commande : une grande pizza avec tomate, basilic, fromage en plus
En JSON : {"size":"grande","toppings":["tomate","basilic","fromage en plus"]}

Vous pouvez combiner les options. --no-default-features --features json donne le JSON sans le fromage. Pour plusieurs features, séparez-les par des virgules : --features json,extra_cheese. Les mêmes options fonctionnent avec cargo build, cargo check et cargo test.

La plupart du temps, vous êtes de l’autre côté du comptoir : vous utilisez des crates qui ont des features. Vous l’avez déjà vu dans Cargo et les crates :

[dependencies]
serde = { version = "1", features = ["derive"] }
tokio = { version = "1", features = ["full"] }
serde_json = { version = "1", features = ["preserve_order"] }
  • La feature derive de serde vous donne #[derive(Serialize, Deserialize)].
  • tokio n’a aucune feature par défaut. Seule, elle ne vous donne presque rien. full active tout : le runtime, #[tokio::main], les minuteurs, le réseau, les fichiers et plus encore. C’est pratique pendant l’apprentissage. Dans un vrai projet, vous pouvez en demander moins, par exemple features = ["rt-multi-thread", "macros"].
  • preserve_order de serde_json garde les clés des objets dans l’ordre où vous les avez insérées.

cargo add écrit ces lignes pour vous : cargo add tokio --features full.

Pourquoi les crates sont-elles découpées ainsi ? Parce que chaque partie que vous n’utilisez pas, c’est du code que vous n’avez pas à télécharger, à compiler ni à livrer. Une grosse crate comme tokio rendrait la compilation de chaque petit projet très lente si elle compilait toujours tout. Les features réduisent les temps de compilation et la taille des binaires.

Vous pouvez aussi désactiver les features par défaut d’une crate et ne choisir que ce dont vous avez besoin, avec default-features = false :

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

Pour voir quelles features sont activées dans votre projet, demandez à Cargo :

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

Pour pizza_order, cela affiche :

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

Sans --depth 1, vous voyez l’arbre complet, y compris les features que vos dépendances activent dans leurs propres dépendances. C’est long, mais c’est le meilleur outil quand vous vous demandez « pourquoi cette crate est-elle compilée ? ».

Les features ne devraient qu’ajouter des choses. Activer une feature ne devrait jamais retirer une fonction ni changer ce que fait une fonction existante. Voici pourquoi. Si deux crates de votre projet utilisent toutes les deux serde_json, et que l’une demande preserve_order alors que l’autre non, Cargo compile serde_json une seule fois, avec les features des deux activées. Chaque crate doit donc continuer à fonctionner quand quelqu’un d’autre active des features en plus.

Évitez les features qui ne peuvent pas être activées ensemble. Une conception du type « feature postgres OU feature sqlite, mais jamais les deux » casse dès que deux crates en choisissent une différente, et --all-features ne fonctionne plus. Préférez des features qui peuvent toutes être activées en même temps.

Documentez vos features. Écrivez une courte liste dans votre README ou dans la documentation de votre crate : le nom de la feature, ce qu’elle ajoute et si elle est activée par défaut. Sur docs.rs, chaque crate a une page « Feature flags » (par exemple celle de tokio), et beaucoup de crates marquent certains éléments avec « Available on crate feature … only » (disponible seulement avec la feature …).

Vérifiez toutes les combinaisons en CI. Le code derrière une feature n’est compilé que si cette feature est activée. Ajoutez ces commandes à votre CI pour que rien ne s’y cache :

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

Pour les crates plus grosses, l’outil cargo-hack peut vérifier chaque feature séparément (cargo hack check --each-feature).

Une faute de frappe dans un nom de feature. Si vous écrivez #[cfg(feature = "jsno")], le code compile quand même. La ligne JSON ne s’affiche simplement jamais, même avec --features json. Heureusement, les versions actuelles de Rust vous préviennent :

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: `#[warn(unexpected_cfgs)]` on by default

L’avertissement dit que la valeur jsno est inattendue, qu’il en existe une au nom proche ("json"), et quelles features existent vraiment. Ne l’ignorez pas. En ligne de commande, Cargo est plus strict : cargo run --features jsno s’arrête avec error: the package 'pizza_order' does not contain this feature: jsno (le paquet n’a pas cette feature) et vous suggère json.

Tester le nom de la dépendance au lieu du nom de la feature. À cause de dep:serde, il n’existe aucune feature appelée serde dans pizza_order. Si vous écrivez #[cfg_attr(feature = "serde", ...)], vous obtenez le même avertissement « unexpected cfg condition value », cette fois pour serde. Ensuite, cargo run --features json échoue avec E0277 : « the trait bound Order: serde::Serialize is not satisfied » (Order n’implémente pas serde::Serialize), parce que le derive n’a jamais été ajouté. Utilisez le nom qui figure dans [features] : feature = "json".

Oublier dep:. Dès qu’une feature écrit dep:serde, vous devez utiliser dep: pour serde partout. Ajoutez une nouvelle feature yaml = ["serde"] et Cargo refuse de lire le fichier :

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 vous explique que serde est une dépendance optionnelle sans feature implicite, et vous demande d’utiliser dep:serde. La correction est yaml = ["dep:serde"]. Et si vous n’écrivez dep: nulle part, Cargo crée discrètement une feature publique pour chaque dépendance optionnelle (ici, --features serde fonctionnerait). C’est un style ancien. Utiliser dep: garde votre liste de features claire et explicite.

Du code qui n’est jamais compilé. Imaginons que vous tapiez serde_json::to_strng par erreur dans la ligne #[cfg(feature = "json")]. cargo check dit que tout va bien, car cette ligne n’est pas compilée par défaut. Seul cargo check --all-features trouve l’erreur :

error[E0425]: cannot find function `to_strng` in crate `serde_json`
--> src/main.rs:18:42
|
18 | println!("En JSON : {}", serde_json::to_strng(&order).unwrap());
| ^^^^^^^^
|
help: a function with a similar name exists
|
18 | println!("En JSON : {}", serde_json::to_string(&order).unwrap());
| +

Le compilateur ne trouve pas to_strng dans serde_json et vous propose to_string. C’est exactement pour cette raison que --all-features a sa place dans votre CI.

1. Un dossier de configuration pour chaque système

Section intitulée « 1. Un dossier de configuration pour chaque système »

Écrivez une fonction config_dir() qui renvoie l’endroit où une application de notes range ses réglages : "~/.config/notes" sous Linux, "~/Library/Application Support/notes" sous macOS, r"%APPDATA%\notes" sous Windows, et "./notes" sur tous les autres systèmes. Affichez le résultat dans main. Indice : une fonction par cas avec #[cfg(target_os = "...")], et not(any(...)) pour la solution de repli.

Voir la solution
#[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!("Les réglages sont enregistrés dans {}", config_dir());
}

Sortie :

Les réglages sont enregistrés dans ~/.config/notes
▶ Essayez par vous-même

Sur n’importe quel ordinateur, exactement une des quatre fonctions survit, donc main trouve toujours un config_dir. Le Playground tourne sous Linux, vous voyez donc le chemin Linux. Une vraie application utiliserait une crate comme dirs pour trouver ces dossiers correctement.

Vous avez un petit programme leaderboard qui affiche des scores. Ajoutez une feature export (désactivée par défaut) qui fait venir serde_json et ajoute une fonction export_json. Avec --features export, le programme doit aussi afficher les scores en JSON. Indice : rendez serde_json optionnelle, utilisez dep:serde_json dans la feature, et mettez #[cfg(feature = "export")] à la fois sur la fonction et sur la ligne qui l’appelle. Le Playground ne permet pas de choisir les features Cargo, alors essayez celui-ci sur votre machine avec cargo new leaderboard.

Voir la solution
[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!("{} : {} points", s.name, s.points);
}
#[cfg(feature = "export")]
println!("Export : {}", export_json(&scores));
}
$ cargo run -q
Ana : 120 points
Bo : 95 points
$ cargo run -q --features export
Ana : 120 points
Bo : 95 points
Export : [{"name":"Ana","points":120},{"name":"Bo","points":95}]

Il n’y a pas de ligne default = [...], donc export est désactivée tant que vous ne la demandez pas. Sans elle, serde_json n’est même pas téléchargée. La fonction et l’appel ont tous les deux besoin de #[cfg], car chacun d’eux mentionne serde_json.

  • #[cfg(condition)] ne garde le code que si la condition est vraie. Sinon, il est retiré avant la compilation.
  • cfg!(condition) n’est qu’un bool. Les deux branches doivent quand même compiler sur toutes les plateformes.
  • Combinez des conditions avec not(..), any(..) et all(..). Les plus courantes sont target_os, unix/windows, debug_assertions et test.
  • #[cfg_attr(condition, attribut)] ajoute un attribut seulement si la condition est vraie.
  • Les features Cargo se trouvent dans [features]. Utilisez optional = true avec dep:nom pour les crates optionnelles, et default = [...] pour ce qui est activé par défaut.
  • Les features ne devraient qu’ajouter des choses. Lancez cargo check --all-features en CI et surveillez les avertissements unexpected_cfgs.

Petit quiz

1. Que devient une fonction marquée #[cfg(target_os = "windows")] quand vous compilez sous Linux ?

2. Pourquoi if cfg!(unix) { ... } ne suffit-il pas pour du code qui utilise une API propre à Unix ?

3. Dans Cargo.toml, que fait json = ["dep:serde_json"] ?

4. Vous avez écrit #[cfg(feature = "jsno")] par erreur. Que se passe-t-il ?

Score : 0 / 4