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 surtout pub(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}");
}