Rust par la pratique

Introduction

Dans ce second article consacré à Rust (le premier est disponible ici), nous allons tenter de nous familiariser avec ce langage via un atelier pratique. L'objectif est de faire un tour rapide de certains concepts en essayant de monter crescendo en expérience.

Pour chacun des exemples de code, il vous suffit de les copier/coller dans https://play.rust-lang.org et d'appuyer sur Run pour compiler et exécuter.

D'ailleurs, le résultat va distinguer 2 zones : la sortie du compilateur et la sortie de l'exécution.

Pour ceux qui ont envie d'utiliser Rust directement sur leur poste, j'ai créé un dépôt git avec l'ensemble des exemples que vous pouvez retrouver via ce lien : https://github.com/mothsART/tutoRust. Soit git clone https://github.com/mothsART/tutoRust.

J'ai alimenté un README pour vous aiguiller sur son utilisation.

Pour chacun des exemples cités, je mets l'instruction à lancer pour générer le code (en commentaire).

Exemple : make variable_1

Premiers pas sur les variables

Avant toute chose, pour que votre code fonctionne sur https://play.rust-lang.org, il va falloir l'enroller dans une fonction main. La fonction main est comme en C, c'est la fonction principale et le point d'entrée. Sur un exécutable Rust elle est obligatoire (par contre, une librairie n'en contiendra pas) .

            // make variables_un

            fn main() {

                        let x; // On peut déclarer une variable "x" sans rien lui assigner (un peu comme en C).

                        x = 42; // On attribue à notre variable une valeur après coup.

            }

Mais au final, on fera bien plus souvent :

            // make variables_deux

            fn main() {

                        let x = 42;

            }

Dans notre cas, le compilateur a attribué à la variable x automatiquement un type (i32 = entier signé de 32 bits). C'est le principe de l'inférence de type.

Néanmoins, imaginons que dans notre code, nous savons pertinemment que notre variable n'aura pas besoin d'être convertie dans un format standard.

Par conséquent, il est inutile d'avoir un entier signé et encore moins occupant une taille aussi grande. On peut donc la forcer à être la plus petite possible soit u8 (entier non signé sur 8 bits) et donc occuper 4 fois moins de place en mémoire :

            // make variables_trois

            fn main() {

                        let x:u8 = 42;

            }

ou

            // make variables_quatre

            fn main() {

                        let x = 42u8; // Je suis moins fan mais les goûts et les couleurs...

            }

 

Pour avoir une bonne visibilité sur les types primitifs (à apprendre par coeur), je vous invites à lire : https://doc.rust-lang.org/std/index.html#primitives

En Rust, les variables sont des constantes par défaut donc :

            // make variables_cinq

            fn main() {

                        let x = 42;

                        x = 50; // Renvoie une erreur de compilation.

            }

Si on veut que cette variable soit mutable, on doit le préciser explicitement :

            // make variables_six

            fn main() {

                        let mut x = 42; // On initialise x à la valeur 42.

                        // ...

                        x = 50; // On change la valeur de x pour qu’elle vaille désormais 50.

            }

"Struct" et "enum"

Les données primitives c'est bien, mais on est vite limité. En Rust, pas de programmation orientée objet. En effet, on sépare 2 principes bien distincts : les données et les fonctions. En utilisant Rust, vous pouvez donc très bien architecturer votre code pour avoir dans des fichiers différents :

  • • Les structures de données : on va dire équivalentes à des classes sans méthodes.
  • • Les données en elle-même : si elles ne sont pas dynamiques (flux d'une api, issues d'une base de données etc) bien évidemment.
  • • Les algorithmes : la manière dont sont exploitées ces données.
  • Pour construire des données complexes, on a à notre disposition principalement les enum et les struct.

Les "struct"

C'est la donnée dans sa forme la plus pure. Elle a un gabarit qui va définir la topologie d'une donnée. Les données créées à partir des struct (instanciées dans le jargon POO) suivront cette nomenclature.

Exemple : https://github.com/mothsART/tutoRust/blob/main/src/bin/struct.rs (make struct)

Dans notre exemple, on a utilisé des primitives pour les propriétés de notre structure mais il est tout à fait possible de le composer à partir d'autres structures voir même de lui-même. Ceci sous-entend que l’on peut utiliser des structures récursives du genre :

https://github.com/mothsART/tutoRust/blob/main/src/bin/struct_recursif.rs (make struct_recursif)

 

Pour des raisons de simplification, un dossier ne peut avoir qu'un sous-dossier mais il vous serait tout à fait possible de prendre en compte une liste. Une fois avoir abordé la section "tableau", vous serez dans la capacité de faire cela. (à bon entendeur)

 

En attendant, j'ai introduit 2 notions : Option et Box.

Rust étant centré sur la mémoire, il ne compile rien sans connaître à l'avance la taille d'une donnée. Le compilateur ne pouvant définir à l'avance la profondeur sur des données récursives, on se retrouve coincé. C'est à ce moment que Box intervient.

Sans rentrer dans les détails. Box est un pointeur sur le tas permettant d'allouer un espace mémoire dynamique. Option est un enum prenant 2 valeurs : soit Some soit None. En Rust, aucune valeur ne peut être nullable. Il faut donc encapsuler une valeur avec un Option pour permettre cela.

Dans notre cas, sans Option, notre hiérarchie a une profondeur infinie : ce qui n'est pas possible dans un contexte fini. Rust ne compile donc pas. En ajoutant un Option, on arrête donc la hiérarchie au dernier sous-dossier en lui passant None.

Les enum

Comme mentionné pour Option (qui est natif), on peut utiliser des enum.

À la différence de la plupart des langages, les enum vont être capables de lister des valeurs simples dans l'ordre (une liste d'entiers par exemple) mais également d'utiliser des structs (et inversement), d'autres enum ou encore d'utiliser de la récursivité. C'est à ce moment que vous commencez à vous rendre compte que Rust, malgré le fait qu'il n'utilise pas de POO, n'a vraiment pas à pâlir.

Un exemple vaut mieux que mille discours. Imaginez que vous développiez le module de paiement de votre banque. Vous avez plusieurs modes de paiement à votre disposition avec chacun leurs caractéristiques.

https://github.com/mothsART/tutoRust/blob/main/src/bin/enum.rs (make enum)

 

Je vous laisse apprécier l'élégance produite : dans d'autres langages, on aurait eu le droit à plusieurs if qui parasitent la compréhension (des instructions normalement réservées pour des algorithmes se retrouvent à structurer de la donnée dans plein de langages et c’est pas vraiment adéquat) et qui posent pas mal de pièges à l’exécution.

Ici, tout est vérifié côté compilation. On ne fait que proposer un gabarit et alimenter notre programme avec des données qui respectent ce gabarit. Un constat à mi-chemin sur Rust : bien structurer ces données fait parti de l'ADN du langage et si on peut passer pas mal de temps sur cette partie, c'est pour notre bien.

Pour la petite subtilité syntaxique : on accède à des propriétés d'une donnée via un “.” (ex: MaStruct.ma_propriete) et “::” quand on accède à des espaces de noms. Les enums utilisent des espaces de noms donc MonEnum::maValeur

L'ownership et la durée de vie

En Rust, une variable ne vit que dans un espace donné.

Par exemple, une variable déclarée dans une fonction ne vivra qu'au sein de cette fonction. En dehors, elle disparaîtra. Ce fonctionnement présente de vrais avantages : on limite les effets de bord et on rend chaque fonction totalement isolée du reste du programme. L'inconvénient est de transiter une donnée d'une fonction à une autre.

Vous pouvez bien évidemment cloner cette donnée mais cela signifie que l’on va la dupliquer en mémoire. Du coup, on utilise une référence (ou pointeur dans la terminologie C/C++). Tout comme l'on va se communiquer une adresse web pour parler d'un article qui nous tient à cœur, on communiquera la référence d'une donnée plutôt que la donnée elle-même.

Exemple : https://github.com/mothsART/tutoRust/blob/main/src/bin/ownership.rs (make ownership)

 

Dans notre exemple, on commence à rentrer dans le vif du sujet (enfin un peu d'algo). On déclare une struct nommée Adresse, on l'alimente avec une nouvelle donnée et on souhaite vérifier qu’elle est valide (avant enregistrement par exemple).

Vous pouvez remarquer qu'on utilise 2 formats de représentations d'une chaîne de caractères : &'static str et String. Pour faire simple, &'static str est une représentation dans son plus simple appareil ! Une adresse et une longueur (attention, longueur en mémoire ne veut pas dire longueur de la chaîne).

Elle est tout à fait suffisante dans plein de cas mais ici nous souhaitons connaître la taille de notre chaîne et ce genre d'opération n'est pas possible.

Pour tout ce qui est manipulation, vous utiliserez String (et il est aisé de passer d’un format à l'autre). Afin d'effectuer le test de vérification, on a créé une fonction qui renvoie true ou false.

Jusque là, rien de compliqué. Seulement voilà : comme dit précédemment, on déplace une donnée d'un emplacement à un autre et si on ne veut pas bêtement copier cette donnée, on est obligé de le passer par référence. C'est là qu'intervient le & qui précise que l'on ne désire pas communiquer la valeur en question mais bien un pointeur sur celle-ci.

Je vous invite à tester sans le &, le compilateur vous dira que la structure Adresse n'implémente pas le trait Copy.

Si vous remplacez #[derive(Debug)] par #[derive(Debug, Copy)], cela résoudra votre soucis mais d'une mauvaise manières : en recopiant votre donnée dans la fonction.

Beaucoup de débutants en Rust contournent ce type de soucis ainsi. Je vous le déconseille fortement car vous ne progresserez pas (sans compter les problèmes de performances engendrés). Cloner a bien une utilité mais dans un cadre restreint : vous avez 2 objets (on parle aussi d'objet en Rust bien que l'on ne soit pas dans un contexte POO) très proches dont l'un est créé à partir de l'autre. Plutôt que de recopier bêtement chaque propriété, on clone l'objet et on ne modifie que ce qui diffère.

Les tableaux

Bien évidemment, pour interagir avec des données du monde réel, il va falloir utiliser des tableaux. Rust utilise 3 notions :

1. Les array

Ce sont des tableaux statiques avec une taille en mémoire finie.On les privilégie pour le code en dur.

Exemple :

            // make array

 

            fn main() {

                        let _array: [i32; 3] = [1, 3, 8];

                        // Cette boucle va afficher 1, 3, 8

                        for x in &_array {

                                   print!("{} ", x);

                        }

            }

array ne peut pas être modifié, uniquement lu et on est obligé d'ajouter un & pour pouvoir itérer dessus. Si vous mettez la variable _array en mut, on pourra changer les valeurs mais pas sa taille. Son utilisation reste donc assez limitée.

2. Les vec

Ce sont des tableaux dynamiques donc avec une taille non déterminée à l'avance :

            // make vec

 

            fn main() {

                        let mut v: Vec<u8> = Vec::new(); // On crée un nouveau Vec rempli d'entiers non signés de 8 bits.

                        v.push(1);

                        v.push(3);

                        v.push(8);

                        println!("{:?}", v);

            }

C'est le tableau le plus courant et le plus pratique à utiliser. Sa principale limitation est qu'il représente des données stockées dans un emplacement mémoire. Si on venait à communiquer cette donnée (la passer d'une fonction à une autre), ça nécessiterait de la copier en mémoire. Pour éviter ça, on utilise une troisième représentation : les slices.

3. Les slices

Ils vont représenter un morceau de tableau via un pointeur et une taille. Si vous créez un slice à partir d'un Vec, vous ne créerez aucune donnée en mémoire supplémentaire vu que cette slice va seulement contenir l'adresse de ce morceau de Vec.

            // make slice

 

            fn main() {

                        let v = vec![1, 2, 3, 4, 5];

                        let v2 = &v[2..4];

                        println!("v2 = {:?}", v2); // on affiche : 2 = [3, 4]

    }

Le pattern matching

Comme dans chaque langage, les if/else sont de la partie. Cependant, en Rust j'aurais tendance à dire de les oublier. En effet, l'instruction match vous permettra de faire la même chose mais de manière bien plus élégante.

Premier exemple : https://github.com/mothsART/tutoRust/blob/main/src/bin/pattern_matching_un.rs ( make pattern_matching_un)

 

Vous allez me dire que c'est un switch (notion qui n'existe pas en Rust) et vous aurez raison sur des cas simples. En Rust, match peut être utilisé sur n'importe quelle structure de données, émettre des conditions dessus et surtout, il vous obligera à être exhaustif. Voici un exemple qui va vous donner un petit aperçu de ce que l'on peut faire :

https://github.com/mothsART/tutoRust/blob/main/src/bin/pattern_matching_deux.rs (make pattern_matching_deux)

 

On retrouve des notions vues précédemment : les structures, les références, les fonctions. J'ai rajouté une boucle for qui ne devrait pas vous poser soucis. Il existe également l'instruction while et loop pour les plus curieux d'entre vous.

map, filter et closure

Si vous avez l'habitude des langages fonctionnels, vous avez l'habitude de manipuler vos données sans une succession de if (ou des match en Rust) illisibles mais avec map, filter, reduce etc. Rust inclut bien évidemment ces primitives. Voici un exemple concret qui pourrait vous en dire davantage :

https://github.com/mothsART/tutoRust/blob/main/src/bin/map_filter_closure.rs (make map_filter_closure)

Si vous ne connaissez pas les closures (propre à plein de langages), ces dernières permettent de capturer leur environnement (toutes les variables présentent dans leur scope) et d’effectuer un traitement.

En Rust, vous trouverez les closures principalement pour faire de l'événementiel, de l'asynchronisme, des évaluations paresseuses et du traitement de données.

L'implémentation et les traits

Pour l'instant, vous avez découvert des fonctions simples. Néanmoins, on peut définir une liste de méthodes en une structure. Vous allez voir qu'au final, on est très proche de l'orienté objet mais sans rentrer dans ces travers et toujours en dissociant structures, données et calculs.

Exemple : https://github.com/mothsART/tutoRust/blob/main/src/bin/implement.rs (make implement)

Pour forcer une implémentation précise, Rust met à disposition les trait. C'est grosso modo l'équivalent des interfaces dans le monde objet.

Revoyons notre exemple avec un trait : https://github.com/mothsART/tutoRust/blob/main/src/bin/trait.rs (make trait)

Pour notre exemple, ça ne change pas grand chose mais imaginons que nous voulions avoir la même API pour des rovers pilotables sur Terre, Mars et Vénus. Leur implémentation sera très différente mais vu de l'extérieur, ils auront les mêmes fonctions !

Bien évidemment, vous pouvez implémenter une infinité de trait pour une structure. Par exemple, pour les rovers de Mars et Vénus, on pourrait avoir un trait CapteurDeTemperature qui serait probablement inutile pour celui terrestre. Le rover Persévérance pourrait implémenter le trait SpectrometreLaser inutile pour bien d'autres rovers qui n'ont pas besoin de détecter des molécules organiques etc.

Conclusion

Vous avez fait un tour d'horizon de bons nombres de fonctionnalités de Rust. Il resterait sans doute des choses à dire sur :

  • • La généricité
  • • L'asynchronisme
  • • Les macros
  • • Les types RefCell et Rc
  • • Les Foreign Function Interface et l'unsafe
  • • La documentation
  • • Les tests unitaires et les benchmarks
  • • Les espaces de noms, les projets multi-fichiers et la gestion des libs/dépendances
  • • L'utilisation de Cargo
  • • etc.

 

Néanmoins, je pense vous avoir déjà donné les premières armes pour un démarrage rapide. Le mieux étant déjà d'expérimenter et surtout en Rust, d'apprivoiser le compilateur. Ce dernier vous semblera à vos débuts votre pire ennemi mais cette tendance va s'inverser !

Je vous conseille dans la phase de prototypage :

  • De préfixer vos variables non utilisées avec un "_" : le compilateur n'émettra pas de warnings.
  • D'utiliser des unwrap() plutôt que de tout gérer parfaitement du premier coup.

Cela vous permettra d'avancer vite sans vous soucier des détails. En revanche, une fois votre structure de données maîtrisée et avant la phase de production, remplacez dûment tout ce beau monde !

Jérémie Ferry

Consultant Versusmind