Features et cfg()

Dans un chapitre précédent, nous avons mentionné que les crates peuvent avoir des fonctionnalités optionnelles appelées features. Dans ce chapitre, nous allons explorer les mécanismes de compilation conditionnelle de Rust, qui permettent de gérer ces fonctionnalités optionnelles ainsi que les quelques parties du code qui dépendent du matériel/OS hôte.

Options de configuration

Le compilateur Rust expose un certain nombre d’options de configuration qui permettent de connaître l’architecture CPU cible (ex : x86_64, aarch64…), les fonctionnalités CPU optionnelles activées à la compilation (ex : avx, fp16), le système d’exploitation cible (ex : linux, windows), les opérations atomiques suportées, les features activées via Cargo, etc.

La façon la plus simple d’interroger ces options de compilation est la macro cfg!(), qui prend en paramètre une option de configuration et retourne true si cette option est activée et false sinon :

#![allow(unused)]
fn main() {
// Test de l'OS cible
let os = if cfg!(target_os = "linux") {
    "Linux"
} else if cfg!(target_os = "macos") {
    "macOS"
} else if cfg!(target_os = "windows") {
    "Windows"
} else {
    "autre chose"
};
println!("J'ai été compilé pour {os}");
}

Cependant, cette macro ne suffit pas à répondre à tous les besoins de compilation conditionnelle. En effet, en dehors de la macro cfg!(), le code ci-dessus reste analysé comme d’habitude par le compilateur, en compilant toutes les branches du if.

On ne pourrait donc pas utiliser dans ces différentes branches des fonctions spécifiques à Linux comme io_uring_enter() ou des fonctions spécifiques à Windows comme GetQueuedCompletionStatus(), car la compilation échouerait sur les autres systèmes d’exploitation où ces fonctions n’existent pas :

#![allow(unused)]
fn main() {
// On vérifie si la dépendance optionnelle "schmilblik" est activée
if cfg!(feature = "schmilblik") {
    // ERREUR : La dépendance optionnelle "schmilblik" n'est pas activée,
    //          mais ce code qui l'utilise est compilé quand même.
    use schmilblik::COULEUR;
    println!("Le schmilblik est {COULEUR}");
}
}

A la place, on doit utiliser une approche plus radicale qui amène le code spécifique à être complètement ignoré par le compilateur.

L’attribut #[cfg()]

L’attribut #[cfg()] peut être appliqué à toutes sortes d’éléments du code : imports use x::y::z;, déclarations de types et de fonctions, blocs d’instructions…

Comme la macro cfg!(), cet attribut prend en paramètre une option de compilation. Si l’option de compilation est activée, l’attribut n’a aucun effet. Mais si l’option de compilation n’est pas activée, le code auquel l’attribut #[cfg()] s’applique est supprimé du code source.

Comme la directive préprocesseur #ifdef en C/++, cet attribut est donc appliqué au code spécifique à un matériel, un système d’exploitation… pour assurer que ce code ne soit pas compilé quand ses conditions de bon fonctionnement ne sont pas remplies :

#![allow(unused)]
fn main() {
// Ouverture d'un fichier en écriture (cf chapitre système)
use std::fs::File;
let fichier = File::create("/tmp/test.txt")
                   .expect("Echec de création du fichier");

// Instructions spécifiques aux systèmes Unix (Linux, macOS...)
#[cfg(unix)]
{
    use std::os::unix::io::AsRawFd;
    let fd = fichier.as_raw_fd();
    println!("On utilise le descripteur de fichier numéro {fd}");
}
}

Il est courant de vouloir appliquer un même attribut #[cfg()] à plusieurs déclarations. La façon la plus courante de faire est de grouper l’ensemble de ces déclarations au sein d’un même module, auquel on applique l’attribut #[cfg()] :

#![allow(unused)]
fn main() {
#[cfg(unix)]
pub mod signals {
    use std::ffi::c_int;

    pub const SIGHUP: c_int = 1;
    pub const SIGINT: c_int = 2;
    pub const SIGQUIT: c_int = 3;
    /* ... et ainsi de suite ... */
}
}

Opérations logiques

Le fait que #[cfg()] soit un attribut a pour conséquence fâcheuse qu’on ne peut pas utiliser des opérations booléennes standard quand on interroge les options de compilation. On n’a même pas d’équivalent au #else du préprocesseur C/++.

A la place, il faut utiliser une syntaxe dédiée qui devient très rapidement désagréable :

#![allow(unused)]
fn main() {
// Sélection de code selon qu'on est sous Windows ou pas
#[cfg(windows)]
println!("On est sous Windows");
#[cfg(not(windows))]  // Notez la répétition de la condition
println!("On n'est pas sous Windows");

// Test que nous sommes sous Linux et utilisons la glibc (équivalent à &&)
#[cfg(all(target_os = "linux", target_env = "gnu"))]
println!("On est sous Linux et on utilise la glibc");

// Test qu'on utilise des pointeurs 32 ou 64 bits (équivalent à ||)
#[cfg(any(target_pointer_width = "32", target_pointer_width = "64"))]
println!("On utilise des pointeurs 32 ou 64 bits");
}

Des crates comme cfg_if ou cfg_aliases s’emploient donc à améliorer l’ergonomie de cfg() de différentes manières, afin de se rapprocher de l’ergonomie du préprocesseur C sans ramener tous les inconvénients dudit préprocesseur au passage : séquences if ... else if ... else, définition d’aliases courts pour éviter de répéter des expressions compliquées dans le code, etc.

L’attribut #[cfg_attr()]

Une deuxième conséquence du fait que #[cfg()] soit un attribut est qu’il y a besoin d’une syntaxe dédiée pour appliquer un attribut de façon conditionnelle.

Prenons par exemple l’attribut windows_subsystem. Il s’applique à l’intégralité d’une crate exécutable, et il indique si l’application est destinée à s’exécuter en ligne de commande ou de façon graphique, afin que Windows décide si il doit ouvrir un terminal ou pas lorsqu’on lance l’application depuis l’explorateur de fichiers. C’est donc une notion extrêmement spécifique à Windows, et on a besoin d’utiliser la compilation conditionnelle pour que cet attribut ne soit pas utilisé quand on compile pour d’autres OSes comme Linux et macOS.

On utilise pour ça cfg_attr(configuration, attribut), qui indique qu’un attribut doit être appliqué si et seulement si une option de configuration est activée :

// Notez la syntaxe #![] qui signifie que l'attribut s'applique à l'entité
// englobante, ici la crate entière.
#![cfg_attr(windows, windows_subsystem = "windows")]

// Autre exemple d'utilisation. Il est bien connu que les utilisateurs macOS
// n'ont pas besoin de Debug car leurs applications ne plantent jamais ;)
#[cfg_attr(
    not(target_os = "macos"),
    derive(Debug)
)]
struct Buggy;

fn main() {}