Site WWW de Laurent Bloch
Slogan du site

ISSN 2271-3905
Cliquez ici si vous voulez visiter mon autre site, orienté vers des sujets moins techniques.

Pour recevoir (au plus une fois par semaine) les nouveautés de ce site, indiquez ici votre adresse électronique :

Premiers programmes en Rust
Un héritier d’Ada pour la programmation système ?
Article mis en ligne le 5 mai 2021
dernière modification le 9 novembre 2022

par Laurent Bloch

Cette rubrique donne quelques exemples de programmation en Rust, mais pour qui souhaite une vision d’ensemble sous une forme néanmoins concise, je ne saurais trop recommander, sur le site Techniques de l’ingénieur, l’article consacré à Rust, dont je suis l’auteur.

Cet article a une suite.

Programmation de bas niveau dans un langage moderne

Après l’assembleur RISC-V je suis monté d’un étage dans l’échelle d’abstraction des langages et me voici en Rust. L’apparition de ce nouveau langage de bas niveau (c’est-à-dire proche du matériel) m’a séduit a priori parce que je n’ai jamais pu me résoudre à C, qui à mon avis conserve tous les inconvénients de l’assembleur, plus quelques-uns de son cru, sans avoir les avantages d’un assembleur sobre et élégant (RISC-V, MIPS). Cette syntaxe déplaisante m’a révélé ses origines quand je me suis frotté à l’assembleur VAX, héritier (en pire) de l’assembleur PDP, ancêtre caché de C.

Rust, conçu et développé par Mozilla Research depuis 2010, propose toutes sortes de primitives de bas niveau, utiles au programmeur système et réseau : concurrence, parallélisme, opérations sur champs de bits, gestion contrôlée de la mémoire (pile et tas). Ces opérations de bas niveau cohabitent avec des caractéristiques de langages évolués modernes : variables immuables (sauf si on les déclare muables), les pointeurs sont des références (sauf si on veut accéder directement aux adresses), gestion sûre de la mémoire (sauf si on veut sauter les barrières de sécurité), système de types inspiré de Haskell, modèle de programmation avec des objets.

Je me suis demandé avec quel manuel je pourrais commencer, mes correspondants Twitter unanimes m’ont recommandé celui de Steve Klabnik et Carol Nichols, effectivement très bon, il y a une traduction en français sur Github. Ce livre est disponible dans une bibliothèque que je fréquente, mais comme il n’y a qu’un seul exemplaire je me garderai bien de révéler laquelle. J’ai acheté la version numérique mais c’est quand même moins commode, sauf pour copier-coller du code ; elle est disponible sur la page Github du livre, et ici en français.

On consultera aussi avec profit cet article de Bastien Vigneron et celui-là, qui complète parfaitement celui-ci.

Héritage d’Ada

Je retrouve avec plaisir, sous une forme moderne, le polymorphisme et la généricité d’Ada, ainsi que la séparation de la spécification et de l’implémentation des fonctions ou des types, ainsi par exemple :

Rust suggère un certaine style de programmation, sans vraiment le rendre obligatoire, mais en émettant quand même des avertissements si on ne le respecte pas. Les identifiants ordinaires comme les noms de modules doivent être écrits avec des lettres minuscules, éventuellement séparées par des blancs soulignés, comme précisé ici : Rust snake case. Les types ont droit aux initiales majuscules. Les variables statiques et les constantes doivent être identifiées en majuscules.

Modèle de gestion de la mémoire

Le modèle de mémoire ne comporte pas de glaneur de cellules (GC, en anglais garbage collector), mais un mécanisme de possession et d’emprunt de valeurs assorti d’un mécanisme de compteur de références qui permet de savoir précisément si un objet peut (doit) être abandonné ou pas. Chaque valeur est la possession d’une et d’une seule variable, chaque variable a une portée délimitée par des accolades, et après la fermeture des accolades la variable et sa valeur n’existent plus. Mais une variable peut prêter sa valeur à une autre, la transmettre à une fonction, ou (dans le cas des agrégats tels que chaînes de caractères, structures ou vecteurs) on peut en créer un clone. La création d’un clone correspond à ce que dans d’autres langages on nomme copie profonde, par opposition à l’emprunt de valeur, qui est une copie superficielle (shallow copy), avec cette restriction que lorsqu’une variable muable a prêté sa valeur elle ne peut plus y accéder. Si la variable qui possède la valeur à un instant donné sort de portée, la valeur peut être détruite sans hésitation.

Le GC est une idée géniale, mais seulement tant que l’on ne dépend pas trop du temps : pour le logiciel de pilotage d’un véhicule c’est franchement déconseillé. En effet, les langages de programmation modernes (par opposition au Fortran des années 1960) permettent l’allocation dynamique de zones de mémoire en cours d’exécution. En l’absence de GC il est de la responsabilité du programmeur de surveiller les allocations, de vérifier qu’elles ne prolifèrent pas au point de saturer la mémoire, et de libérer les zones dont il n’a plus besoin ; à défaut, le programme sera fragile et sujet aux interruptions inopinées, voire au blocage du système. Le GC est un élément de logiciel incorporé au programme exécutable [1] pour effectuer automatiquement la surveillance et la libération des zones utilisées, ce qui est très confortable ; Scheme, Java, OCaml, Go sont des exemples de langages à GC. Le revers de la médaille, c’est que les opérations du GC se déclenchent automatiquement, c’est-à-dire à un instant non choisi, et si cet instant est celui où le pilotage automatique du véhicule s’apprêtait à effectuer un arrêt d’urgence, le retard induit par le GC risque d’être fatal. Le système de possession et d’emprunt de Rust évite les inconvénients de la gestion manuelle comme du GC, en procurant une gestion de mémoire exacte et, surtout, vérifiable à la compilation.

Les accès à la mémoire (et notamment les bornes de tableaux ou de chaînes) sont contrôlés à la compilation, ce qui évite une des pires plaies des systèmes écrits en C. L’inférence de type et le filtrage par motifs sont fournis gratuitement. Bref, c’est un langage moderne qui n’apporte aucune révolution mais reprend de bonnes idées du passé, par exemple celles d’Ada, et prend en considération de mauvaises expériences, par exemple The Most Expensive One-byte Mistake.

Rust implémente la concurrence par des threads [2], là aussi en reprenant certaines idées d’Ada (le rendez-vous) en profitant des résultats de quarante ans de recherche dans le domaine. La solution proposée consiste à assurer la communication et la synchronisation par passage de messages, comme le conseillent les auteurs du langage Go : « Ne communiquez pas en partageant la mémoire, partagez plutôt la mémoire en communicant ».

Programmation modulaire et construction de programmes avec Cargo

Ce que j’ai peut-être le plus aimé avec Rust, c’est Cargo, son système de construction de programmes modulaires. Il fait à peu près les mêmes choses que le système de construction des programmes Ada de ma jeunesse, mais en bénéficiant des quarante ans d’expérience de programmation accumulée depuis, ou que les systèmes conçus pour Java, mais sans cliquodrome ni la complexité des usines à gaz du genre Eclipse. C’est presque aussi simple que le système de modules de Bigloo, mon compilateur Scheme préféré. Vive la ligne de commande !

Le système de modules permet de construire des programmes de structure complexe, par assemblage d’unités de programme obtenues soit à partir de divers fichiers locaux, soit depuis le registre de paquets de Rust (les paquets Rust sont appelés crates, cageots en français). Bien sûr, Cargo assemble les différents composants du programme en vérifiant la cohérence des différents modules entre eux.

Typer correctement un programme Rust et en organiser les dépendances peut être délicat. Bastien Vigneron m’écrit : c’est vrai que le mode de détection / gestion des modules est un peu particulier au début (entre l’utilisation du nom du fichier, ou alors de celui du répertoire mais qui doit contenir un fichier mod.rs…)

Comme beaucoup de choses sont vérifiées à la compilation, comme en Ada, le compilateur (invoqué par Cargo) peut donner des messages d’erreur précis, et même suggérer directement une correction, qui souvent résout le problème sans autre investigation.

Alignement de séquences biologiques

Après avoir compilé et fait tourner quelques programmes aimablement fournis par Steve Klabnik et Carol Nichols, je me suis orienté vers l’implémentation en Rust de mon algorithme préféré d’alignement de séquences biologiques, celui de Messieurs Needleman et Wunsch. Bon, Vincent Esche a déjà fait le travail pour l’algorithme proprement dit, et même en prime il nous donne celui de Smith et Waterman, mais reste à administrer les entrées-sorties.

Bon, commençons par construire notre arborescence de modules, avec l’aide de Cargo. Je sais que j’aurai à lire des données de séquences au format FASTA alors je peux d’ores et déjà prévoir un un sous-répertoire fasta_files_mgt dans le répertoire src que Cargo va créer pour les textes de mes programmes source.

J’invoque Cargo avec le drapeau --bin parce que je veux construire un programme exécutable, qui sera accompagné d’une librairie de fonctions. Si j’avais voulu ne construire qu’une librairie j’aurais choisi le drapeau --lib.

À la racine, donc dans needleman_wunsch/src/, je place deux fichiers :

main.rs déclare le crate needleman_wunsch et indique que la procédure principale de mon programme sera get_filenames. La notation :: sépare les éléments du chemin dans l’arborescence du crate ; elle peut aussi, par exemple dans String::new(), servir à indiquer que la fonction new est associée au type String (pour créer une nouvelle instance de String). La notation . dénote l’application d’une méthode à une instance.

lib.rs déclare le module public fasta_files_mgt qui se trouvera dans le sous-répertoire de même nom.

C’est dans le répertoire fasta_files_mgt que seront les premiers programmes qui font vraiment des choses. On commence par déclarer le module :

On note l’invocation, par des clauses use, de modules externes qui peuvent être chargés à partir du registre de paquets Rust. La fonction args du module env, lui-même fourni par le module std, renvoie un itérateur sur les arguments de la ligne de commande qui a lancé l’exécution du programme, le premier élément étant le chemin de l’exécutable. La méthode collect formate le résultat de args sous la forme voulue d’un vecteur de chaînes ; c’est l’annotation de type Vec<String> de la variable args (à ne pas confondre avec la fonction de même nom) qui indique à collect le type de collection désiré.

Puis pour lire les séquences :

Pour construire le programme et l’exécuter :

Pour que vous puissiez essayer, voici deux fichiers au format FASTA, ce sont des gènes de deux variétés d’orchidées :

Bon, pour l’instant ce programme ne fait rien que de se montrer, la suite dans un prochain article.