Génération de code

Introduction

La génération de code fait partie de ces pratiques que tout le monde aime mépriser, mais qui font inévitablement leur entrée dès qu’un code dépasse une certaine taille.

Les concepteurs de langages de programmation sont particulièrement en froid avec elle, car elle est souvent utilisée pour contourner des limites du langage qui conduisent à un code stupide et redondant, ce qu’ils tendent à prendre comme un reproche et une incitation à améliorer le langage plutôt que son support de la génération de code.

Pourtant, la génération de code a encore de nombreuses applications aujourd’hui, par exemple…

  • L’implémentation automatique de comportements par défaut (comme derive() en Rust).
  • La sérialisation de données dans des formats standardisés, et la reconstruction de données typées depuis ces formats, avec gestion des erreurs de typage.
  • La génération automatique de schémas de bases de données et de requêtes SQL à partir d’une représentation du modèle de données dans le système de types du langage.
  • La production de traces d’exécution indiquant les valeurs d’entrées et de sorties d’une fonction à chaque fois que celle-ci est appelée.

En C++, le support de la génération de code se limite aux macros du préprocesseur C, qui ne sont qu’une machine à copier coller du texte sans modification. Avec ça, on ne va pas loin, par conséquent, on doit souvent avoir recours à des générateurs de code externes au langage comme ROOT et le MOC de Qt. Cela implique une intégration laborieuse à l’environnement de compilation et une importante duplication du travail déjà effectué par le compilateur du langage puisque le générateur doit ré-analyser le code.

En Rust, en revanche, la génération de code est un membre pleinement reconnu de l’écosystem. Il existe des outils pleinement intégrés au langage pour consommer du code pré-digéré par le compilateur (arbre de tokens) et réémettre un code différent, qui est celui qui sera consommé par le compilateur. Il est donc beaucoup plus facile d’utiliser la génération de code en Rust, et elle est donc logiquement plus souvent utilisée dans ce langage qu’en C++.

Mécanismes

Rust fournit trois mécanismes de génération de code, de difficulté et d’expressivité croissante :

  • Les macros déclaratives, dites “macros par l’exemple”, utilisent un petit langage déclaratif intégré à Rust, basé sur la reconnaissance de motifs (un peu comme match).
  • Les macros procédurales utilisent un type particulier de bibliothèque, qui contiennent des fonctions Rust ordinaires prenant un arbre de tokens en paramètre et émettant un autre arbre de tokens en sortie. C’est l’approche utilisée par #[derive(Trait)].
  • Les scripts de compilation ne font pas partie de Rust à proprement parler, mais de son environnement de compilation standard cargo. Ils permettent d’exécuter du code arbitraire avant même que le compilateur ne commence à parser le code. C’est souvent utilisé pour la gestion de dépendances externes.

Maîtriser chacun de ces mécanismes nécessite un temps d’apprentissage important, il me semble donc qu’ils sortent du périmètre de ce cours introductif.

Mais pour vous donner un “Hello World”, voici une macro déclarative qui implémente un trait pour un tuple de taille choisie par l’utilisateur. C’est quelque chose que l’on est actuellement forcé de faire en Rust, en attendant l’arrivée des génériques variadiques qui sont un des rares domaines ou C++ a de l’avance. On voit donc ici une utilisation classique des macros pour contourner une fonctionnalité manquante du langage de programmation sous-jacent.

trait TraitVide {}

// Définition de la macro
macro_rules! impl_trait_tuple {
    (
        // Accepte en entrée une liste d'identifiants séparée par des virgules,
        // qui serviront à nommer les types internes au tuple.
        $( $t:ident ),*
    ) => {
        // Bloc d'implementation générique pour ce type de tuple.
        //
        // Notez la syntaxe de répétition un peu différente sur la droite, qui
        // permet de générer une virgule terminale pour le tuple unaire (T,).
        //
        // Notez aussi l'utilisation de $crate pour avoir un chemin relatif à la
        // racine de la crate active, indispensable si la macro a vocation à
        // être utilisable dans d'autres crates.
        impl< $( $t ),* > $crate::TraitVide for ( $( $t, )* ) {}
    };
}

// Utilisation de la macro pour implémenter TraitVide jusqu'à une taille de
// tuple maximale de notre choix
impl_trait_tuple!();
impl_trait_tuple!(A);
impl_trait_tuple!(A, B);
impl_trait_tuple!(A, B, C);
impl_trait_tuple!(A, B, C, D);

fn main() {}

Pour apprendre les macros déclaratives, deux bonnes sources sont…

Si vous voulez plus tard vous essayer aux macros procédurales, votre réflexe de base devrait être d’utiliser les excellentes bibliothèques syn et quote de David Tolnay, qui permettent respectivement de transformer en entrée l’arbre de tokens fourni par le compilateur en arbre syntaxique (Abstract Syntax Tree ou AST), et de générer en sortie un arbre de tokens avec une syntaxe ressemblant à du code Rust ordinaire.

La raison pour laquelle le compilateur ne nous expose pas directement son AST est que les auteurs du compilateur souhaitent garder la possibilité de le faire évoluer avec l’implémentation du compilateur. Par exemple, le front-end de rustc est actuellement en train d’être parallélisé, et cela n’aurait sans doute pas été possible sans briser l’API si les types de l’AST avaient été exposés.

Il existe aussi d’autres bibliothèques basées sur syn et quote qui simplifient des pratiques récurentes, comme l’implémentation de macros derive() par exploration récursive d’un type structuré. C’est un écosystème plus mouvant, donc j’hésite à faire des recommandations qui ont de fortes chances de devenir obsolètes en quelques années seulement.

Quand aux scripts de compilation, ce sont des programmes Rust ordinaires qui sont juste compilés et exécutés par cargo selon un protocole bien précis avant d’entamer le processus de compilation du reste du programme.