Article écrit par Cedric Brancourt
Parlons de la rouille, cette délicieuse sauce qui accompagne nos soupes de poisson. Le bon dosage des ingrédients ravira le palais vos convives !
Pour faire une portion de rouille les ingrédients sont :
1/2 gousse d'ail dégermée 1/8 petit piment rouge 1/8 jaune d'œuf 1/8 tranche de pain 1/32 L d'huile d'olive
Plus on est nombreux autour de la table et plus on rit.
Il va donc falloir multiplier la proportion des ingrédients pour atteindre un dosage équilibré. Miam !
Je profite de cet article dédié à la recette de la rouille pour évoquer Rust au passage.
Non, pas le jeu vidéo pour survivaliste virtuel frustré (je plaisante, bisous !). Rust(2018) : Le langage de programmation qui fait peur.
Je ne sais plus par quelle association d’idée j’en suis arrivé là. Mais je vais vous expliquer comment je calcule le dosage des ingrédients de ma rouille avec Rust.
En espérant qu’a l’issu de cet article Rust ne vous intimidera plus et titillera votre palais.
Pourquoi autant d’intérêt pour la rouille ?
Le premier aspect séduisant de Rust est l’absence de garbage collector, évitant beaucoup de problèmes de latence.
Son système de gestion de mémoire, qui martyrise les nouveaux venus, apporte un gros avantage en termes de sécurité. (Mais ce n’est pas le pays des Bisounours non plus)
Rust est idéal pour l’embarqué, les outils système, mais pas que. WebAssembly, protocoles réseau, concurrence, portabilité…
C’est à chacun de choisir ses arguments.
La recette
Pour générer un nouveau crate
(un package Rust), il est possible d’utiliser cargo
(l’outil de build).
cargo new doser --bin
L’option --bin
précise qu’il s’agit d’un exécutable et non d’une bibliothèque.
Le point d’entrée d’un crate
, sans surprise, est le fichier main.rs
.
Le doseur de rouille a besoin de la recette unitaire. Profitons-en pour introduire quelques notions.
// main.rs const RECIPE: &str = " 1/2 gousse d'ail dégermée 1/8 piment rouge 1/8 jaune d'œuf 1/8 tranche de pain 1/32 L d'huile d'olive ";
C’est une constante const
, nommée RECIPE
dont on définit le type &str
. &str
est une référence vers une chaîne de caractères UTF-8 immuable.
La ligne se terminant par un ;
il s’agit d’une déclaration.
Les Ingrédients
Pour modéliser les ingrédients de la recette et leurs proportions utilisons une structure dans son propre module.
Un module Rust peut être déclaré grâce à mod
suivi d’accolades {}
, le contenu des accolades sera alors la définition du module.
Le contenu du module peut aussi être déporté dans un autre fichier qui porte le nom du module en question. Dans ce cas on omet les parenthèses.
// main.rs use ingredient
// ingredient.rs use num_rational::Ratio; pub struct Ingredient { pub label: String, pub quantity: Ratio<u32> }
Dans notre fichier ingredient.rs
est défini une structure (struct
) que le module expose publiquement. Par défaut la visibilité est privée le mot clé pub
rend publique la structure et ses champs.
Nos ingrédients sont composés :
- d’un
label
qui contient la désignation, qui est une donnée de typeString
. - d’une
quantity
qui contient une fractionRatio
.
use num_rational::Ratio
est la syntaxe d’import de Rust. Par ce biais nous indiquons que nous utilisons la structure Ratio
de la bibliothèque num_rational
.
La philosophie de Rust est d’embarquer le strict minimum dans le noyau du langage et d’utiliser des crates
pour ajouter des fonctions ou structures.
num_rational
ne fait pas partie du langage, c’est une dépendance externe.
Pour gérer les dépendances il faut utiliser le fichier Cargo.toml
qui est crée à la racine du projet.
[package] name = "doser" version = "0.1.0" authors = ["Cedric <cedric.brancourt@gmail.com>"] edition = "2018" [dependencies] num-rational = "0.2"
La section dependencies
permet de référencer les dépendances à récupérer lors du cargo build
ou cargo run
.
Le type Ratio
prend un paramètre si l’on regarde la définition de ce type.
La définition du constructeur est pub fn new(numer: T, denom: T) -> Ratio<T>
.
Puisqu’il existe plusieurs types de numériques suivant leur taille, signés ou pas, la bibliothèque utilise un type générique T
. Qui donnera un Ratio
dérivé de ce type.
Dans le cadre de notre recette, les quantités négatives n’étant pas possible, il ne s’agira que d’entiers non signés de 32 bits : u32
.
À présent définissons une méthode scale
qui sera en charge de retourner un nouvel ingrédient multiplié par la quantité.
Un nouvel ingrédient, car il n’est pas question ici de changer la valeur de l’ingrédient d’origine, mais d’en obtenir un nouveau à l’échelle de la recette.
// ingredient.rs // ... impl Ingredient { pub fn scale(&self, mult: u32) -> Self { Self { label: self.label.clone(), quantity: self.quantity * mult, } } }
impl
nous permet de définir des méthodes pour la structure Ingredient
. Nous définissons une méthode publique (pub
) nommée scale
.
Elle prend en argument :
&self
qui est une référence à l’objet courant.mult: u32
qui est notre multiplicateur de typeu32
Elle retourne Self
qui est un alias vers le type courant, donc vers Ingredient
Le corps de la fonction se contente de retourner une nouvelle instance d’ingrédient dont :
- la
quantity
est multipliée parmult
. Ce qui retourne un nouveauRatio
- le label est lui explicitement cloné.
Sinon nous aurions transféré la propriété du label au nouvel ingrédient. Ce qui produirait une erreur de compilation car leur cycle de vie est différent.
Tests unitaires
Rust permet d’écrire des tests unitaires à proximité du code source sans les embarquer dans le build de production.
Beaucoup de tests qui auraient été écrits dans un langage interprété et non typé sont évités car le compilateur sert de garde-fou.
L’unique test à écrire dans notre cas est de vérifier que la logique implémentée est la bonne.
// ingredient.rs // ... #[cfg(test)] mod tests { use crate::Ingredient; use num_rational::Ratio; #[test] fn it_scale_quantity() { let quantity = Ratio::new(1, 2); let ingredient = Ingredient { label: "".to_string(), quantity, }; assert_eq!(ingredient.scale(2).quantity, Ratio::from_integer(1)) } }
#[cfg(test)]
est une annotation destinée au compilateur. Elle indique que la section qui suit est destinée à l’environnement de test et donc ne sera pas embarquée dans le binaire de production.
Ensuite est défini un module de tests avec mod tests {}
. Ce module importe ses dépendances à Ratio
et Ingredient
avec use
.
Viens ensuite notre test qui n’est autre qu’une fonction précédée de l’annotation #[test]
.
Dans ce test on crée un ingrédient avec une quantity
de 1/2.
Puis on vérifie que la quantity
du résultat d’une multiplication par 2 nous donne un Ratio
équivalent à 1.
À ce stade vous pouvez jouer avec le fichier ingredient.rs dans le Playground
Le parser
Notre recette est une chaîne de caractères multi-lignes. Nous allons l’analyser pour retourner une collection d’ingrédients.
Notre analyseur est lui aussi un module nommé parser
. Il expose une fonction publique :
pub fn parse(input: &str) -> Vec<Ingredient> { input.lines().filter_map(&parse_line).collect() }
Cette fonction prend en paramètre un &str
que nous avons déjà rencontré plus haut.
Elle renvoi une collection de type vecteur Vec
.
Le type Vec
attend lui aussi un paramètre, qui est le type de ses éléments (Ingredient
).
lines()
appelé sur input
renvoi un itérateur sur les lignes du &str
.
Ensuite est appelé filter_map
qui fonctionne comme un map
classique à quelques détails près, nous y reviendrons plus loin.
L’argument &parse_line
est une référence à la fonction parse_line
qui sera appliquée sur chaque ligne.
Le tout renvoi un iterator
de nouveau.
Pour terminer nous appelons collect
qui s’applique aux itérateurs pour renvoyer une collection (Vec
).
Revenons à notre fonction parse_line
.
// parser.rs // ... fn parse_line(line: &str) -> Option<Ingredient> { let splits: Vec<&str> = line.splitn(2, ' ').collect(); match splits[..] { [quantity, label] => Some(Ingredient { quantity: str_to_ratio(quantity), label: label.to_string(), }), _ => None, } }
Cette fonction prend en argument un &str
qui représente une ligne de texte et l’analyse pour en ressortir un Ingredient
.
Cependant il est possible que la ligne ne soit pas formatée correctement.
Dans ce cas quel est le retour de la fonction ?
Rust n’a pas de nil
ou null
, comme d’autres langages il dispose de valeurs mises en boites, ici Option
qui dénote l’éventualité qu’il y ait une valeur.
Option
est une énumération de valeurs possibles :
Some(Ingredient)
None
line.splitn(2, ' ').collect()
divise la ligne en 2 parties à partir du premier espace, et retourne une collection.
let splits: Vec<&str>
permet de préciser le type de la collection attendue qui ne peut pas être toujours déduit par le compilateur.
splits
est ensuite déstructuré avec match
qui permet de faire des branches en fonction des motifs (pattern matching).
Soit nous obtenons 2 éléments pour construire Some(Ingredient)
soit la ligne est mal formatée et donc None
.
Lors de la construction de Ingredient
, to_string
est appelé sur label
.
label
localement est de type &str
(référence empruntée) alors que la structure Ingredient
attend un String
(valeur possédée), to_string
opère la copie comme vu précédemment avec clone
.
Puisque nous obtenons un résultat de type Option
, dans la fonction parse
nous obtenons un Iterator<Option>
.
Pour obtenir un Iterator<Ingredient>
il suffit d’utiliser filter_map
qui va ignorer les résultats de type None
et déballer l’ingrédient du Some(Ingredient)
.
Pour convertir quantity
qui est aussi de type &str
en Ratio
, nous utilisons la fonction str_to_ratio
// parser.rs //... fn str_to_ratio(s: &str) -> Ratio<u32> { let numerics: Vec<u32> = s .splitn(2, '/') .map(str::parse::<u32>) .filter_map(Result::ok) .collect(); match numerics[..] { [numer] => Ratio::from_integer(numer), [numer, denom] => Ratio::new(numer, denom), _ => panic!("Malformed recipe quantity: {:?}", numerics), } }
Cette fonction sépare le &str
en 2 parties à partir du /
puis transforme les résultats en u32
.
filter_map
est de nouveau utilisé, cette fois pour n’obtenir que le contenu des boites Result::ok
.
Ensuite le résultat est décomposé avec match
:
- une seule valeur c’est un
Ratio
depuis un entier. - deux valeurs c’est un
Ratio
à partir d’un numérateur et d’un dénominateur. - aucun des deux c’est une erreur.
panic!
est une macro comme tout ce qui se termine par un !
. Elle termine le programme comme une exception non interceptée.
En lecteur attentif, vous noterez qu’il aurait été préférable de renvoyer un Option<Ratio>
pour sécuriser les erreurs d’analyse de la chaîne de caractères. Ce qui n’est pas fait par besoin de démontrer panic!
dans l’article.
La chanson de la main
De retour dans notre fichier main.rs
voyons comment combiner tout ça.
// main.rs mod ingredient; mod parser;
Cette forme de déclaration de module précise que les modules ingredient
et parser
se trouvent dans les fichiers du même nom.
La fonction main
du fichier main.rs
est, sans surprise, celle qui est exécutée lors du lancement du programme.
// main.rs fn scale_ingredients(ins: Vec<Ingredient>, mult: u32) -> Vec<Ingredient> { ins.iter().map(|e| e.scale(mult)).collect() } fn main() { let scale: u32 = 2; let base_ingredients = parser::parse(RECIPE); for i in base_ingredients.iter() { println!("{}", i.scale(scale)) } }
Cette fonction déclare une variable locale scale
qui est le multiplicateur de la recette. Puis utilise la fonction parse
du module parser
pour obtenir les ingrédients depuis RECIPE
.
Ensuite nous itérons sur les ingrédients pour afficher l’ingrédient multiplié par l’échelle.
iter()
Nous permet d’obtenir un Iterator
depuis un Vec
.
println!
est la macro qui permet d’afficher sur la sortie standard.
Pour qu’une valeur puisse être formatée pour la sortie texte, elle doit implémenter le Trait
Display
.
Un Trait
s’apparente à une interface pour un comportement implémenté sur plusieurs types.
L’implémentation de Display
pour notre ingrédient se déclare ainsi :
// ingredient.rs impl std::fmt::Display for Ingredient { fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { write!(f, "{} {}", self.quantity, self.label) } }
Elle prend en argument la structure courante et une fonction de formatage
La macro write!
pousse les données formatées dans un tampon. Le format choisi est tout simplement d’afficher la quantité suivie du libellé.
Pour aller plus loin
L’ensemble du programme est disponible est disponible dans le Playground Rust, qui permet de jouer avec le code sans avoir à installer Rust.
Le code est également disponible sur GitHub.
cargo test
permettra de lancer les tests. cargo run
lancera l’application (en environnement de développement).
Pour continuer d’expérimenter avec Rust je recommande de tenter ces exercices :
- sécuriser la fonction
parser::str_to_ratio
pour supprimer lepanic!
- faire en sorte de pouvoir passer le multiplicateur à l’invocation du programme
- paralléliser l’exécution des
parse_line
avec l’outillage du langage
Un dernier conseil pour la faim et le dosage de la rouille :
Rust permet beaucoup d’optimisations de gestion de la mémoire. Cependant, la maîtrise de la recette est dans le dosage !
Utiliser des références à tout bout de chandelles vous obligera à affronter le bourreau shaker.
Dans un premier temps abusez de clone
, puis optimisez progressivement.
Toutes bonnes choses ayant une fin, il est temps de nous quitter.
Au revoir lecteur, je pars…