Modules
Introduction
Jusqu’à présent, nos exemples tenaient bien en un seul fichier de code Rust. Mais avec l’introduction du parallélisme dans le chapitre sur les threads, on se rapprochait de la limite du raisonnable.
Il est donc temps d’aborder le système de modules de Rust, qui sert à…
- Grouper nos déclarations en ensembles logiques
- Encapsuler les détails d’implémentation des fonctionnalités
En revanche contrairement à ce qui se passe avec les fichiers source C++, un module Rust n’est pas une unité de compilation. Ca, c’est la fonction des crates, que nous allons aborder un peu plus tard.
Cela signifie que la façon dont nous décidons d’organiser notre code en modules n’affecte pas les décisions d’optimisation du compilateur. Nous n’avons donc besoin…
- Ni de choisir entre organisation logique et performances d’exécution
- Ni de tout fourrer dans une seule unité de compilation, ce qui ne passe pas à l’échelle dans les gros programmes (pas de parallélisation dans GCC et clang actuellement, et le temps de compilation et la consommation mémoire explosent vite avec la combinatoire).
- Ni d’avoir un recours systématique à l’optimisation à l’édition de liens, qui impacte fortement le temps de compilation avec une efficacité aléatoire.
Tout ceci est un gros point positif par rapport au modèle de compilation du C++.
En revanche, il y a un prix à payer, qui est qu’un programme Rust idiomatique a beaucoup moins d’unités de compilation qu’un programme C++ classique, et peut donc moins paralléliser la compilation en distribuant le travail entre unités de compilation. La compilation parallèle de code Rust doit donc utiliser des mécanismes plus sophistiqués de parallélisation automatique à l’intérieur d’une unité de compilation. Mais heureusement, le compilateur sait plutôt bien faire ça pour vous, et la plupart du temps vous n’aurez pas à vous préoccuper de ce détail d’implémentation.
Création d’un module
On peut d’abord créer un module directement au sein d’un fichier de code. C’est très pratique quand on fait de la compilation conditionnelle, pour grouper les différentes déclarations qui dépendent d’une même condition. C’est par ailleurs aussi utile pour contourner les limite techniques des exemples exécutables de ce cours, qui ne peuvent contenir qu’un fichier de code unique :
#![allow(unused)] fn main() { mod module { /* ... déclarations ... */ } }
En dehors des cas particuliers mentionnés ci-dessus, la façon normale de créer
un module est d’ajouter une déclaration mod module;
puis…
- Soit créer un fichier
module.rs
qui contient l’implémentation du module. - Soit créer un dossier “module”, contenant un fichier
mod.rs
qui contient lui-même l’implémentation du module.
La deuxième manière de faire sert lorsqu’on veut créer des sous-modules au sein des modules que nous avons créé. Il est facile, et usuel quand la complexité du code croît au fil du temps, de passer de la première à la seconde configuration au moment où le besoin s’en fait sentir.
Contrairement aux modules C++20, il y a un lien simple entre noms de modules et hiérarchie du système de fichiers. Cela clarifie le code, et surtout simplifie énormément l’implémentation du compilateur : contrairement à l’implémentation des modules C++20 de GCC, le compilateur Rust n’a pas besoin d’exécuter un serveur IPv6 parlant un protocole maison en tâche de fond pour savoir à quel nom de fichier correspond un nom de module dans le code…
Visibilité
Par défaut, toutes les déclarations d’un module sont privées et ne peuvent pas être utilisées de l’extérieur, donc ce code ne compile pas :
#![allow(unused)] fn main() { mod module { static VALEUR: u32 = 42; } println!("{}", module::VALEUR); }
Pour qu’il compile, il faut rendre la déclaration publique avec le mot-clé pub
:
#![allow(unused)] fn main() { mod module { pub static VALEUR: u32 = 42; } println!("{}", module::VALEUR); }
Cela ne concerne pas que les entités directement déclarées au sein du module,
mais aussi leur structure interne, comme les membres de struct
:
#![allow(unused)] fn main() { mod module { pub struct S { pub x: u32, y: f32, } impl S { pub fn new() -> S { S { x: 42, y: 2.4 } } } } let s = module::S::new(); // Ok, constructeur public println!("{}", s.x); // Ok, membre public /* println!("{}", s.y); */ // Erreur, membre privé }
Notez qu’il n’est pas nécessaire que le module soit public pour qu’on puisse
accéder à son contenu. Tout ce qui est défini au sein du module actif est
visible, public ou pas, y compris les sous-modules. Le mot-clé pub
n’affecte
que la visibilité depuis l’extérieur du module.
Il est possible de nuancer une déclaration de visibilité avec des variantes du
pub
comme…
pub(super)
, qui ne rend une déclaration visible qu’au sein du module parent.pub(crate)
, qui rend une déclaration visible au sein de la crate (~bibliothèque) active, mais pas pour les clients extérieurs à la crate.pub(in <chemin>)
, qui rend une déclaration visible dans un module bien précis du programme, à l’exclusion de tous les autres.
Cependant, d’un point de vue de couplage entre vos modules de code, j’aurais tendance à dire que…
- L’idéal est d’avoir uniquement une distinction
pub
/non-pub
bien claire. pub(crate)
est un compromis acceptable pour des détails communs à l’ensemble du code d’une bibliothèque, comme l’interface FFI dans un binding.pub(super)
et surtoutpub(in)
sont suspects et doivent vous amener à vous poser des questions sur la qualité du découpage de votre code en modules indépendants.
Chemins et import
Dans les exemples ci-dessus, vous avez pu voir que les modules se comportent un
peu comme des namespaces en C++. On peut désigner une entité enfant du module
actif au sein de la hiérarchie des modules avec des chemins relatifs du style
chemin::vers::<entité>
:
#![allow(unused)] fn main() { mod A { pub mod B { pub mod C { pub static VALEUR: u32 = 42; } } } println!("{}", A::B::C::VALEUR); }
…et on peut importer des entité au sein du scope actuel avec la syntaxe
use chemin::vers::<entité>
, qui peut prendre plusieurs noms d’entité en
paramètre pour plus de concision :
#![allow(unused)] fn main() { mod A { pub mod B { pub mod C { pub static X: u32 = 42; pub static Y: u32 = 24; } } } use A::B::C::{X, Y}; println!("{X} {Y}"); }
Toutes les bibliothèques (crates) dont dépend le programme sont également
visibles depuis tous les modules du programme. C’est pourquoi nous avons pu
faire des use std::xyz
depuis le début.
Il n’aura pas échappé aux personnes attentives qu’il peut donc exister une collision entre le nom d’une entité déclarée au sein du programme actif et le nom d’une bibliothèque. Il existe des syntaxes pour contourner ces collisions, mais pour garder le code lisible, je vous encourage fortement à ne pas vous en servir. Evitez de créer volontairement des collisions, et résolvez-les en renommant l’entité sous votre contrôle si ça se produit après coup.
Pour conclure, au sein d’un sous-module, on peut aussi désigner le module parent
par super::
et la racine du code source de la crate active avec
crate::
:
mod A { pub mod B { pub mod C { pub static X: u32 = super::Y; } pub static Y: u32 = crate::Z; } } pub const Z: u32 = 42; fn main() { use A::B::{Y, C::X}; println!("{X} {Y} {Z}"); }