Go to Hackademy website

Du dosage à la rouille

Cédric Brancourt

Posté par Cédric Brancourt dans les catégories back

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 type String.
  • d’une quantity qui contient une fraction Ratio.

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 type u32

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 par mult. Ce qui retourne un nouveau Ratio
  • 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

Elle vient de loin…

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 :

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

L’équipe Synbioz.
Libres d’être ensemble

Articles connexes

Une brève histoire d'Elixir et Erlang/OTP

31/01/2019

Je développe depuis plusieurs années en Ruby. Depuis mon arrivée chez Synbioz, j’expérimente en plus avec Elixir de façon assez naturelle. En quoi Elixir est-il différent, me demanderez-vous ? Pour...

Écrire une thread pool en Ruby

10/01/2019

Pouvoir exécuter plusieurs tâches en parallèle, que ce soit dans un script ou une application, peut être vraiment très utile, surtout dans le cas où le traitement de ces tâches peut être très long....

Translation temporelle

31/05/2018

Cette semaine, je me suis essayé à un nouveau format d’article qui se présente sous la forme d’une nouvelle de Science-Fiction. Je tiens en passant à remercier Valentin pour ses illustrations....

Authentifier l'accès à vos ressources avec Dragonfly

11/05/2017

Pour ceux qui ne connaissent pas Dragonfly, c’est une application Rack qui peut être utilisée seule ou via un middleware. Le fait que ce soit une application Rack la rend compatible avec toutes les...