Introduction au langage Rust

Tags :
  • MON
  • 2023-2024
  • temps 1
  • Rust
Auteurs :
  • Paul Vietor

Une introduction au langage Rust au travers du livre Rust officiel.

Introduction

Dans ce second MON, j'ai décidé de me lancer dans le langage Rust en parcourant le livre de Rust, en faisant les quelques projets qui y sont proposés au cours de sa lecture.

Mais, me demanderez-vous, qu'est-ce que Rust ?

Présentation du langage Rust

D'après Wikipédia, "Rust est un langage de programmation compilé multi-paradigme conçu et développé par Mozilla Research depuis 2010."... Ouais. Ça ne nous avance pas beaucoup, et même si le reste de la page nous donne de plus amples explications, c'est un peu long pour tout citer ici, donc je vais me contenter des grandes propriétés importantes :

En quoi consiste le livre de Rust

La communauté Rust a écrit un livre à destination de ceux désirant apprendre le langage Rust. En une vingtaine de chapitres (trop pour que je puisse le finir en 10h), il nous présente les fonctionnalités du langage Rust, et nous accompagne dans 3 projets : un très simple jeu dans lequel le joueur doit deviner un nombre aléatoire entre 1 et 100, dès le chapitre 2, un clone minimaliste du programme grep, qui permet de rechercher une chaîne de caractères dans un fichier, au chapitre 12, et enfin un serveur web multithreadé, c'est-à-dire qui exécute plusieurs tâches en parralèle sur différents threads du processeur, au chapitre 20.

Tout d'abord, le premier chapitre est dédié à l'installation des outils nécessaires au développement en Rust, et le deuxième chapitre nous guide au travers du premier projet.
Ensuite, les chapitres 3 à 10 nous présentent les fonctionnalités et spécificités essentielles du langage : les types et structures de données de base disponibles, les bibliothèques (nommées crates), la gestion des erreurs, et, bien sûr, la spécificité incontournable de Rust, la notion de propriété, reprise plus en détail par Assane Diouf dans son premier MON.
Le chapitre 11, quant à lui, nous présente les fonctionnalités de tests automatiques qui pourront être exécutés par cargo, l'outil principal de gestion des projets Rust, afin de nous préparer au deuxième projet, au chapitre 12.
Enfin, les chapitres 13 à 19 nous présentent les fonctionnalités avancées de Rust afin de nous préparer au dernier projet du chapitre 20, à l'exception du chapitre 14 qui revient sur cargo ainsi que la gestion des crates et Crates.io, le site officiel pour trouver des crates.

Pour ce MON, je vais donc tenter d'expliquer de façon succinte les divers concepts rencontrés dans ce livre au fil de ma lecture, chapitre par chapitre, jusqu'au point auquel j'aurai réussi à arriver en dix heures.

Mon avancement dans la lecture du livre

Chapitre 1 : Outils et installation

Le chapitre 1 était très rapide, puisqu'il n'a été pour moi que l'affaire de lancer quelques commandes et attendre que les téléchargements se fassent.

Chapitre 2 : Premier projet

Le chapitre 2 m'a pris environ une heure et demi pour tout bien comprendre. Ce chapitre sert d'introduction à plusieurs concepts que nous verrons plus en profondeur dans les chapitres suivants :

Chapitre 3 : Notions communes en programmation

Le chapitre 3, rapide à lire quand on a déjà des bases de programmation, présente des notions communes à la très grande majorité des langages de programmation : les variables, les types de données, les fonctions, les commentaires et les structures de contrôle (control flow). On y découvre quelques spécificités de Rust :

Chapitre 4 : La propriété

Le chapitre 4 est le chapitre qui présente la notion de propriété, et peut être long à comprendre puisque qu'il explique la gestion de la mémoire en Rust. J'en ai personnellement eu pour deux petites heures, en ayant déjà rencontré certaines notions abordées dans ce chapitre. Je ne reviendrai pas sur la notion de propriété spécifiquement, puisque ce serait une redite de ce qu'a déjà expliqué Assane dans son MON. Cependant, je peux expliquer la gestion de la mémoire : en Rust, les données peuvent aller à deux endroits :

La distinction est importante car, quand on assigne une valeur à une variable, on assigne seulement la valeur qui se trouve sur la pile. Ainsi, si on déclare deux entiers, a et b, puis que l'on fait b = a, b est une copie indépendante de a, et on peut modifier b sans affecter la valeur de a (pour peu que b soit mutable, évidemment).
Cependant, si on essaye de faire la même chose avec deux chaînes de caractères, b ne va copier que la valeur de a se trouvant sur la pile : l'emplacement sur le tas, la longueur et la place disponible. Les deux variables pointeront donc aux même données sur le tas, et toute modification à b modifierait a, et inversement. Pour éviter de faire cette erreur, Rust rend alors a invalide : on ne peut plus l'utiliser, sous peine de causer une erreur à la compilation. Si on veut réellement copier a dans une nouvelle variable, il faut le faire explicitement avec la méthode clone().

La façon dont Rust détermine le comportement à adopter en pratique implique le trait Copy, mais les traits étant seulement au programme du chapitre 10, il ne me semblait pas intéressant d'en parler ici.

Chapitre 5 : Structures

Le chapitre 5 présente les structures (structs), en opposition aux uplets (tuples), décrits dans le chapitre 3. Les deux permettent de stocker plusieurs valeurs dans une même variables, mais dans une structure, chacune de ces valeurs peut avoir un nom, et une structure est en soi un type de données nommé, dont le nom est donné par le programmeur. Cela permet alors de passer des structures comme arguments à nos fonctions, rendant le code plus clair, et de définir des méthodes pour les structures.

Pour implémenter une méthode pour notre structure, il faut utiliser un bloc impl dans lequel on met nos méthodes, séparé du bloc struct définissant la structure.

Il est également possible de créer des structures ne stockant aucune valeur, on a alors un type dit unité (et de même pour les uplets, on peut créer le 0-uplet () qui ne contient aucune valeur). Cela peut également avoir des intérêts, mais qu'on ne verra pas avant le chapitre 10.

Ainsi, ce chapitre nous montre comment utiliser une structure pour représenter un rectangle, et définit quelques méthodes sur ces rectangles : le calcul de leur aire, et savoir si un rectangle rentre dans un autre.

Les structs et les uplets pouvant encapsuler plusieurs valeurs, ce sont ce que l'on appelle des types produits, en référence au produit cartésien d'ensembles en mathématiques. On les distingue des types dits sommes, tels que les énumérations dont nous parle le chapitre 6.

Chapitre 6 : Énumérations et match

Ce chapitre 6 nous présente les énumerations (enums), qui sont des types dont la valeur peut elle-même être de plusieurs types différents, mais un seul à la fois : ce sont des types sommes, comme dit dans l'encadré juste au-dessus. Chacun de ces types est alors appelé une variante de notre type énumération, ces variantes peuvent elles-mêmes contenir des valeurs.

On peut alors considérer chaque variante de notre énumération comme une structure, éventuellement unité si la variante ne contient aucune valeur.

Les énumérations ont plusieurs utilités : il se pourrait que l'on veuille, par exemple utiliser des adresses IP. Or, il existe de nos jours deux types d'adresses IP : les adresses IPv4 et les adresses IPv6. Plutôt que de devoir s'embêter avec un type (qui serait alors une structure) pour chaque forme, on peut utiliser une énumération, et définir des méthodes communes à toutes les adresses IP, n'avoir qu'une seule définition de fonction qui doit prendre une adresse IP en paramètre, etc.

Cependant, cette approche demande de pouvoir distinguer les opérations que l'on fait selon la variante de l'énumération : c'est là que match entre en jeu.
Quand on utilise match sur une variable dont le type est une énumération, on peut emprunter une différente branche de notre programme pour chaque variante, et, si cette variante contient elle-même une valeur, on peut lier cette valeur à une variable pour la durée de la branche.

Enfin, ce chapitre nous parle des énumérations Result et Option, représentant respectivement un résultat, qui pourrait indiquer un échec ou une réussite avec une valeur, et une valeur pouvant éventuellement valoir Null (qui est en quelque sorte une "absence de valeur"), et de certaines méthodes ou fonctionnalités du langage que l'on peut utiliser avec ces énumérations.

Chapitre 7 : Crates, paquets et modules

Ce chapitre 7 nous présente les crates, les paquets et les modules. C'est un chapitre important, relativement long et complexe, j'ai mis près d'une heure et demi à bien l'assimiler.

En Rust, formellement, une crate est tout simplement une unité de code que considère le compilateur. On distingue deux types de crates : les crates binaires, qui sont des programmes exécutables, et les crates bibliothèques, qui contiennent des fonctionnalités réutilisables depuis d'autres programmes. En général, quand on parle de crate, on parle de ce second type.

Un paquet est un ensemble de une ou plusieurs crates, mais qui ne peut pas avoir plus d'une crate bibliothèque. À chacune des crates d'un paquet va correspondre soit le fichier src/main.rs, qui est la crate binaire principale du paquet, soit le fichier src/lib.rs, qui est la crate bibliothèque du paquet, soit un fichier dans src/bin.

Enfin, tout fichier qui ne correspond à aucun de ces chemins peut définir un module, que l'on peut ensuite utiliser dans notre code avec l'instruction mod <nom du module>, où le nom du module est le nom du fichier sans l'extension ou le nom d'un dossier contenant un fichier mod.rs.
Par défaut, tout ce que l'on définit dans un module est privé, il faut précéder les déclarations du mot-clef pub afin de rendre ce que l'on définit accessible par le module parent et utiliser pub mod au lieu de mod pour rendre les sous-modules accessibles au module parent.

Chapitre 8 : Collections

Le chapitre 8 est consacré aux collections, qui sont des types contenant multiples valeurs d'un même type. Ici on nous présente 3 collections : les vecteurs, les Strings et les tables de hachage (hash tables). C'est également notre première rencontre avec les génériques, qui seront là encore vus plus en profondeur au chapitre 10.

En ce qui concerne les vecteurs, on nous présente comment créer un vecteur, comment en récupérer une valeur, ou en ajouter ou supprimer une, ainsi que comment itérer sur les éléments d'un vecteur. De plus, bien qu'un vecteur ne puisse contenir que des éléments d'un même type, rien n'empêche ce type d'être une énumération, donc pour créer un vecteur dont les éléments peuvent avoir certains types prédéfinis, il suffit d'utiliser une énumération dont les variantes peuvent contenir les types de données souhaitées.

Ensuite, on nous présente les Strings, qui contiennent des chaînes de caractères Unicode encodés en UTF-8. En particulier, puisque ce type de données n'est "que" un vecteur avec quelques méthodes supplémentaires ou légèrement modifiées, la majorité des méthodes disponibles pour les vecteurs sont disponibles sur les Strings. Cependant, on ne paut pas utiliser [] pour récupérer un élément d'un String, puisque cela correspondrait à récupérer un élément du vecteur sous-jacent, qui n'est souvent pas un caractère à lui tout seul. De plus, on nous donne deux méthodes pour concaténer des Strings, soit avec l'opérateur +, soit avec format!. Enfin, pour itérer sur un String, Rust nous donne deux méthodes : chars() et bytes(), qui permettent d'itérer respectivement sur les caractères, qui sont alors donnés sous forme de chars de Rust, et sur les octets de l'encodage UTF-8.

On peut récupérer une partie d'un String en l'indiçant avec un range entre crochets, mais cela est fortement déconseillé car peut causer des erreurs.

Enfin, les tables de hachage sont un type qui implémente les dictionnaires : un ensemble de couples (clef, valeur) qui nous permet de récupérer une valeur étant donnée la clef correspondante. Contrairement aux vecteurs et aux Strings, les tables de hachage ne sont pas ordonnées. Enfin, là encore, on nous présente les méthodes pour insérer ou enlever des éléments.

Chapitre 9 : La gestion des erreurs

Il ne me restait pas beaucoup de temps, et le chapitre 10 me semblait plus intéressant que le chapitre 9, donc j'ai décidé de passer le chapitre 9 pour ce MON.

Chapitre 10 : Types génériques, traits et durées de vie

Ce chapitre, qui est le dernier que j'ai eu le temps de lire, nous présente la programmation générique : ne pas se soucier des types mêmes, mais uniquement de propriétés qu'ont les types. Par exemple, on peut définir des relations d'ordres sur de nombreux types, tels que les entiers, les nombres à virgule flottante, les chaînes de caractères... mais dans un langage statiquement typé comme Rust, à priori, il faudrait écrire une fonction pour chacun de ces types si on souhaite par exemple trier une liste, ou en trouver le maximum. Pour remédier à cela, on va utiliser un type générique, et dire au compilateur que, pour que l'appel à notre fonction soit valide, il faut que ce type supporte une relation de comparaison.

Il est également possible d'utiliser des types génériques dans les définitions de structures ou d'énumération, de la même façon que dans les fonctions.

C'est là qu'interviennent les traits. En Rust, un trait est un propriété qu'a un type. Par exemple, un type sur les éléments duquel est définie une relation d'ordre (partielle) aura le trait std::cmp::PartialOrd, un type dont les éléments supportent l'addition avec + aura le trait std::ops::Add, etc. On dit alors que le type implémente le trait. En général, implémenter un trait veut dire que l'on implémente les méthodes que doit supporter le trait. Ensuite, quand on crée une fonction, un énumération, une structure... qui demande un type générique implémentant le trait, on est garanti que l'on va pouvoir utiliser ces méthodes.

Il est également possible de demander à ce que le type générique implémente plusieurs traits, en séparant les différents traits requis d'un +.

Pour les traits cités ci-dessus, les méthodes à implémenter sont en fait celles que Rust va appeler quand on utilisera les opérateurs <, > et +, ce qui nous permet d'utiliser directement ces opérateurs dans notre code.

Comme il est souvent chronophage d'implémenter toutes les méthodes requises pour un trait, Rust nous permet de demander au compilateur de créer une implémentation par défaut pour certains traits en utilisant la macro derive, mais ce n'est pas abordé dans ce chapitre.

Enfin, ce chapitre présente les durées de vie. C'est ce qui détermine dans quel cadre (pour combien de "temps") une référence est valide. Cela est notamment utile quand on veut écrire une fonction qui renvoie une référence ou une structure dont un des membres est une référence, puisqu'il faut que Rust sache quand est valide cette référence par rapport à la structure ou aux arguments de la fonction.
Cependant, c'est un sujet que je n'ai pas encore bien compris, et que j'espère réussir à comprendre en passant à la pratique lors du projet.

Conclusion

Au travers de ces 10 premiers chapitres, on a pu découvrir les fonctionnalités de base de Rust, comment les appréhender et les utiliser, et comment ces fonctionnalités et son compilateur permettent de données certaines garanties de sécurité et de validité des opérations qui sont effectuées lors la compilation, permettant d'éviter bien des erreurs au moment d'utiliser le programme, dans un langage qui se veut pourtant relativement proche de la machine. Il reste cependant de nombreuses fonctionnalités qui ne seront présentées que dans les chapitres suivants, donc je continuerai la lecture de mon côté, notamment en vue du développement sur notre projet.