Overblog
Editer l'article Suivre ce blog Administration + Créer mon blog
Laurent COCAULT

Premier module en Rust

Dans un article récent intitulé Faut-il s'intéresser à Rust, j'avançais quelques arguments qui motivent une réponse positive à cette question. Pour permettre à ceux qui lisent mon blog d'en savoir plus sur les atouts de ce langage, il me semblait utile de nous aventurer au-delà de l'affiche publicitaire. Cet article introduit quelques concepts du langage et outils de développement.

Commençons avec les outils de développement, et plus particulièrement Cargo. Il s'agit de l'outil officiel de gestion des paquets Rust, appelés "Crates". Cet outil permet ainsi de créer des paquets de type "binary" intégrant une fonction "main" et destinés à l'exécution, ou de type "library" pour réaliser des modules réutilisables.

Pour l'exercice, nous commencerons avec une librairie de gestion d'unités. L'initialisation d'une telle librairie passe par la commande suivante:

PS> cargo new unit --lib
Created library `unit` package

La commande initialise un module "unit" présentant une structure de code par défaut fournissant un exemple de fonction et un test unitaire associé qu'il est possible d'exécuter avec la commande "cargo test".

Premier module en Rust

Cette structure de base amène quelques commentaires:

  • Le dossier est nommé "unit" mais l'unique fichier source porte le nom de "lib.rs". Si nous avions opté pour un programme principal, notre fichier source aurait été nommé "main.rs" par Cargo.
  • Le fait de disposer d'emblée d'un test unitaire est particulièrement intéressant dans la perspective d'un développement en Test Driven Development (TDD).
  • Un fichier "Cargo.toml" a été créé au même niveau que le répertoire de sources. Ce fichier permet d'indiquer à Cargo quelle est l'identité de notre module et quelles sont ses dépendances avec le reste du monde. Cargo.toml est aux développeurs Rust ce que pom.xml est aux développeurs Java/Maven ou package.json aux développeurs JavaScript/npm.

Pour l'exemple, nous allons mettre en place un outillage de gestion de paramètres de configuration. Les paramètres ainsi gérés pourront être des grandeurs physiques accessibles selon les unités du choix de l'utilisateur, dans l'esprit de ce qui existe en Java avec la JSR 385 traitée dans cet article.

Le modèle de données des premières étapes d'implémentation est le suivant:

Premier module en Rust

Commençons par écrire un test unitaire permettant de spécifier la création d'un volume en litres.

Premier module en Rust

Ce premier exemple permet de découvrir quelques éléments de syntaxe du langage Rust:

  • Le mot clé "mod" introduit le concept de module qui permet de gérer des espaces de visibilité.
  • Le mot clé "use" permet d'importer des déclarations externes dans l'espace de visibilité du module de test.
  • Le mot clé "fn" permet de déclarer une fonction; on notera que la convention de casse recommandée pour le langage est le "snake case".
  • Le mot clé "let" permet de déclarer une variable locale (non mutable, on reviendra sur ce concept plus tard).
  • La fonction "assert_eq" permet, dans un test unitaire, de définir une assertion.

On notera que l'IDE utilisé indique que le type de retour de la fonction "get_quantity" est un Box<dyn Quantity>. Il est ici temps de regarder comment sont définis nos types spécifiques, conformément au modèle de données proposée ci-dessus.

Premier module en Rust

Le code ci-dessus correspond à une implémentation partielle du modèle de données cible, mais est tout à fait suffisant pour introduire certains concepts du langage.

  • Le mot clé "enum" permet d'introduire une énumération.
  • Le mot clé "trait" permet de déclarer un "Trait" qui correspond en Rust à une spécification (un contrat) d'interface. On notera, dans la signature de la fonction "get_value", que la déclaration du paramètre "self" permet, comme en langage Python, un accès aux données de la structure qui sera utilisée pour supporter son implémentation.
  • Le mot clé "struct" permet, comme en langage C, de déclarer une structure de données.
  • Le mot clé "impl" permet de proposer l'implémentation d'un trait en s'appuyant sur une structure précédemment déclarée.
  • Le mot clé "pub" utilisé pour certains types permet d'indiquer que leur visibilité en dehors du module est publique.
  • La déclaration des structures ou des contrats de fonction fait apparaître le type f64 qui correspond à un réel double précision sur 64 bits.

Cet exemple permet d'illustrer quelques principes du langage :

  • Le dénomination des types de base (ici f64, mais on pourrait aussi évoquer les entiers signés i32 ou non signés sur la même précision u32, ou encore des entiers courts i16 ou u16) mentionne la précision et le caractère signé ou non. Autrement dit Rust explicite les détails d'implémentation physique des données manipulées.
  • Le concept de visibilité proposé avec le mot clé "pub" offre un levier pour gérer l'encapsulation des données. Si l'exemple proposé ci-dessus fait une utilisation élémentaire du concept de visibilité, il est intéressant de savoir que le mot clé "pub" peut introduire plusieurs niveaux de visibilité (in path, crate, super ou self).
  • La notion de "trait" est un élément essentiel pour travailler avec des abstractions et adopter une approche de programmation par contrat. Ce concept ouvre la porte à l'utilisation du principe de substitution de Liskov (le "L" de SOLID). La possibilité d'associer une structure à un trait de manière séparée de la déclaration de la structure et du trait, ouvre la possibilité d'associer plusieurs traits à une structure et ainsi de ségréguer les interfaces (le "I" de SOLID).

Maintenant que nous voyons comment sont déclarés nos types, regardons de plus près le type de retour de la fonction get_quantity. 

Premier module en Rust

Le type de retour de la fonction indique que la valeur retournée respecte le trait Quantity. La valeur retournée n'est pas directement une instance créée localement; mais une valeur allouée dynamiquement sur la "heap" dont on utilise la structure "Box" pour en assurer le transport. La fonction "new" de Box prend en charge l'allocation dynamique de la valeur et son référencement au sein de la "boite".

L'implémentation de la fonction met par ailleurs en évidence le concept de "pattern matching" qui permet ici d'associer une valeur de retour en fonction de l'énumération Unit passée en paramètre.

Allons plus loin que ces implémentations triviales en prenant l'exemple des unités de température pour mettre en place des conversions de valeurs.

Premier module en Rust

Pour traiter ces nouvelles unités, il est nécessaire d'enrichir la fonction "get_quantity".

Premier module en Rust

La fonction fait appel à des fonctions de conversion depuis les unités dérivées vers l'unité Kelvin. Ces fonctions sont implémentées comme suit.

Premier module en Rust

On peut noter que ces fonctions s'appuient sur des constantes dont la définition passe par le mot clé "const".

Premier module en Rust

Ces mêmes constantes sont utilisées dans des fonctions mises en œuvre pour convertir la valeur stockée en Kelvin dans la structure Temperature.

Premier module en Rust

A ce stade de la réalisation, le fichier "lib.rs" commence à se charger et atteint plus de 130 lignes en comptant les tests unitaires. Il est désormais temps de structurer l'arborescence de fichier en externalisant les fonctions relatives à la gestion de la température. On commence ainsi par créer un fichier "temperature.rs" dans lequel on extrait l'ensemble des éléments propres à la notion de température. Ce faisant, il devient nécessaire de changer la visibilité d'un certain nombre de fonctions pour les rendre utilisables depuis le fichier principal "lib.rs", dans lequel on déclarera et on importera le module "temperature". Gérer la visibilité des fonctions extraites ne pose pas de souci, mais rendre visible la structure interne de Temperature pose un problème de violation des principes d'encapsulation qui voudraient que le module principal n'ait pas à connaître la façon dont une température est implémentée. A l'occasion de ce "refactoring", on en profitera pour créer des fonctions de construction des instances de température à partir des différentes unités de chaque température. En appliquant la même approche aux volumes et longueurs, la fonction "get_quantity" devient:

Premier module en Rust

Le contenu résultant du fichier "temperature.rs" est alors:

Premier module en Rust

Les fonctions déplacées dans ce fichier qui n'ont pas vocation à être utilisées en dehors du module restent privées.

Premier module en Rust

A l'issue de ces travaux de "refactoring", il ne reste dans le fichier "lib.rs" que la définition de l'énumération Unit, du trait Quantity et de la fonction get_quantity (ainsi que les tests unitaires permettant de manipuler ces concepts, indépendamment de leur implémentation). Le déséquilibre entre l'API du module et ses tests unitaires invite enfin à extraire les tests dans un fichier "tests.rs" dédié.

Pour terminer ce premier module et illustrer quelques concepts complémentaires du langage, on ajoutera une seconde fonction publique permettant de parser une chaîne de caractères pour créer une quantité. En se limitant au cas des degrés Kelvin et Celsius, le code de cette fonction est le suivant:

Premier module en Rust

Cette fonction met en évidence plusieurs nouveautés, à commencer par l'introduction de nouveaux types de base. Le type "&str" représente la chaîne de caractères de laquelle on souhaite lire une quantité (notons qu'il existe également un type String qui propose une représentation différente et des fonctions complémentaires). L'autre type invoqué dans cette fonction est celui de "Vec" qui permet de manipuler des vecteurs de données. On l'utilise ici pour distinguer la partie numérique de la chaîne à lire, de la partie représentant l'unité employée pour caractériser la quantité.

La nouveauté la plus intéressante à mettre en avant avec cette fonction est le type "Result" qui permet d'aborder la façon dont Rust propose de gérer les cas dégradés. Contrairement à de nombreux langages modernes, Rust ne propose pas de mécanisme d'exception. Il propose néanmoins deux manières de gérer les cas dégradés: la première consiste à interrompre le flux d'exécution avec l'instruction "panic!" qu'il est possible de paramétrer avec le message d'erreur à afficher (on trouve un exemple d'utilisation dans la fonction "get_value" de Temperature proposée précédemment pour traiter le cas d'une unité non supportée). Cette approche est destinée à traiter les cas d'erreur qu'il n'est pas possible de traiter ou de compenser. Si un traitement du cas dégradé est possible, le type Result peut être retourné pour porter la valeur du résultat ou un contexte d'erreur si le traitement censé produire le résultat a échoué.

On trouve un exemple de décodage d'un Result dans cette même fonction. Le "parsing" de la première partie de la chaîne sous la forme d'un réel double précision peut en effet se solder par une erreur. On exploite ainsi le résultat du "parsing" avec un "pattern matching" permettant de traiter distinctement une valeur numérique valide (avec Ok) et un éventuel contexte d'erreur (avec Err).

Terminons l'initialisation de notre premier module Rust avec une exécution des tests unitaires créés pour son élaboration. Dans le prochain article, nous introduirons un autre module dont l'implémentation fera apparaître d'autres concepts du langage.

Premier module en Rust

Les sources utilisés pour cet article peuvent être trouvées ici.

Crédit couverture: Tabble sur Pixabay

Partager cet article
Repost0
Pour être informé des derniers articles, inscrivez vous :
Commenter cet article