Les crates avec Cargo

On l’a évoqué précédemment, les modules de Rust permettent d’organiser son code et de créer des barrières d’encapsulation, mais ils ne sont pas une unité de compilation. Ce qui joue ce rôle en Rust, ce sont les crates, un concept général qui regroupe tous les produits possibles de la compilation.

Que vous soyez en train de travailler sur un programme ou une bibliothèque, du point de vue de Rust, le source associé est composé d’une ou plusieurs crates, chacune de ces crates est traitée comme une unité de compilation, et le but du processus de compilation est de transformer chaque crate en un objet binaire du bon type (exécutable, archive statique, shared object/DLL…).

Rôle de Cargo

Si vous avez installé un environnement de développment Rust en local, alors vous avez d’ors et déjà créé une ou plusieurs crates avec la commande cargo new.

En Rust, la façon recommandée de créer, gérer et compiler des crates est d’utiliser Cargo. Il s’agit d’un outil qui remplit plusieurs fonctions normalement assurées par des outils séparés dans l’environnement de développement C++ traditionnel :

  • Il permet de sélectionner de bonnes versions des dépendances de votre projet, les télécharger et les compiler automatiquement, à la manière de Spack et des gestionnaires de paquets plus orientés systèmes d’exploitation (APT, DNF, Zypper, Brew, portage…).
  • Il permet de configurer le processus de compilation, à la manière de CMake et Meson.
  • Il détecte ce qui doit être (re-)compilé, dans quel ordre, et gère l’ordonnancement et l’exécution du processus de compilation parallèle, à la manière de GNU Make et Ninja.
  • Il s’interface avec les fonctionnalités de documentation (analogues à Doxygen), de test unitaire et de benchmarking du compilateur Rust pour permettre de lancer tout ça facilement.
  • Il s’interface également avec les outils d’analyse statique rustfmt et clippy fournis avec le compilateur Rust pour fournir du formatage automatique et des lints plus poussés.

Grâce à cette centralisation des fonctionnalités, et à certains choix de conception plus heureux que ceux de la pile C++ traditionnelle (configuration déclarative, accent sur l’édition de lien statique et la réduction des dépendances vis à vis des bibliothèques/outils de l’OS hôte…), cargo rend le processus de compilation typique beaucoup plus simple et sans bavure que ce à quoi on est habitué en C++.

Ainsi, lorsqu’on compile un programme ou une bibliothèque Rust, la norme est que ça fonctionne du premier coup, sans aucune étape préparatoire, en une simple commande cargo build ou cargo install. Et les rares fois où l’on rencontre des problèmes, ils sont quasiment toujours liés aux dépendances C/++ auxquelles on n’a pas réussi à se soustraire, comme openSSL ou HDF5. Cela peut expliquer en partie, sans l’excuser, la tendance souvent observée des personnes nouvellement formées à Rust à vouloir réécrire l’intégralité de leurs dépendances dans ce langage.

Utilisation de Cargo

Nous avons déjà présenté brièvement l’utilisation de Cargo dans le chapitre sur l’installation d’un environnement de développement local, il est maintenant temps de détailler un peu plus. Voici un petit tour d’horizon des principales commandes disponibles après avoir fraîchement installé un environnement de développement Rust via rustup :

  • cargo new permet de créer une nouvelle crate avec un squelette de code et une configuration minimale. Par défaut on crée un exécutable, l’option --lib permet de créer une bibliothèque.
  • Un grand nombre de commandes permettent de construire et exécuter des binaires :
    • cargo build compile le programme sans l’exécuter.
    • cargo run compile le programme, puis l’exécute.
    • cargo test compile et exécute les tests unitaires, d’intégration, et les exemples de la documentation intégrée au code.
    • cargo bench compile et exécute les microbenchmarks du code.
  • Plusieurs outils d’analyse statique sont intégrés d’office :
    • cargo check vérifie que le programme passe les vérifications de typage et les lints du compilateur, sans essayer de construire un binaire.
    • cargo fix permet d’appliquer automatiquement les suggestions fournies par certaines lints de cargo check à votre code.
    • cargo clippy applique des lints plus agressives, qui voient des choses que les lints de base ne voient pas mais au prix d’un taux de faux positifs plus élevé.
    • cargo fmt formate votre code selon des normes communément admises par la communauté Rust.
    • cargo doc génère automatiquement une documentation HTML de référence à partir de commentaires spéciaux dans votre code, à la manière de Doxygen.
  • D’autres commandes relèvent plutôt de la gestion de paquets, elles fonctionnent par défaut en utilisant le dépôt public de paquets crates.io.

En sus de ça, cargo dispose d’un système de plug-ins qui permettent d’ajouter d’autres sous-commandes cargo. On peut par exemple mentionner…

  • cargo-outdated pour visualiser l’intégralité des mises à jour de dépendances disponibles, y compris celles qui changent d’API et nécessitent des adaptations manuelles.
  • cargo-criterion pour exécuter plus efficacement des microbenchmarks basés sur criterion (l’analyse est centralisée au lieu d’être dupliquée dans chaque benchmark).
  • cargo-show-asm pour visualiser l’assembleur de ses fonctions quand on optimise son code.
  • cargo-miri qui analyse dynamiquement le code unsafe en vérifiant l’absence de comportement indéfini, à la manière de Valgrind et des sanitizers en C++.
  • cargo-llvm-lines qui permet de détecter le code bloat associé aux fonctions génériques quand on abuse du polymorphisme statique, pour éliminer plus facilement les désagréments associés (compilation lente, gros binaires…).

Configuration de Cargo

A la racine du code source de votre projet, cargo new crée un fichier Cargo.toml qui permet de configurer le comportement de Cargo. On peut par exemple y spécifier…

  • Des options de compilation (ex : activer les informations de déboguage en mode release pour pouvoir profiler son code avec perf ou VTune).
  • Des métadonnées (ex : nom d’auteur, contact, version, licence…) qui sont par exemple utilisées quand on publie son projet sur crates.io.
  • Des fonctionnalités optionnelles (features) que l’utilisateur peut choisir d’activer ou non au moment de la compilation.
  • La liste de ces dépendances, même si celle-ci peut aussi être gérée avec cargo add et remove.
    • Les dépendances peuvent être déclarées comme optionnelles, auquel cas cela crée automatiquement une feature qui porte le même nom. Une feature déclarée manuellement peut aussi activer des dépendences optionnelles. Plus d’informations ici.

Pour plus d’informations sur ce qui peut être configuré, et sur les autres façons possibles de le configurer (notamment via des fichiers dans son dossier personnel ou des variables d’environnement), ainsi que sur d’autres fonctionnalités plus avancées que je ne couvre pas dans cette introduction (gestion conjointe d’un ensemble de crates via les workspaces, profilage de la compilation…), je vous invite à consulter le manuel de Cargo.