introductionbases1bases2types_avancesgen_types_composesproprietemodules_visibilitetoolinggestion_erreursclosuresiterateurscollectionslifetimescliunsafe

Introduction

Crédits et licence

Ce cours est fortement inspiré de l'excellent tutoriel Rust-101 écrit par Ralf Jung https://www.ralfj.de/projects/rust-101/main.html. Il est publié sous la license CC BY-SA 4.0.

Préambule

Ce court texte n'a pas vocation à remplacer un cours complet, mais à rappeler les concepts importants en les illustrant à l'aide de codes courts.

Les codes discutés ont tous pour but de calculer la valeur minimale d'entiers contenus dans une liste. La difficulté et l'élégance de ces codes ira en augmentant pour illustrer de façon itératives les différents concepts du présents dans le langage.

Installation du compilateur Rust

Pour pratiquer le Rust, il est nécessaire d'installer le compilateur Rust. Il n'est pas recommandé d'utiliser votre gestionnaire de paquet, mais plutôt de télécharger toute la chaîne de compilation grâce à l'outil rustup. Ou alors d'exécuter la commande suivante dans un terminal

curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

Ce script installera pour vous le gestionnaire de paquet et de compilation cargo, le compilateur rustc, ainsi que le linter clippy et l'outil de formatage de code rustfmt.

Vous pouvez maintenant créer un nouveau projet rust (astucieusement nommé new_project) avec la commande

cargo new new_project

Cette commande crée un répertoire new_project, ainsi que les fichiers new_project/Cargo.toml et new_project/main.rs.

Vous pouvez compiler votre programme avec

cargo build

Puis l'exécuter à l'aide de la commande

cargo run

La commande cargo run dépend de l'étape de compilation, par conséquent si le code n'est pas compilé, alors la commande cargo run lancera la compilation avant d'exécuter votre programme.

Il est également possible de nettoyer les artéfacts de compilation ainsi que l'exécutable à l'aide de la commande

cargo clean

Génération

Il est possible de générer ce cours. Pour ce faire, vous pouvez télécharger les sources depuis le repo du cours rust-101. Puis il faut installer le programme mdbook (voir ce lien pour l'installation).

Depuis la racine du repo, il suffit d'exécuter les commandes

$ cd book
$ mdbook build

et vous retrouverez l'index dans le fichier

$ book/index.html

Références

Il existe un grand nombre de références pour le Rust. Vous en trouverez quelques-unes ci-dessous.

  • The book. Il s'agit de l'excellent de référence sur le Rust.
  • Rust by example. Une série d'exemple illustrant la syntaxe du langage et ses concepts.
  • Gentle introduction to Rust. Une introduction au Rust pour les personnes connaissant divers autres langages.
  • Rustlings. De courts exercices sur le langage Rust.

Discussion du code bases1

Concepts

Les concepts abordés dans cet exemple sont:

  1. Les variables mutables ou non, les constantes.
  2. Les structures de contrôle if ... else et for.
  3. L'utilisation de tableaux statiques.
  4. L'utilisation de macros pour la gestion d'erreurs ou les sorties dans le terminal.

Discussion

Chaque code Rust a un unique point d'entrée: la fonction fn main() {}. Ainsi, le code le plus simple (qui ne fait absolument rien) est.

fn main() {
}

Le corps de votre programme se trouvera donc entre les accolades.

Variables, variables mutables, et constantes

Dans l'ordre d'apparition, nous avons d'abord une constante nommée SIZE, dont le type est usize (entier non signé dont la taille dépend de l'architecture, 8 octets sur une architecture 64 bits) et qui vaut 9. Le nom du type vient après :.

#![allow(unused)]
fn main() {
const SIZE: usize = 9;
}

Ensuite nous déclarons un tableau statique (alloué sur la pile) d'entiers 32 bits et de taille SIZE.

#![allow(unused)]
fn main() {
const SIZE: usize = 9;
let tab: [i32; SIZE] = [10, 32, 12, 43, 52, 53, 83, 2, 9];
}

Vous notez le mot clé let qui permet de déclarer une variable immutables (les valeurs contenues dans tab ou sa taille ne pourront plus changer). On dit qu'on lie (ou bind en anglais) tab à la valeur du [10, 32, 12, 43, 52, 53, 83, 2, 9]. Plus bas nous déclarons au contraire une variable mutable (qui elle pourra changer de valeur au cours de l'exécution du programme).

#![allow(unused)]
fn main() {
const SIZE: usize = 9;
let tab: [i32; SIZE] = [10, 32, 12, 43, 52, 53, 83, 2, 9];
let mut min = tab[0];
}

et l'initialisons avec la valeur du 1er élément de tab (ici la valeur est 10).

Structures de contrôle

Voici deux extraits de code. Dans le premier

#![allow(unused)]
fn main() {
const SIZE: usize = 0;
if SIZE == 0 {
    panic!("Size is of tab = 0.");
}
}

nous testons si SIZE == 0 et utilisons la macro panic! qui lorsqu'elle est exécutée fait quitter le programme et affiche le message d'erreur en argument. Ainsi le code ci-dessus retourne:

   Compiling playground v0.0.1 (/playground)
    Finished dev [unoptimized + debuginfo] target(s) in 0.56s
     Running `target/debug/playground`
thread 'main' panicked at 'Size is of tab = 0.', src/main.rs:5:5
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

Dans le second cas, nous sommes dans une boucle for

#![allow(unused)]
fn main() {
const SIZE: usize = 9;
let tab: [i32; SIZE] = [10, 32, 12, 43, 52, 53, 83, 2, 9];
let mut min = tab[0];
for i in 1..SIZE {
    if min > tab[i] {
        min = tab[i];
    }
}
}

où l'indice i prend successivement les valeur 1 à SIZE-1 (la notation a..b veut dire de a à b non inclus) et assignons la valeur tab[i] à la variable mutable min. Si nous avions omis le mot clé mut lors de la déclaration de min l'assignation donnerait une erreur (cliquez sur "play" pour la démonstration)

#![allow(unused)]
fn main() {
const SIZE: usize = 9;
let tab: [i32; SIZE] = [10, 32, 12, 43, 52, 53, 83, 2, 9];
let min = tab[0];
for i in 1..SIZE {
    if min > tab[i] {
        min = tab[i];
    }
}
}

Macros

Outre la macro panic!() nous utilisons ici deux macros permettant d'afficher des chaînes de caractère dans le terminal. Les macros sont toujours identifiées à l'aide du ! se trouvant à la fin de l'appel, comme pour panic!(), print!() (affiche la chaîne de caractère en argument) ou println!() (qui est comme print!() mais retourne à la ligne après avoir affiché). Comme on le voit dans les lignes suivantes

#![allow(unused)]
fn main() {
const SIZE: usize = 9;
let tab: [i32; SIZE] = [10, 32, 12, 43, 52, 53, 83, 2, 9];
let mut min = tab[0];
for i in 1..SIZE {
    if min > tab[i] {
        min = tab[i];
    }
}
println!("Among the numbers in the list:");
for i in 0..SIZE {
    print!("{} ", tab[i]);
}
println!();
}

le formatage de la ligne de caractère se fait à l'aide des accolades {} et les macros print!() / println!() prennent un nombre d'arguments variables. A chaque {} doit correspondre une variable dont on veut afficher le contenu.

Il est également possible de numéroter chaque {}. Par exemple

#![allow(unused)]
fn main() {
println!("{1} {0}", "abc", "def");
}

Affichera def abc.

Rustlings

Les rustlings à faire dans ce chapitre sont les suivants:

Introduction

$ rustlings run intro1
$ rustlings run intro2

Les variables

$ rustlings run variables1
$ rustlings run variables2
$ rustlings run variables3
$ rustlings run variables4
$ rustlings run variables5

Les types primitifs

$ rustlings run primitive_types1
$ rustlings run primitive_types2
$ rustlings run primitive_types3

Discussion du code part01

Concepts

Les concepts abordés dans cet exemple sont:

  1. Les fonctions.
  2. Les if ... else sont des expressions.
  3. Itérer sur les éléments d'un tableaux directement avec la boucle for.

Discussion

Les fonctions

Dans cette seconde itération de notre programme calculant le minimum d'une liste de nombres, nous introduisons les fonctions en Rust.

La syntaxe d'une fonction est la suivante:

fn function_name(arg1: type, arg2: type, ...) -> ReturnType

Une fonction est annotée avec le mot-clé fn suivi de son identifiant. Son type de retour se trouve à droite après la flèche (en ASCII-art) ->. Si une fonction n'a pas de type de retour explicite (il n'y a pas de flèche) le type de retour est () qui rappelle le type void de C (aka sans type, mais oui c'est un type en Rust). Entre parenthèses se trouvent les arguments de la fonction suivi de leur type. Le nombre d'arguments est supérieur ou égal à zéro.

Une fonction est ensuite appelée par son identifiant et ses éventuels arguments.

const SIZE: usize = 9;
fn read_command_line() -> [i32; SIZE] {
    [10, 32, 12, 43, 52, 53, 83, 2, 9]
    // équivalent à return [10, 32, 12, 43, 52, 53, 83, 2, 9];
}
fn main() {
    read_command_line();
}

La fonction read_command_line() ne prend ainsi aucun argument et retourne un tableau statique d'entier 32 bits et de taille SIZE. Pour qu'une fonction retourne une valeur, on a deux solutions:

  1. Si la valeur est la dernière expression de la fonction, on peut juste mettre la valeur (ou la variable) sans point virgule à la fin de la ligne.
  2. On peut, de façon similaire au C, utiliser le mot clé return val; On constate ici que les tableaux statiques (contrairement à ce qui se passe dans le langage C) peuvent être retournés par une fonction (le tableau n'est pas désalloué au moment du retour).

La vérification de la taille du tableau se fait dans la fonction check_size(size: usize) qui prend donc un argument de type usize et ne retourne rien.

fn check_size(size: usize) {
    if size == 0 {
        panic!("Size is of tab = 0.");
    }
}
fn main() {
check_size(10); // runs alright
check_size(0);  // panics
}

À l'inverse la fonction print_tab(tab) prend en argument un tableau statique et ne retourne rien.

const SIZE: usize = 9;
fn read_command_line() -> [i32; SIZE] {
    [10, 32, 12, 43, 52, 53, 83, 2, 9]
}
fn print_tab(tab: [i32; SIZE]) {
    for t in tab {
        print!("{} ", t);
    }
    println!();
}
fn main() {
let tab = read_command_line();
print_tab(tab);
}

Les fonctions min_i32(lhs, rhs) retourne la plus petite valeur de deux entiers signés de 32 bits.

fn min_i32(lhs: i32, rhs: i32) -> i32 {
    if lhs < rhs {
        lhs
    } else {
        rhs
    }
}
fn main() {
let min = min_i32(2, 10);
println!("{min}");    
}

En Rust le if est une expression

Mais si vous étudiez attentivement ce code, on constate que le corps de la fonction est étrange. On voit qu'aucune ligne ne possède de point terminal. Cela voudrait dire que lhs ou rhs sont la dernière expression de la fonction. En fait ce qui se passe c'est que le if ... else est une expression. De façon similaire au if ternaire en C, le if ... else en Rust retourne une valeur. On peut écrire de façon équivalente au code ci-dessus

fn min_i32(lhs: i32, rhs: i32) -> i32 {
    let min = if lhs < rhs {
        lhs
    } else {
        rhs
    };
    min
}
fn main() {
let min = min_i32(2, 10);
println!("{min}");    
}

On voit qu'ici à la fin du bloc du else nous avons un point virgule qui indique la fin de la ligne. Ainsi lhs ou rhs sont liés à la variable immutable min.

Cette notion d'expression est très importante. Comme nous l'avons expliqué précédemment la dernière expression d'une fonction est évaluée puis retournée par cette dernière.

Un bloc de code qui produit un résultat est une expression.

En Rust, plusieurs structures de contrôle tel que le If sont des expression. Il n'est pas nécessaire de connaître la valeur de l'expression pour la retourner.

Nous pourrions considérer par exemple les fonctions suivante :

#![allow(unused)]
fn main() {
fn add(lhs: i32, rhs: i32) -> i32 {
    lhs + rhs
}

fn add_one(val: i32) -> i32 {
    add(1, val)
}
}

La boucle for revisitée

La dernière fonction à discuter est find_min(tab) qui retourne la valeur minimale se trouvant dans tab.

const SIZE: usize = 9;
fn read_command_line() -> [i32; SIZE] {
    [10, 32, 12, 43, 52, 53, 83, 2, 9]
}
fn check_size(size: usize) {
    if size == 0 {
        panic!("Size is of tab = 0.");
    }
}
fn min_i32(lhs: i32, rhs: i32) -> i32 {
    if lhs < rhs {
        lhs
    } else {
        rhs
    }
}
fn find_min(tab: [i32; SIZE]) -> i32 {
    check_size(SIZE);
    let mut min = i32::MAX;
    for t in tab {
        min = min_i32(min, t);
    }
    min
}
fn main() {
let min = find_min(read_command_line());
println!("The minimal value is: {min}");
}

Cette version du calcul du minimum contient une différence importante dans la syntaxe de la boucle for. En effet, ici on itère pas sur un indice et on accède ensuite aux éléments du tableau. La syntaxe

for val in tab

permet d'itérer directement sur les valeurs contenues dans tab. Au début de chaque tour de la boucle for, val prend la valeur "courante" du tableau. A la fin du bloc du for, la valeur courante prend la valeur "suivante" dans le tableau si elle existe. En fait le tableau est implicitement converti en itérateur (plus de détails plus tard sur le sujet).

Dans d'autres langages on appelle ça une boucle "for each" qui traduit en français signifie pour chaque.

Le code ci-dessus est strictement équivalent à

const SIZE: usize = 9;
fn read_command_line() -> [i32; SIZE] {
    [10, 32, 12, 43, 52, 53, 83, 2, 9]
}
fn check_size(size: usize) {
    if size == 0 {
        panic!("Size is of tab = 0.");
    }
}
fn min_i32(lhs: i32, rhs: i32) -> i32 {
    if lhs < rhs {
        lhs
    } else {
        rhs
    }
}
fn find_min(tab: [i32; SIZE]) -> i32 {
    check_size(SIZE);
    let mut min = i32::MAX;
    for i in 0..SIZE {
        min = min_i32(min, tab[i]);
    }
    min
}
fn main() {
let min = find_min(read_command_line());
println!("The minimal value is: {min}");
}

Le code

const SIZE: usize = 9;
fn read_command_line() -> [i32; SIZE] {
    [10, 32, 12, 43, 52, 53, 83, 2, 9]
}
fn check_size(size: usize) {
    if size == 0 {
        panic!("Size is of tab = 0.");
    }
}
fn print_tab(tab: [i32; SIZE]) {
    for t in tab {
        print!("{} ", t);
    }
    println!();
}
fn min_i32(lhs: i32, rhs: i32) -> i32 {
    if lhs < rhs {
        lhs
    } else {
        rhs
    }
}
fn find_min(tab: [i32; SIZE]) -> i32 {
    check_size(SIZE);
    let mut min = i32::MAX;
    for t in tab {
        min = min_i32(min, t);
    }
    min
}
fn main() {
    let tab = read_command_line();
    println!("Among the numbers in the list:");
    print_tab(tab);
    let min = find_min(tab);
    println!("The minimal value is: {min}");
}

Rustlings

Les rustlings à faire dans ce chapitre sont les suivants:

Les ifs

$ rustlings run if1
$ rustlings run if2
$ rustlings run if3

Les fonctions

$ rustlings run functions1
$ rustlings run functions2
$ rustlings run functions3
$ rustlings run functions4
$ rustlings run functions5

Quiz

$ rustlings run quiz1

Discussion du code bases2

Concepts

Les concepts abordés dans cet exemple sont:

  1. Les types énumérés.
  2. Les fonctions statiques.
  3. Les arguments des fonctions en Rust.
  4. Le pattern matching.

Discussion

Les types énumérés

Dans ce code, nous introduisons un type complexe: le type énuméré NumberOrNothing

#![allow(unused)]
fn main() {
enum NumberOrNothing {
    Nothing,
    Number(i32),
}
}

Un type énuméré est très utile quand on veut décrire un objet ayant un nombre fini de valeurs possibles. Ici nous avons deux variantes pour le type NumberOrNothing:

  1. NumberOrNothing::Nothing qui décrit l’absence de valeur,
  2. NumberOrNothing::Number(i32) qui décrit la présence d'une valeur et qui encapsule un entier 32 bits.

On note ici, que pour accéder à une variante de NumberOfNothing il est nécessaire d'utiliser la syntaxe Type::Variante.

Les fonctions statiques

Sur ce type énuméré, nous définissons deux fonctions statiques à l'aide de la syntaxe

impl NumberOrNothing {
// fonction statiques
}

Ici nous avons deux fonctions. La première, fn new(val), créer une instance de NumberOrNothing à partir d'un entier et le stocke dans la variante NumberOrNothing::Number(val). En général la fonction new sur un type est une sorte de "constructeur": elle permet de créer une nouvelle instance d'un type. La notion d'instance est très importante. Si nous prenons un type connu i32, il s'agit du type et let i:i32 = 5; est une instance de ce type.

enum NumberOrNothing {
    Nothing,
    Number(i32),
}
impl NumberOrNothing {
    fn new(val: i32) -> Self {
        NumberOrNothing::Number(val)
    }
}
fn main() {
let num = NumberOrNothing::new(10);
}

On voit ici que pour appeler la fonction new on la préfixe par le nom du type suivi de ::, soit NumberOrNothing::new(val).

La seconde fonction, fn print(self), sert à afficher le contenu d'une instance d'un NumberOrNothing.

enum NumberOrNothing {
    Nothing,
    Number(i32),
}
impl NumberOrNothing {
    fn new(val: i32) -> Self {
        NumberOrNothing::Number(val)
    }
    fn print(self) {
        match self {
            NumberOrNothing::Nothing => println!("No number."),
            NumberOrNothing::Number(val) => println!("The number is: {}", val),
        }
    }
}
fn main() {
let num = NumberOrNothing::new(10);
num.print();
// NumberOrNothing::print(num); équivalent
}

Les arguments des fonctions en Rust

Il y a plusieurs choses à noter dans cette fonction. La première est qu'elle prend en argument le mot-clé self qui se réfère à l'instance sur laquelle la fonction est appelée. Ainsi quand on veut appeler la fonction print() on utilise la syntaxe du sélecteur sur la variable sur laquelle s'appelle la fonction: num.print(). Il existe une syntaxe équivalente qui serait:

NumberOrNothing::print(num);

Avant de nous intéresser au code qui est présent dans le corps de la fonction print(), essayons d'appeler deux fois de suite la fonction print():

enum NumberOrNothing {
    Nothing,
    Number(i32),
}
impl NumberOrNothing {
    fn new(val: i32) -> Self {
        NumberOrNothing::Number(val)
    }
    fn print(self) {
        match self {
            NumberOrNothing::Nothing => println!("No number."),
            NumberOrNothing::Number(val) => println!("The number is: {}", val),
        }
    }
}
fn main() {
    let num = NumberOrNothing::new(10);
    NumberOrNothing::print(num);
    NumberOrNothing::print(num);
}

On constate que la compilation échoue avec un message intéressant

   Compiling playground v0.0.1 (/playground)
error[E0382]: use of moved value: `num`
  --> src/main.rs:19:28
   |
17 |     let num = NumberOrNothing::new(10);
   |         --- move occurs because `num` has type `NumberOrNothing`, which does not implement the `Copy` trait
18 |     NumberOrNothing::print(num);
   |     --------------------------- `num` moved due to this method call
19 |     NumberOrNothing::print(num);
   |                            ^^^ value used here after move
   |
note: consider changing this parameter type in method `print` to borrow instead if owning the value isn't necessary
  --> src/main.rs:9:14
   |
9  |     fn print(self) {
   |        ----- ^^^^ this parameter takes ownership of the value
   |        |
   |        in this method
note: `NumberOrNothing::print` takes ownership of the receiver `self`, which moves `num`
  --> src/main.rs:9:14
   |
9  |     fn print(self) {
   |              ^^^^

For more information about this error, try `rustc --explain E0382`.

En fait, cela signifie que la fonction est devenue propriétaire de la valeur de num lors du premier appel à print(), puis lorsque la fonction s'est terminée, la variable et les données qu'elle contient a été détruite automatiquement. Ainsi la variable num ne contient "plus rien" (la variable n'est plus liée à la valeur Number(10)) et ne peut pas rappeler print(). En fait, il s'agit du comportement par défaut des fonctions en Rust. Elles deviennent propriétaires (owners) des valeurs passées en argument et que la valeur est déplacée (moved).

Mais alors comment cela se fait que les fonctions

print_tab(tab);
let min = find_min(tab);

ne donnent pas de message d'erreur à la compilation? En fait, ici tab est copié implicitement avant d'être passé à ces deux fonctions. Ainsi, c'est cette valeur copiée qui est déplacée dans la fonction et qui est détruite à la fin. Cela est le cas pour des types "simples" comme tous les types de base de Rust et les tableaux statiques si le type des éléments est également un type "copiable" (on dit qu'ils sont Copy). Ainsi, les types i32, usize et [N; i32] utilisés dans ce programme sont Copy. On verra dans les chapitres suivants plus de détails sur ce fonctionnement.

Le pattern matching

L'autre nouveauté introduite dans ce code est le pattern matching.

match self {
    NumberOrNothing::Nothing => println!("No number."),
    NumberOrNothing::Number(val) => println!("The number is: {}", val),
}

Ici, on vérifie quelle variante du type est encapsulé par notre instance représentée par la variable self. Dans le cas où c'est NumberOrNothing::Nothing, on affiche No number., et dans le cas où c'est NumberOrNothing::Number(val) on lie la valeur encapsulée dans la variante Number et on affiche cette valeur. Le compilateur détectera s'il manque une variante du type énuméré et produira une erreur si cela est le cas.

La syntaxe en général est a suivante

match variable {
    variante_1 => {
        expression
    },
    variante_2 => {
        expression
    },
    // ...
    variante_n => {
        expression
    },
    _ => {
        expression
    },
}

où le dernier _ est un remplacement pour toutes les variantes pas traitées du type énuméré (il est similaire au default du switch ... case de C).

enum NumberOrNothing {
    Nothing,
    Number(i32),
}
impl NumberOrNothing {
    fn new(val: i32) -> Self {
        NumberOrNothing::Number(val)
    }
    fn print(self) {
        match self {
            NumberOrNothing::Nothing => println!("No number."),
            NumberOrNothing::Number(val) => println!("The number is: {}", val),
        }
    }
}
const SIZE: usize = 9;
fn read_command_line() -> [i32; SIZE] {
    [10, 32, 12, 43, 52, 53, 83, 2, 9]
}
fn print_tab(tab: [i32; SIZE]) {
    for t in tab {
        print!("{} ", t);
    }
    println!();
}
fn min_i32(lhs: i32, rhs: i32) -> i32 {
    if lhs < rhs {
        lhs
    } else {
        rhs
    }
}
fn find_min(tab: [i32; SIZE]) -> NumberOrNothing {
    let mut min = NumberOrNothing::Nothing;
    for t in tab {
        match min {
            NumberOrNothing::Nothing => min = NumberOrNothing::new(t),
            NumberOrNothing::Number(val) => min = NumberOrNothing::new(min_i32(val, t)),
        }
    }
    min
}
fn main() {
    let tab = read_command_line();
    println!("Among the numbers in the list:");
    print_tab(tab);
    let min = find_min(tab);
    min.print();
    let nothing = NumberOrNothing::Nothing;
    NumberOrNothing::print(nothing);
}

Rustlings

Les rustlings à faire dans ce chapitre sont les suivants:

Les types énumérés

$ rustlings run enums1
$ rustlings run enums2
$ rustlings run enums3

La structures

$ rustlings run structs1
$ rustlings run structs2
$ rustlings run structs3

Discussion du code gen_types_composes

Dans cette partie nous discutons de ce code.

Concepts

Les concepts abordés dans cet exemple sont:

  1. La généricité et les traits.
  2. Le trait Minimum.
  3. Les tuples
  4. Les traits Clone et Copy.

Discussion

La généricité et les traits

En Rust (comme dans beaucoup de langages) on a un moyen d'éviter de dupliquer du code en utilisant le concept de généricité. Il s'agit ici de remplacer un type par un caractère générique lors de la définition d'un type ou d'une fonction.

Jusqu'ici nous avions une structure NumberOrNothing qui contenait soit Nothing soit Number(i32) qui encapsule un entier 32 bits. Afin d'éviter de devoir réécrire tout le code pour chaque type (u64, double, ou n'importe quel autre type) on va réécrire notre type énuméré en le renommant astucieusement SomethingOrNothing (en effet, il se pourrait que nous ne voulions plus uniquement trouver le plus petit nombre dans une liste, mais on pourrait vouloir trouver le mot le plus "petit" dans l'ordre lexicographique). Ainsi on a

#![allow(unused)]
fn main() {
enum SomethingOrNothing<T> {
    Nothing,
    Something(T),
}
}

Il faut noter ici la présence du caractère générique T, qui est déclaré comme générique lors de la définition du type SomethingOrNothing<T>, puis est utilisé à la place de i32 dans Something(T).

Maintenant qu'on a changé la définition de noter type, plus rien fonctionne et on doit adapter le reste du code. Pour aller dans l'ordre, on doit modifier l'implémentation de la fonction SomethingOrNothing::new(val)

#![allow(unused)]
fn main() {
enum SomethingOrNothing<T> {
    Nothing,
    Something(T),
}
impl<T> SomethingOrNothing<T> {
    fn new(val: T) -> SomethingOrNothing<T> {
        SomethingOrNothing::Something(val)
    }
}
}

On voit ici qu'il faut annoter tout le bloc impl<T> SomethingOrNothing<T> avec le type générique afin qu'il puisse être réutilisé dans les fonctions statiques. En effet, si on omet les <T> on une erreur de compilation

#![allow(unused)]
fn main() {
enum SomethingOrNothing<T> {
    Nothing,
    Something(T),
}
impl SomethingOrNothing<T> {
    fn new(val: T) -> SomethingOrNothing<T> {
        SomethingOrNothing::Something(val)
    }
}
}

Afin d'illustrer une particularité de la généricité de Rust, nous avons également réécrit la fonction print(val)

#![allow(unused)]
fn main() {
enum SomethingOrNothing<T> {
    Nothing,
    Something(T),
}
// Print function
// We know the generic type T must be Displayable
fn print<T: std::fmt::Display>(val: SomethingOrNothing<T>) {
    match val {
        SomethingOrNothing::Nothing => println!("Nothing."),
        SomethingOrNothing::Something(val) => println!("Something is: {}", val),
    }
}
}

On voit ici qu'il y a une annotation particulière dans l'entête de la fonction, T: std::fmt::Display

fn print<T: std::fmt::Display>(val: SomethingOrNothing<T>)

Cette syntaxe dit au compilateur que le type générique implémente une fonctionnalité particulière, nommée trait, qui dit au programme comment afficher une variable du type générique T. On voit qu'on doit afficher la valeur val encapsulée dans Something(val)

SomethingOrNothing::Something(val) => println!("Something is: {}", val),

Hors si on ne dit pas à notre programme comment faire cet affichage, il sera bien embêté. Ici, nous devons donc préciser qu'il est nécessaire que T implémente le trait Display sinon le programme ne compilera pas (cliquez sur play pour le vérifier)

#![allow(unused)]
fn main() {
enum SomethingOrNothing<T> {
    Nothing,
    Something(T),
}
fn print<T>(val: SomethingOrNothing<T>) {
    match val {
        SomethingOrNothing::Nothing => println!("Nothing."),
        SomethingOrNothing::Something(val) => println!("Something is: {}", val),
    }
}
}

On retrouve la contrainte d'implémenter le trait Display dans la fonction print_tab(tab). Corriger le code ci-dessous pour qu'il compile

const SIZE: usize = 9;
fn read_command_line() -> [i32; SIZE] {
    [10, 32, 12, 43, 52, 53, 83, 2, 9]
}
fn print_tab<T>(tab: [T; SIZE]) {
    for t in tab {
        print!("{} ", t);
    }
    println!();
}
fn main() {
    print_tab(read_command_line());
}

Le trait Minimum

La fonctionnalité principale dont nous avons besoin pour que notre code fonctionne est de pouvoir trouver le minimum d'une liste de SomethingOrNothing<T> (voir l'appel à la fonction current_minimum.min(SomethingOrNothing::new(t))).

fn find_min<T: Minimum>(tab: [T; SIZE]) -> SomethingOrNothing<T> {
    let mut current_minimum = SomethingOrNothing::Nothing;
    // Here is T is not Copyable tab is consumed and cannot be returned
    for t in tab {
        current_minimum = current_minimum.min(SomethingOrNothing::new(t));
    }
    current_minimum
}

Ainsi on doit annoter T pour qu'il puisse calculer la plus petite valeur entre deux SomethingOrNothing<T>. On va donc devoir écrire définir notre premier trait. On définit un trait à l'aide de la syntaxe suivante

#![allow(unused)]
fn main() {
trait Minimum: Copy {
    fn min(self, rhs: Self) -> Self;
}
}

Ici le trait Minimum sera implémenté sur un type qui implémente le trait Copy (un trait qui garantit qu'on sait comment copier une valeur). Notre trait n'a que la fonction min (le nombre de fonction dans un trait est arbitraire, il peut même être nul). L'entête de la fonction nous dit que la fonction min a deux argument, la variable sur laquelle elle est appelée, et une variable du même type (annoté avec le type Self, avec un "s" majuscule, contrairement à self qui fait référence à une variable) et retourne également une valeur du même type. Il est important de vous rappeler qu'ici on ne sait pas encore quel est le type sur lequel on implémente cette fonction.

L'implémentation de Minimum pour SomethingOrNothing<T> se fait comme suit

trait Minimum: Copy {
    fn min(self, rhs: Self) -> Self;
}
impl<T: Minimum> Minimum for SomethingOrNothing<T> {
    fn min(self, rhs: Self) -> Self {
        match (self, rhs) {
            (SomethingOrNothing::Nothing, SomethingOrNothing::Nothing) => {
                SomethingOrNothing::Nothing
            }
            (SomethingOrNothing::Something(lhs), SomethingOrNothing::Something(rhs)) => {
                SomethingOrNothing::new(lhs.min(rhs))
            }
            (SomethingOrNothing::Nothing, SomethingOrNothing::Something(rhs)) => {
                SomethingOrNothing::new(rhs)
            }
            (SomethingOrNothing::Something(lhs), SomethingOrNothing::Nothing) => {
                SomethingOrNothing::new(lhs)
            }
        }
    }
}

Pour implémenter un trait sur un type on utilise la syntaxe

impl Trait for Type

puis implémenter toutes les fonctions se trouvant dans la définition du trait. Ici, nous n'avons que la fonction min à implémenter.

Le type SomethingOrNothing est générique, donc il faut à nouveau notre code pour en tenir compte. On voit que le type générique doit lui-même implémenter le trait Minimum pour que cette fonction compile: on voit l'utilisation de la fonction min

SomethingOrNothing::new(lhs.min(rhs))

Nous verrons le détail de la syntaxe de cette fonction dans la section sur les tuples.

Comme nous utilisons des SomethingOrNothing<i32> dans ce code, nous devons implémenter le trait Minimum pour des entiers. Ce qui est fait dans le bout de code suivant

#![allow(unused)]
fn main() {
trait Minimum: Copy {
    fn min(self, rhs: Self) -> Self;
}
impl Minimum for i32 {
    fn min(self, rhs: Self) -> Self {
        if self < rhs {
            self
        } else {
            rhs
        }
    }
}
}

Les tuples

On découvre ici, une syntaxe inconnue: les tuples. Un tuple est une collection d'un nombre arbitraire de valeurs dont les types peuvent être différents. Un tuple lui-même est une valeur dont le type est

(T1, T2, T3, ...)

T1, T2, T3, ... sont les types des membres du tuple. Un tuple peut être utilisé pour retourner plusieurs valeurs depuis une fonction par exemple.

Ainsi quand on fait du pattern mathching

match (self, rhs)

on va créer un tuple avec les valeurs de self et de rhs et vérifier les types de toutes les valeurs possibles pour ces types énumérées. On a donc 4 cas différents:

match (self, rhs) {
    (SomethingOrNothing::Nothing, SomethingOrNothing::Nothing) => {
        SomethingOrNothing::Nothing
    }
    (SomethingOrNothing::Something(lhs), SomethingOrNothing::Something(rhs)) => {
        SomethingOrNothing::new(lhs.min(rhs))
    }
    (SomethingOrNothing::Nothing, SomethingOrNothing::Something(rhs)) => {
        SomethingOrNothing::new(rhs)
    }
    (SomethingOrNothing::Something(lhs), SomethingOrNothing::Nothing) => {
        SomethingOrNothing::new(lhs)
    }
}

quand on a deux variantes Nothing, on retourne Nothing, quand on a une variante Nothing et une valeur Something(val) on retourne Something(val) et finalement quand on a Something(lhs) et un Something(rhs), c'est Something(lhs.min(rhs)) qui est retourné (donc la valeur la plus petite qui encapsulée). D'où la nécessité que T implémente le trait Minimum. Il faut noter à nouveau qu'il n'y a pas de ; dans les blocs des variantes du pattern matching et que les valeurs sont retournées (la structure de contrôle match est une expression).

Les traits Clone et Copy

En Rust, il existe deux traits essentiels Copy et Clone. Le trait Copy permet de copier les instances d'un type bit à bit. La copie est une action implicite. Par exemple, dans le code ci-dessous

let y : i32 = 5;
let x = y;

la valeur de y (ici 5) est copiée dans une zone mémoire nouvellement allouée qui ensuite est liée à la variable x. Dans notre code nous décidons d'autoriser la copie de notre type énuméré en implémentant le trait Copy


/// In gen_types_composes we introduce genericity through traits and in particular, [Copy],
/// [Clone], [std::fmt::Display] .
enum SomethingOrNothing<T> {
    Nothing,
    Something(T),
}

impl<T> SomethingOrNothing<T> {
    fn new(val: T) -> SomethingOrNothing<T> {
        SomethingOrNothing::Something(val)
    }
}

// Print function
// We know the generic type T must be Displayable
fn print<T: std::fmt::Display>(val: SomethingOrNothing<T>) {
    match val {
        SomethingOrNothing::Nothing => println!("Nothing."),
        SomethingOrNothing::Something(val) => println!("Something is: {}", val),
    }
}

impl<T: Clone> Clone for SomethingOrNothing<T> {
    fn clone(&self) -> Self {
        match self {
            SomethingOrNothing::Nothing => SomethingOrNothing::Nothing,
            SomethingOrNothing::Something(val) => SomethingOrNothing::new(val.clone()),
        }
    }
}

impl<T: Copy> Copy for SomethingOrNothing<T> {}

// If we remove Copy, we have a problem with the t in tab
// in the computation of the minimum.
trait Minimum: Copy {
    fn min(self, rhs: Self) -> Self;
}

impl<T: Minimum> Minimum for SomethingOrNothing<T> {
    fn min(self, rhs: Self) -> Self {
        match (self, rhs) {
            (SomethingOrNothing::Nothing, SomethingOrNothing::Nothing) => {
                SomethingOrNothing::Nothing
            }
            (SomethingOrNothing::Something(lhs), SomethingOrNothing::Something(rhs)) => {
                SomethingOrNothing::new(lhs.min(rhs))
            }
            (SomethingOrNothing::Nothing, SomethingOrNothing::Something(rhs)) => {
                SomethingOrNothing::new(rhs)
            }
            (SomethingOrNothing::Something(lhs), SomethingOrNothing::Nothing) => {
                SomethingOrNothing::new(lhs)
            }
        }
    }
}

// i32 is Copyable as a very basic type as f32, f64, etc.
// Arrays for example are not copyable.
impl Minimum for i32 {
    fn min(self, rhs: Self) -> Self {
        if self < rhs {
            self
        } else {
            rhs
        }
    }
}

const SIZE: usize = 9;

fn read_command_line() -> [i32; SIZE] {
    [10, 32, 12, 43, 52, 53, 83, 2, 9]
}

// Prints tab and returns tab.
// Tab would be destructed at the end of the function otherwise.
fn print_tab<T: std::fmt::Display>(tab: [T; SIZE]) {
    for t in tab {
        print!("{} ", t);
    }
    println!();
}

fn find_min<T: Minimum>(tab: [T; SIZE]) -> SomethingOrNothing<T> {
    let mut current_minimum = SomethingOrNothing::Nothing;
    // Here is T is not Copyable tab is consumed and cannot be returned
    for t in tab {
        current_minimum = current_minimum.min(SomethingOrNothing::new(t));
    }
    current_minimum
}

fn main() {
    let tab = read_command_line();
    println!("Among the Somethings in the list:");
    print_tab(tab);
    let min = find_min(tab);
    print(min);
}

Comme on peut le voir ici, il n'y a pas de fonction à implémenter avec Copy, ce trait permet uniquement d'effectuer une copie binaire des données.

Il est également important de noter qu'afin que notre type SomethingOrNothing<T> implémente le trait Copy, il est nécessaire que le type paramètrique T implémente lui aussi Copy. Ce qui veut dire plus simplement qu'un type complexe ne peut pas implémenter le trait Copy si les types qu'il contient ne sont pas copiable.

On peut aussi remarquer qu'il est possible d'indiquer la nécessité que les types implémentant un trait soit copiables. Par exemple


/// In gen_types_composes we introduce genericity through traits and in particular, [Copy],
/// [Clone], [std::fmt::Display] .
enum SomethingOrNothing<T> {
    Nothing,
    Something(T),
}

impl<T> SomethingOrNothing<T> {
    fn new(val: T) -> SomethingOrNothing<T> {
        SomethingOrNothing::Something(val)
    }
}

// Print function
// We know the generic type T must be Displayable
fn print<T: std::fmt::Display>(val: SomethingOrNothing<T>) {
    match val {
        SomethingOrNothing::Nothing => println!("Nothing."),
        SomethingOrNothing::Something(val) => println!("Something is: {}", val),
    }
}

impl<T: Clone> Clone for SomethingOrNothing<T> {
    fn clone(&self) -> Self {
        match self {
            SomethingOrNothing::Nothing => SomethingOrNothing::Nothing,
            SomethingOrNothing::Something(val) => SomethingOrNothing::new(val.clone()),
        }
    }
}

impl<T: Copy> Copy for SomethingOrNothing<T> {}

// If we remove Copy, we have a problem with the t in tab
// in the computation of the minimum.
trait Minimum: Copy {
    fn min(self, rhs: Self) -> Self;
}

impl<T: Minimum> Minimum for SomethingOrNothing<T> {
    fn min(self, rhs: Self) -> Self {
        match (self, rhs) {
            (SomethingOrNothing::Nothing, SomethingOrNothing::Nothing) => {
                SomethingOrNothing::Nothing
            }
            (SomethingOrNothing::Something(lhs), SomethingOrNothing::Something(rhs)) => {
                SomethingOrNothing::new(lhs.min(rhs))
            }
            (SomethingOrNothing::Nothing, SomethingOrNothing::Something(rhs)) => {
                SomethingOrNothing::new(rhs)
            }
            (SomethingOrNothing::Something(lhs), SomethingOrNothing::Nothing) => {
                SomethingOrNothing::new(lhs)
            }
        }
    }
}

// i32 is Copyable as a very basic type as f32, f64, etc.
// Arrays for example are not copyable.
impl Minimum for i32 {
    fn min(self, rhs: Self) -> Self {
        if self < rhs {
            self
        } else {
            rhs
        }
    }
}

const SIZE: usize = 9;

fn read_command_line() -> [i32; SIZE] {
    [10, 32, 12, 43, 52, 53, 83, 2, 9]
}

// Prints tab and returns tab.
// Tab would be destructed at the end of the function otherwise.
fn print_tab<T: std::fmt::Display>(tab: [T; SIZE]) {
    for t in tab {
        print!("{} ", t);
    }
    println!();
}

fn find_min<T: Minimum>(tab: [T; SIZE]) -> SomethingOrNothing<T> {
    let mut current_minimum = SomethingOrNothing::Nothing;
    // Here is T is not Copyable tab is consumed and cannot be returned
    for t in tab {
        current_minimum = current_minimum.min(SomethingOrNothing::new(t));
    }
    current_minimum
}

fn main() {
    let tab = read_command_line();
    println!("Among the Somethings in the list:");
    print_tab(tab);
    let min = find_min(tab);
    print(min);
}

où la notation trait Minimum: Copy spécifie que Copy doit être implémenté quand on implémente Minimum. Ici tous les types qui implémentent le trait Minimum doivent également implémenter Copy. C'est le cas par exemple du type i32


/// In gen_types_composes we introduce genericity through traits and in particular, [Copy],
/// [Clone], [std::fmt::Display] .
enum SomethingOrNothing<T> {
    Nothing,
    Something(T),
}

impl<T> SomethingOrNothing<T> {
    fn new(val: T) -> SomethingOrNothing<T> {
        SomethingOrNothing::Something(val)
    }
}

// Print function
// We know the generic type T must be Displayable
fn print<T: std::fmt::Display>(val: SomethingOrNothing<T>) {
    match val {
        SomethingOrNothing::Nothing => println!("Nothing."),
        SomethingOrNothing::Something(val) => println!("Something is: {}", val),
    }
}

impl<T: Clone> Clone for SomethingOrNothing<T> {
    fn clone(&self) -> Self {
        match self {
            SomethingOrNothing::Nothing => SomethingOrNothing::Nothing,
            SomethingOrNothing::Something(val) => SomethingOrNothing::new(val.clone()),
        }
    }
}

impl<T: Copy> Copy for SomethingOrNothing<T> {}

// If we remove Copy, we have a problem with the t in tab
// in the computation of the minimum.
trait Minimum: Copy {
    fn min(self, rhs: Self) -> Self;
}

impl<T: Minimum> Minimum for SomethingOrNothing<T> {
    fn min(self, rhs: Self) -> Self {
        match (self, rhs) {
            (SomethingOrNothing::Nothing, SomethingOrNothing::Nothing) => {
                SomethingOrNothing::Nothing
            }
            (SomethingOrNothing::Something(lhs), SomethingOrNothing::Something(rhs)) => {
                SomethingOrNothing::new(lhs.min(rhs))
            }
            (SomethingOrNothing::Nothing, SomethingOrNothing::Something(rhs)) => {
                SomethingOrNothing::new(rhs)
            }
            (SomethingOrNothing::Something(lhs), SomethingOrNothing::Nothing) => {
                SomethingOrNothing::new(lhs)
            }
        }
    }
}

// i32 is Copyable as a very basic type as f32, f64, etc.
// Arrays for example are not copyable.
impl Minimum for i32 {
    fn min(self, rhs: Self) -> Self {
        if self < rhs {
            self
        } else {
            rhs
        }
    }
}

const SIZE: usize = 9;

fn read_command_line() -> [i32; SIZE] {
    [10, 32, 12, 43, 52, 53, 83, 2, 9]
}

// Prints tab and returns tab.
// Tab would be destructed at the end of the function otherwise.
fn print_tab<T: std::fmt::Display>(tab: [T; SIZE]) {
    for t in tab {
        print!("{} ", t);
    }
    println!();
}

fn find_min<T: Minimum>(tab: [T; SIZE]) -> SomethingOrNothing<T> {
    let mut current_minimum = SomethingOrNothing::Nothing;
    // Here is T is not Copyable tab is consumed and cannot be returned
    for t in tab {
        current_minimum = current_minimum.min(SomethingOrNothing::new(t));
    }
    current_minimum
}

fn main() {
    let tab = read_command_line();
    println!("Among the Somethings in the list:");
    print_tab(tab);
    let min = find_min(tab);
    print(min);
}

Le deuxième trait que nous retrouvons dans ce code est le trait Clone. Clone est un supertrait de Copy, ce qui signifie qu'un type qui implémente Copy doit nécessairement implémenter Clone. Le trait Clone permet de dupliquer explicitement une instance. En effet, pour cloner une instance, il faut appeler la méthode clone() explicitement


/// In gen_types_composes we introduce genericity through traits and in particular, [Copy],
/// [Clone], [std::fmt::Display] .
enum SomethingOrNothing<T> {
    Nothing,
    Something(T),
}

impl<T> SomethingOrNothing<T> {
    fn new(val: T) -> SomethingOrNothing<T> {
        SomethingOrNothing::Something(val)
    }
}

// Print function
// We know the generic type T must be Displayable
fn print<T: std::fmt::Display>(val: SomethingOrNothing<T>) {
    match val {
        SomethingOrNothing::Nothing => println!("Nothing."),
        SomethingOrNothing::Something(val) => println!("Something is: {}", val),
    }
}

impl<T: Clone> Clone for SomethingOrNothing<T> {
    fn clone(&self) -> Self {
        match self {
            SomethingOrNothing::Nothing => SomethingOrNothing::Nothing,
            SomethingOrNothing::Something(val) => SomethingOrNothing::new(val.clone()),
        }
    }
}

impl<T: Copy> Copy for SomethingOrNothing<T> {}

// If we remove Copy, we have a problem with the t in tab
// in the computation of the minimum.
trait Minimum: Copy {
    fn min(self, rhs: Self) -> Self;
}

impl<T: Minimum> Minimum for SomethingOrNothing<T> {
    fn min(self, rhs: Self) -> Self {
        match (self, rhs) {
            (SomethingOrNothing::Nothing, SomethingOrNothing::Nothing) => {
                SomethingOrNothing::Nothing
            }
            (SomethingOrNothing::Something(lhs), SomethingOrNothing::Something(rhs)) => {
                SomethingOrNothing::new(lhs.min(rhs))
            }
            (SomethingOrNothing::Nothing, SomethingOrNothing::Something(rhs)) => {
                SomethingOrNothing::new(rhs)
            }
            (SomethingOrNothing::Something(lhs), SomethingOrNothing::Nothing) => {
                SomethingOrNothing::new(lhs)
            }
        }
    }
}

// i32 is Copyable as a very basic type as f32, f64, etc.
// Arrays for example are not copyable.
impl Minimum for i32 {
    fn min(self, rhs: Self) -> Self {
        if self < rhs {
            self
        } else {
            rhs
        }
    }
}

const SIZE: usize = 9;

fn read_command_line() -> [i32; SIZE] {
    [10, 32, 12, 43, 52, 53, 83, 2, 9]
}

// Prints tab and returns tab.
// Tab would be destructed at the end of the function otherwise.
fn print_tab<T: std::fmt::Display>(tab: [T; SIZE]) {
    for t in tab {
        print!("{} ", t);
    }
    println!();
}

fn find_min<T: Minimum>(tab: [T; SIZE]) -> SomethingOrNothing<T> {
    let mut current_minimum = SomethingOrNothing::Nothing;
    // Here is T is not Copyable tab is consumed and cannot be returned
    for t in tab {
        current_minimum = current_minimum.min(SomethingOrNothing::new(t));
    }
    current_minimum
}

fn main() {
    let tab = read_command_line();
    println!("Among the Somethings in the list:");
    print_tab(tab);
    let min = find_min(tab);
    print(min);
}

Comme on peut le voir dans le code ci-dessus, il est possible de définir un comportement arbitraire en redéfinissant la méthode clone(). En effet, le trait Clone nous permet de définir librement le comportement attendu lors d'un clonage, ce qui n'est pas le cas avec Copy. Cette liberté a un coût, puisque que l'on peut écrire notre propre fonction de clonnage, ce dernier peut facilement devenir beaucoup plus coûteux que la simple copie binaire des données.

Le langage Rust offre un attribut afin de pouvoir implémenter simplement les traits Copy et Clone. Il s'agit de #[derive(...)]. Par exemple avec le code suivant

#![allow(unused)]
fn main() {
#[derive(Clone,Copy)]
struct MyStruct;
}

l'annotation permet d'indiquer au compilateur que notre struct MyStruct nécessite une implémentation par défaut des traits Copy et Clone. Ce qui est équivaut à écrire

#![allow(unused)]
fn main() {
struct MyStruct;

impl Copy for MyStruct { }

impl Clone for MyStruct {
    fn clone(&self) -> MyStruct {
        *self
    }
}

}

Le code


/// In gen_types_composes we introduce genericity through traits and in particular, [Copy],
/// [Clone], [std::fmt::Display] .
enum SomethingOrNothing<T> {
    Nothing,
    Something(T),
}

impl<T> SomethingOrNothing<T> {
    fn new(val: T) -> SomethingOrNothing<T> {
        SomethingOrNothing::Something(val)
    }
}

// Print function
// We know the generic type T must be Displayable
fn print<T: std::fmt::Display>(val: SomethingOrNothing<T>) {
    match val {
        SomethingOrNothing::Nothing => println!("Nothing."),
        SomethingOrNothing::Something(val) => println!("Something is: {}", val),
    }
}

impl<T: Clone> Clone for SomethingOrNothing<T> {
    fn clone(&self) -> Self {
        match self {
            SomethingOrNothing::Nothing => SomethingOrNothing::Nothing,
            SomethingOrNothing::Something(val) => SomethingOrNothing::new(val.clone()),
        }
    }
}

impl<T: Copy> Copy for SomethingOrNothing<T> {}

// If we remove Copy, we have a problem with the t in tab
// in the computation of the minimum.
trait Minimum: Copy {
    fn min(self, rhs: Self) -> Self;
}

impl<T: Minimum> Minimum for SomethingOrNothing<T> {
    fn min(self, rhs: Self) -> Self {
        match (self, rhs) {
            (SomethingOrNothing::Nothing, SomethingOrNothing::Nothing) => {
                SomethingOrNothing::Nothing
            }
            (SomethingOrNothing::Something(lhs), SomethingOrNothing::Something(rhs)) => {
                SomethingOrNothing::new(lhs.min(rhs))
            }
            (SomethingOrNothing::Nothing, SomethingOrNothing::Something(rhs)) => {
                SomethingOrNothing::new(rhs)
            }
            (SomethingOrNothing::Something(lhs), SomethingOrNothing::Nothing) => {
                SomethingOrNothing::new(lhs)
            }
        }
    }
}

// i32 is Copyable as a very basic type as f32, f64, etc.
// Arrays for example are not copyable.
impl Minimum for i32 {
    fn min(self, rhs: Self) -> Self {
        if self < rhs {
            self
        } else {
            rhs
        }
    }
}

const SIZE: usize = 9;

fn read_command_line() -> [i32; SIZE] {
    [10, 32, 12, 43, 52, 53, 83, 2, 9]
}

// Prints tab and returns tab.
// Tab would be destructed at the end of the function otherwise.
fn print_tab<T: std::fmt::Display>(tab: [T; SIZE]) {
    for t in tab {
        print!("{} ", t);
    }
    println!();
}

fn find_min<T: Minimum>(tab: [T; SIZE]) -> SomethingOrNothing<T> {
    let mut current_minimum = SomethingOrNothing::Nothing;
    // Here is T is not Copyable tab is consumed and cannot be returned
    for t in tab {
        current_minimum = current_minimum.min(SomethingOrNothing::new(t));
    }
    current_minimum
}

fn main() {
    let tab = read_command_line();
    println!("Among the Somethings in the list:");
    print_tab(tab);
    let min = find_min(tab);
    print(min);
}

Rustlings

Les rustlings à faire dans ce chapitre sont les suivants:

Les traits

$ rustlings run traits1
$ rustlings run traits2
$ rustlings run traits3
$ rustlings run traits4
$ rustlings run traits5

Les tuples

$ rustlings run primitive_types5
$ rustlings run primitive_types6

Discussion du code propriete

Dans cette partie nous discutons de ce code.

Concepts

Les concepts abordés dans cet exemple sont:

  1. Bind et move.
  2. Ownership et borrowing.

Discussion

Bind et move

On l'a déjà brièvement mentionné dans la partie 2 l'instruction

#![allow(unused)]
fn main() {
let a = 2;
}

fait plusieurs choses:

  1. Elle alloue l'espace pour un entier 32 bits.
  2. Initialise la mémoire avec la valeur 2.
  3. Lie (bind en anglais) la variable (l'identifiant) a avec cet espace mémoire.

Quand on sort de la portée (scope en anglais) la variable est plus accessible et la mémoire est automatiquement libérée (l'espace mémoire où est contenue la valeur 2 est libéré automatiquement). Cela se passe aussi en C, sauf quand on a fait un malloc() explicite.

Si on écrit le code un peu plus compliqué

enum SomethingOrNothing<T> {
    Nothing,
    Something(T),
}
impl<T> SomethingOrNothing<T> {
    fn new(val: T) -> SomethingOrNothing<T> {
        SomethingOrNothing::Something(val)
    }
}
fn main() {
    let a = SomethingOrNothing::new(1);
    let _b = a;
    let _c = a;
}

Ici ce code ne compile pas, car nous ne pouvons lier une valeur qu'à une seule variable par défaut. On dit que les données ne peuvent avoir qu'un seul propriétaire (owner en anglais). Ainsi, quand on fait

let a = SomethingOrNothing::new(1);
let _b = a;

on donne la propriété de la valeur SomethingOrNothing::new(1) à la variable a. Puis, quand on transfère la propriété de la valeur SomethingOrNothing::new(1) à la variable _b. Ainsi, a n'est plus lié à SomethingOrNothing::new(1) et ne peut plus rien transférer comme données. Donc quand on fait

let _c = a;

le compilateur refuse de nous laisser faire, car les données liées à a ont déjà été déplacées (moved en anglais) dans _b.

Pour certains types, comme les i32 (ou tous les types qui implémentent le trait Copy) une copie implicite est faite, et on ne déplace ainsi pas la propriété de la valeur mais on en crée une nouvelle qui est liée aux variables.

Cette règle est très importante et a des implications très fortes en Rust (c'est aussi le cas dans tous les langages mais ça passe un peu plus inaperçu, car le compilateur ne se plaint pas). On dit que Rust empêche l'aliasing. On retrouve cette difficulté quand on passe un argument à une fonction. Si le type de la variable passé en argument n'est pas copiable, la propriété de la valeur est passée à l'argument de la fonction. Ainsi au moment de quitter la fonction la valeur est détruite et la mémoire liée à la valeur libérée comme dans le code suivant

enum SomethingOrNothing<T> {
    Nothing,
    Something(T),
}
impl<T> SomethingOrNothing<T> {
    fn new(val: T) -> SomethingOrNothing<T> {
        SomethingOrNothing::Something(val)
    }
}
fn print<T: std::fmt::Display>(val: SomethingOrNothing<T>) {
    match val {
        SomethingOrNothing::Nothing => println!("Nothing."),
        SomethingOrNothing::Something(val) => println!("Something is: {}", val),
    }
}
fn main() {
    let a = SomethingOrNothing::new(1);
    print(a);
    print(a);
}

Ici lors du premier appel à print(a) la propriété de la valeur SomethingOrNothing::new(1) est transférée à l'argument val de la fonction print(val). Lorsqu'on sort de la portée de la fonction la variable val est détruite et toutes les données qui vont avec. Comme la variable a n'est plus propriétaire d'aucune donnée, le second appel à print(a) échoue à la compilation.

Une solution est d'implémenter le trait Copy pour SomethingOrNothing<T>, mais bien que cela puisse être acceptable pour un petit type comme celui-ci, en général le coût de performance serait énorme on devait répéter des copies qui prennent beaucoup de place en mémoire.

Mais alors comment faire pour afficher un SomethingOrNothing<T> sans implémenter le trait Copy?

Ownership et borrowing

Le Rust nous offre un moyen d'emprunter (borrow) les variables pour éviter d'avoir à faire des copies dans tous les sens ou de devoir toujours retourner les variables passées en argument.

enum SomethingOrNothing<T> {
    Nothing,
    Something(T),
}
impl<T> SomethingOrNothing<T> {
    fn new(val: T) -> SomethingOrNothing<T> {
        SomethingOrNothing::Something(val)
    }
}
fn print<T: std::fmt::Display>(val: &SomethingOrNothing<T>) {
    match val {
        SomethingOrNothing::Nothing => println!("Nothing."),
        SomethingOrNothing::Something(val) => println!("Something is: {}", val),
    }
}
fn main() {
    let a = SomethingOrNothing::new(1);
    print(&a);
    print(&a);
}

Dans ce code on voit que la fonction print a la signature suivante

fn print<T: std::fmt::Display>(val: &SomethingOrNothing<T>)

et le type de val est une référence vers un SomethingOrNothing<T> (il faut noter le & avant le type). La référence en Rust est un moyen d'emprunter la propriété d'une valeur. Ainsi quand on va sortir de la portée de la fonction, la variable val, qui est une référence va être détruite, mais les données qui sont liées à cette référence ne le seront pas. C'est pour cela que contrairement à ce qui se passe dans la section Bind et move on peut appeler deux fois la fonction print(&val). Il faut bien noter que la référence a la syntaxe &<variable> comme en C.

Exercice

Corrigez ce code pour qu'il compile (sans enlever de lignes évidemment)

enum SomethingOrNothing<T> {
    Nothing,
    Something(T),
}
impl<T> SomethingOrNothing<T> {
    fn new(val: T) -> SomethingOrNothing<T> {
        SomethingOrNothing::Something(val)
    }
}
fn main() {
    let a = SomethingOrNothing::new(1);
    {
        let b = a;
        // imagine that some thing may happen here with b
    }
    let c = a;
}

On constate dans notre code qu'il y a assez peu de modifications pour éviter les copies implicites et passer par des emprunts. Pour résumer, on a changé la signature de trois fonctions: print(), print_tab(), et find_min().

La fonction print() a déjà été discutée plus haut, et nécessite simplement l'ajout d'une référence vers un SomethingOrNothing<T> pour que la valeur soit prêtée à val dans la fonction

fn print<T: std::fmt::Display>(val: &SomethingOrNothing<T>) {
    match val {
        SomethingOrNothing::Nothing => println!("Nothing."),
        SomethingOrNothing::Something(val) => println!("Something is: {}", val),
    }
}

Ainsi, les valeurs de SomethingOrNothing<T> ne sont pas détruites à l'appel de print().

La fonction print_tab(), de façon similaire, nécessite le passage de la référence vers le tableau statique &[T; SIZE]

fn print_tab<T: std::fmt::Display>(tab: &[T; SIZE]) {
    for t in tab {
        print!("{} ", t);
    }
    println!();
}

La boucle for nécessite une discussion plus approfondie. En effet, dans cette fonction tab est une référence vers un tableau. Ainsi, on ne peut pas itérer sur ses éléments de façon standard. En effet, cela nécessiterait de "move" ou transférer la propriété de la valeur sur laquelle on itère.

Le compilateur de Rust ne l'autoriserait jamais. Le langage nous facilite donc la vie en itérant sur les références des valeurs contenues dans le tableau (dont le type est générique, rappelons le). Donc si on annotait explicitement le type de t dans la boucle for on aurait:

for t: &T in tab

On constate aussi que le langage devine qu'on ne veut pas afficher la valeur de la référence (son adresse mémoire) mais affiche directement la valeur elle même quand on appelle la macro print!()

print!("{} ", t);

Finalement, nous avons également modifié la fonction find_min().

fn find_min<T: Minimum>(tab: &[T; SIZE]) -> SomethingOrNothing<T> {
    let mut current_minimum = SomethingOrNothing::Nothing;
    // Here is T is not Copyable tab is consumed and cannot be returned
    for t in tab {
        current_minimum = current_minimum.min(SomethingOrNothing::new(*t));
    }
    current_minimum
}

Outre l'argument qui est une référence à un tableau statique comme dans les deux autres fonctions discutées précédemment, on a une nouvelle syntaxe dans la boucle for

for t in tab {
    current_minimum = current_minimum.min(SomethingOrNothing::new(*t));
}

On constante qu'on doit déréférencer t (appeler *t), car t est une référence vers T (il est de type &T), car la fonction new() s'attend à un T et non à un &T. L'opérateur *<variable> est l'opérateur de déréférencement et cette syntaxe est la même que celle du C. Si par malheur on oubliait le *t on aurait une erreur de compilation, car le vérificateur de type du Rust ne laisse pas passer ce genre de choses.

Le code


/*!
propriete illustrates the concepts of **Ownership** and **Borrowing**. It also
presents the manual implementation of [Clone] and [Copy].
*/
enum SomethingOrNothing<T> {
    Nothing,
    Something(T),
}
impl<T> SomethingOrNothing<T> {
    fn new(val: T) -> SomethingOrNothing<T> {
        SomethingOrNothing::Something(val)
    }
}

fn print<T: std::fmt::Display>(val: &SomethingOrNothing<T>) {
    match val {
        SomethingOrNothing::Nothing => println!("Nothing."),
        SomethingOrNothing::Something(val) => println!("Something is: {}", val),
    }
}

// Manual implementation of [Clone]
impl<T: Clone> Clone for SomethingOrNothing<T> {
    fn clone(&self) -> Self {
        match self {
            SomethingOrNothing::Nothing => SomethingOrNothing::Nothing,
            SomethingOrNothing::Something(val) => SomethingOrNothing::Something(val.clone()),
        }
    }
}

// Manual implementation of [Copy]
impl<T: Copy> Copy for SomethingOrNothing<T> {}

// If we remove Copy, we have a problem with the t in tab
// in the computation of the minimum.
trait Minimum: Copy {
    fn min(self, rhs: Self) -> Self;
}

impl<T: Minimum> Minimum for SomethingOrNothing<T> {
    fn min(self, rhs: Self) -> Self {
        match (self, rhs) {
            (SomethingOrNothing::Nothing, SomethingOrNothing::Nothing) => {
                SomethingOrNothing::Nothing
            }
            (SomethingOrNothing::Something(lhs), SomethingOrNothing::Something(rhs)) => {
                SomethingOrNothing::Something(lhs.min(rhs))
            }
            (SomethingOrNothing::Nothing, SomethingOrNothing::Something(rhs)) => {
                SomethingOrNothing::Something(rhs)
            }
            (SomethingOrNothing::Something(lhs), SomethingOrNothing::Nothing) => {
                SomethingOrNothing::Something(lhs)
            }
        }
    }
}

impl Minimum for i32 {
    fn min(self, rhs: Self) -> Self {
        if self < rhs {
            self
        } else {
            rhs
        }
    }
}

const SIZE: usize = 9;

// Poorly emulates the parsing of a command line.
fn read_command_line() -> [i32; SIZE] {
    [10, 32, 12, 43, 52, 53, 83, 2, 9]
}

// Prints all the elements of the `tab`.
// Tab is borrowed here
fn print_tab<T: std::fmt::Display>(tab: &[T; SIZE]) {
    for t in tab {
        print!("{} ", t);
    }
    println!();
}

// Computes the minimum of a borrowed Array of a type T which implements the [Minimum] trait.
// Returns a [SomethingOrNothing::Something] containing the the minimum value
// or [SomethingOrNothing::Nothing] if no minimum value was found.
fn find_min<T: Minimum>(tab: &[T; SIZE]) -> SomethingOrNothing<T> {
    let mut current_minimum = SomethingOrNothing::Nothing;
    // Here is T is not Copyable tab is consumed and cannot be returned
    for t in tab {
        current_minimum = current_minimum.min(SomethingOrNothing::new(*t));
    }
    current_minimum
}

fn main() {
    let tab = read_command_line();
    println!("Among the Somethings in the list:");
    print_tab(&tab);
    let min = find_min(&tab);
    print(&min);
}

Rustlings

Les rustlings à faire dans ce chapitre sont les suivants:

La move semantic

$ rustlings run move_semantics1
$ rustlings run move_semantics2
$ rustlings run move_semantics3
$ rustlings run move_semantics4
$ rustlings run move_semantics5
$ rustlings run move_semantics6

Discussion du code modules_visibilite

Dans cette partie nous discutons de ce code.

Concepts

Les concepts abordés dans cet exemple sont:

  1. Les modules.
  2. La visibilité.

Discussion

Jusqu'ici, tout le code était contenu dans le fichier main.rs. Au fur et à mesure que le code devient plus complexe et long, il est nécessaire de le séparer en plusieurs fichiers (modules) et de gérer la visibilité des structures, types énumérés, fonctions, etc. Nous allons voir dans ce chapitre comment cela se passe pour le Rust. Pour plus d'informations vous pouvez vous référer à La Bible du Rust.

Les modules

Afin de séparer le code en plusieurs fichiers il est nécessaire de créer un fichier lib.rs qui se trouve dans le même répertoire que le fichier main.rs. Ce répertoire est en général le répertoire src de votre projet. Ici c'est dans projet05/src. Dans notre cas, il contient très peu de lignes

/*!
modules_visibilite illustrates the concepts of **modules** and **visibility**.
*/

// The size of the tab
const SIZE: usize = 9;

pub mod io;
mod minimum;
pub mod something_or_nothing;

La présence d'un fichier lib.rs indique que vous avez créé une librairie, appelée crate en Rust. Toutes les libraries publiées en Rust sont des crate et peuvent se télécharger depuis le site crates.io.

On voit qu'il y a dans le fichier lib.rs la définition de la constante SIZE, ainsi que trois lignes contenant le mot-clé mod qui indique la présence d'un module. Par défaut, le compilateur Rust va aller chercher le contenu de ces modules dans les fichiers io.rs, minimum.rs, et something_or_nothing.rs (ou io/mod.rs, minimum/mod.rs, et something_or_nothing/mod.rs). Dans ce chapitre, nous avons simplement réparti tout le code qui se trouvait dans main.rs dans la partie 4. Le mot-clé pub indique la visibilité du module à l'intérieur de votre librairie. Ainsi, le module minimum n'est pas exposé à vos utilisatrices et utilisateurs, alors que io et something_or_nothing le sont. Nous verrons un peu plus bas les règles sur la visibilité.

Afin d'utiliser les fonctions définies dans notre librairie dans notre programme principal (le main.rs) comme dans le code suivant

use modules_visibilite::io;
use modules_visibilite::something_or_nothing::find_min;

Pour importer les modules avec la syntaxe suivante

use nom_de_la_crate::nom_du_module;

où le nom_de_la_crate est défini dans le fichier modules_visibilite/Cargo.toml (le champs name), le nom du module ici est io et chaque module est séparé par le symbole ::. On a également importé la fonction find_min spécifiquement avec la syntaxe

use nom_de_la_crate::nom_du_module::nom_de_la_fonction;

Ce n'est pas fait dans cet exemple, mais il est tout à fait possible de définir des sous-modules (voir Le Livre).

Afin d'utiliser de partager des fonctions entre les modules, il faut également les importer comme dans le module something_or_nothing qui nécessite l'import du trait Minimum à l'aide de la syntaxe

use crate::minimum::Minimum;

On voit la nécessité d'utiliser le mot-clé crate pour indiquer que le module est importé depuis l'intérieur de notre librairie. Puis il faut suivre l'arborescence habituelle avec le module minimum et le trait Minimum le tout séparé par des séparateurs, ::.

Observations

  1. Observez ce qui se passe si vous commentez la ligne mod minimum; dans lib.rs et tentez de compiler le code. Que vous dit le compilateur?
  2. Que se passe-t-il, si vous enlevez le mot clé pub de la ligne pub mod io; dans lib.rs et tentez de compiler le code? Quel message s'affiche?

On constate que pour la partie 1, le compilateur n'est pas content, car il ne trouve pas le module minimum dans notre crate

error[E0432]: unresolved import `crate::minimum`
 --> src/something_or_nothing.rs:2:12
  |
2 | use crate::minimum::Minimum;
  |            ^^^^^^^ could not find `minimum` in the crate root

For more information about this error, try `rustc --explain E0432`.
error: could not compile `modules_visibilite` (lib) due to previous error

Pour la partie 2, on a deux messages un peu différents

   Compiling modules_visibilite v0.1.0 (/home/orestis/git/projects/rust-101/codes/rust_lang/modules_visibilite)
warning: function `read_command_line` is never used
 --> src/io.rs:2:8
  |
2 | pub fn read_command_line() -> [i32; crate::SIZE] {
  |        ^^^^^^^^^^^^^^^^^
  |
  = note: `#[warn(dead_code)]` on by default

warning: function `print_tab` is never used
 --> src/io.rs:8:8
  |
8 | pub fn print_tab(tab: &[i32; crate::SIZE]) {
  |        ^^^^^^^^^

warning: `modules_visibilite` (lib) generated 2 warnings
error[E0603]: module `io` is private
 --> src/main.rs:2:13
  |
2 | use modules_visibilite::io;
  |             ^^ private module
  |
note: the module `io` is defined here
 --> /home/orestis/git/projects/rust-101/codes/rust_lang/modules_visibilite/src/lib.rs:9:1
  |
9 | mod io;
  | ^^^^^^

For more information about this error, try `rustc --explain E0603`.
error: could not compile `modules_visibilite` (bin "modules_visibilite") due to previous error

Le compilateur commence par nous prévenir par des warnings que les fonctions print_tab et read_command_line() ne sont jamais utilisées. Puis, nous avons un message nous prévenant que io est privé.

Visibilité

Par défaut, Rust rend privées toutes les structures (et ses membres ou fonctions statiques), traits, fonctions, etc. Cela signifie qu'elles ne sont pas visibles en dehors du module dans lequel elles sont définies. Pour les rendre visibles (en dehors du module dans lequel elles sont définies), il faut les rendre publiques à l'aide du préfixe pub.

Il y a plusieurs exemples de l'utilisation dans ce chapitre.

  • Pour les fonctions:
pub fn print_tab(tab: &[i32; crate::SIZE]) {
    for t in tab {
        print!("{} ", t);
    }
    println!();
}

on voit qu'on préfixe pub devant le mot-clé fn pour rendre la fonction publique. Si on le retire, le compilateur donnera le message d'erreur suivant

error[E0603]: function `print_tab` is private
  --> src/main.rs:11:9
   |
11 |     io::print_tab(&tab);
   |         ^^^^^^^^^ private function
   |
note: the function `print_tab` is defined here
  --> /home/orestis/git/projects/rust-101/codes/rust_lang/modules_visibilite/src/io.rs:9:1
   |
9  | fn print_tab(tab: &[i32; crate::SIZE]) {
   | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

For more information about this error, try `rustc --explain E0603`.
  • Pour un type énuméré:
#[derive(Clone, Copy)]
pub enum SomethingOrNothing<T> {
    Nothing,
    Something(T),
}

où on préfixe le mot clé enum avec un pub.

  • Pour les méthodes
impl<T: std::fmt::Display> SomethingOrNothing<T> {
    pub fn print(&self) {
        match self {
            SomethingOrNothing::Nothing => println!("Nothing."),
            SomethingOrNothing::Something(val) => println!("Something is: {}", val),
        }
    }
}

où comme pour les fonctions, on préfixe fn avec un pub.

  • Pour les traits:
pub trait Minimum: Copy {
    fn min(self, rhs: Self) -> Self;
}

il faut noter que seule la définition du trait a besoin d'être publique. L'implémentation pour un type particulier n'a pas besoin de l'être.

Remarque

Tous les champs d'une structure (même publique) sont privés par défaut. Ainsi une structure Point contenant les coordonnées d'un points en deux dimension se définit comme

#![allow(unused)]
fn main() {
pub struct Point {
  x: f32,
  y: f32,
}
}

En dehors du module où cette structure serait définie, il est impossible d'accéder aux champs de la structure. L'instantiation et initialisation d'un Point

let p = Point { x: 1.0, y: 0.2 };

donnerait une erreur de compilation, car x et y sont privés. Il est donc nécessaire de préfixer les champs publics par un pub pour qu'ils soient accessible en dehors du module.

error[E0451]: field `x` of struct `Point` is private
  --> src/main.rs:15:43
   |
15 |     let p = something_or_nothing::Point { x: 1.0, y: 0.5 };
   |                                           ^^^^^^ private field

error[E0451]: field `y` of struct `Point` is private
  --> src/main.rs:15:51
   |
15 |     let p = something_or_nothing::Point { x: 1.0, y: 0.5 };
   |                                                   ^^^^^^ private field

For more information about this error, try `rustc --explain E0451`.

On aurait pour rendre x et y publics besoin de définir la structure comme

#![allow(unused)]
fn main() {
struct Point {
  pub x: f32,
  pub y: f32,
}
}

Rustlings

Les rustlings à faire dans ce chapitre sont les suivants:

Les modules

$ rustlings run modules1
$ rustlings run modules2
$ rustlings run modules3

Discussion du code tooling

Concepts

Les concepts abordés dans cet exemple sont:

  1. La documentation.
  2. Les tests.
  3. Les outils en plus du compilateur

Discussion

Le Rust étant un langage moderne, il vient avec tout un tas de features qui sont très appréciables pour écrire du code robuste, propre et réutilisable. On va voir quelle est la syntaxe nécessaire pour écrire documentation et tests.

La documentation

Il y a différents moyens de documenter un code. Pour un guide bien plus complet que ce qui est résumé ici, vous pouvez vous référer à ce site.

Les commentaires

Le plus simple est d'y ajouter des commentaires. En Rust, tous caractères qui suivent des // sur la même ligne sont considérés comme un commentaire (ignorés par le compilateur)

#![allow(unused)]
fn main() {
// Ceci est un commentaire
// Et ceci un autre
let a = 2; // Ici on assigne 2 à a
}

On peut écrire également des commentaires sur plusieurs lignes sans avoir à mettre des // sur chacune. Pour ce faire on utilise la syntaxe /* ... */

#![allow(unused)]
fn main() {
/* 
 Ceci est un commentaire
 Et ceci un autre
 Et en voici un dernier
*/
let a = 2; /* Ici on assigne 2 à a */
}

Ce type de documentation se prête très bien à des commentaires sur les détails du code, mais n'est pas très adapté à écrire une documentation plus générale sur le comportement du code. Ainsi, on a autre type de commentaires, qui seront utilisés pour générer automatiquement de la documentation à l'aide de l'outil rustdoc.

La documentation par composants

La documentation d'un composant, que ça soit une struct, un enum, une fonction, etc. se fait en préfixant /// devant la ligne de documentation et la plaçant directement au dessus du composant à commenter. Ainsi les exemples suivants permettent de:

  • Documenter une constante
/// The SIZE constant allows to use statically sized arrays
const SIZE: usize = 9;
  • Documenter une fonction
/// Poorly emulates the parsing of a command line.
pub fn read_command_line() -> [i32; crate::SIZE] {
    [10, 32, 12, 43, 52, 53, 83, 2, 9]
}
  • Documenter une fonction statique
impl<T: std::fmt::Display> SomethingOrNothing<T> {
    /// A static function that prints the content of a SomethingOrNothing.
    pub fn print(&self) {
        match self {
            SomethingOrNothing::Nothing => println!("Nothing."),
            SomethingOrNothing::Something(val) => println!("Something is: {}", val),
        }
    }
}
  • Documenter un trait
/// The [Minimum] trait computes the minimum value between two values of a type
pub trait Minimum: Copy {
    fn min(self, rhs: Self) -> Self;
}
  • Documenter un type énuméré et ses variantes
/// An generic enumerated type that has two variants.
///
/// - Nothing
/// - Something
#[derive(Clone, Copy)]
pub enum SomethingOrNothing<T> {
    /// A [SomethingOrNothing::Nothing]
    Nothing,
    /// A [SomethingOrNothing::Something] encapsulating a T
    Something(T),
}

La documentation d'une crate et des modules

Une librairie est appelée crate Rust. Afin de donner des informations de haut niveau sur le fonctionnement d'une librairie, on peut utiliser une syntaxe spéciale //! à mettre au début de chaque ligne de documentation (on peut également utiliser la syntaxe /*! ... */), comme ci-dessous

//! This is an example of Rust crate comments (or inner comments).
//! They will be rendered in the front page of your (crate) library.
//!
//! # How to generate the documentation
//!
//! In this program we wrote an algorithm that computes the minimum of
//! a sequence of integers.
//!
//! To create the documentation run the command
//! ```bash
//! cargo doc
//! ```
//! The obtain documentation can be found in the `target/doc/tooling/index.html` directory
//!
//! To view the documentation type
//! ```bash
//! cargo doc --open
//! ```
//! which will open the browser and show you the documentation.
//!
//! The documentation supports the CommonMarkdown syntax.
//!
//! Below we will use the `///` comments that will comment the code directly below.
//! We can also sue `//` but they will not be rendered.
//! All the lines written here could be enclosed in `/*! ... */` instead of being prefixed by `//!`.
//!
//! For more informations about writing documentation [follow that link](https://doc.rust-lang.org/rustdoc/what-is-rustdoc.html).
//!
//! # Tooling
//!
//! Also Rust comes with great tooling.
//! - [Clippy](https://doc.rust-lang.org/stable/clippy/): The officiel Rust linter.
//! - [Rustfmt](https://github.com/rust-lang/rustfmt): The official Rust code formatter.

Cette documentation se met dans le fichier lib.rs qui est également l'endroit où on importe les différents modules.

On peut également documenter les modules individuellement. Pour ce faire, il faut utiliser la même syntaxe que pour la documentation de la crate, mais mettre cette documentation au début du fichier contenant chaque module comme par exemple au début du fichier io.rs

//! Contains functions to interact with the user, either
//! by reading inputs from the terminal, either by writing values
//! in it.

Markdown

La documentation supporte la syntaxe du Common Markdown comme on peut le voir dans le code ci-dessus. On a en particulier la possibilité de mettre des titres avec des # ou du code avec des code fences. Il est également possible de mettre des liens vers d'autres parties de la documentation (avec les annotations tu type [MyStruct]) ce qui fait de la syntaxe un outil très puissant et intégré fortement au processus de développement.

Génération de la documentation

Tout ce travail d'annotation du code source permet d'utiliser rustdoc qui est un outil puissant de génération de documentation sous la forme principal d'un site web. Dans le répertoire où se trouve le fichier Cargo.toml, on peut exécuter la commande

cargo doc

et cela va générer la documentation dans un sous répertoire du projet. On peut également automatiquement ouvrir la documentation dans un navigateur à l'aide de la commande

cargo doc --open

Les tests

La documentation aide grandement à la (ré-)utilisation d'une librairie et à son développement (collaboratif ou non). Durant le processus de développement, il est également très utile (et important) d'écrire des tests pour le code. Rust propose un framework de tests totalement intégré au langage. On peut ainsi très facilement écrire des fonctions de test en ajoutant l'annotation #[test] directement au dessus de n'importe quelle fonction

#[test]
fn test_creation() {
    use something_or_nothing::SomethingOrNothing;

    let n1: SomethingOrNothing<i32> = SomethingOrNothing::default();
    assert!(n1 == SomethingOrNothing::Nothing);
    let n2: SomethingOrNothing<i32> = SomethingOrNothing::Something(1);
    assert!(n2 == SomethingOrNothing::Something(1));
}

La fonction test_creation() sera automatiquement appelée lors de l'appel à la commande

cargo test

Aucune fonction non annotée par #[test] n'est appelée quand on exécute cargo test. Si la fonction se termine sans erreur, le test est réussi (il est échoué si la fonction s'arrête en cours de route). On voit dans la fonction test_creation() l'appel à la macro assert!() qui prend en argument une expression booléenne, ici n1 == SomethingOrNothing::Nothing dans le premier appel. Si l'expression prend la valeur true, le code continue normalement son exécution, sinon il fait appel à la macro panic!()! et l'exécution est interrompue et un code d'erreur est retourné par le programme.

Dans l'appel à n1 == SomethingOrNothing::Nothing, on constate qu'on a besoin de vérifier si la valeur n1 est égale à SomethingOrNothing::Nothing. L'opérateur == n'est pas implémenté pour les types complexes, ainsi le code suivant ne compile pas

enum SomethingOrNothing<T> {
    Nothing,
    Something(T),
}
fn main() {
    let n1 = SomethingOrNothing::<i32>::Nothing;
    let b = n1 == SomethingOrNothing::<i32>::Nothing;
}

et nous donne un message d'erreur incluant

note: an implementation of `PartialEq<_>` might be missing for `SomethingOrNothing<i32>`

Ainsi, on doit implémenter le trait PartialEq pour le type SomethingOrNothing<T>, qui permet de tester l'égalité entre deux instances d'un type

/// An generic enumerated type that has two variants.
///
/// - Nothing
/// - Something
#[derive(Clone, Copy)]
pub enum SomethingOrNothing<T> {
    /// A [SomethingOrNothing::Nothing]
    Nothing,
    /// A [SomethingOrNothing::Something] encapsulating a T
    Something(T),
}
/// Implementation of the [PartialEq] trait that is useful for tests.
impl<T: PartialEq> PartialEq for SomethingOrNothing<T> {
    fn eq(&self, other: &Self) -> bool {
        match (self, other) {
            (SomethingOrNothing::Nothing, SomethingOrNothing::Nothing) => true,
            (SomethingOrNothing::Something(lhs), SomethingOrNothing::Something(rhs)) => {
                *lhs == *rhs
            }
            _ => false,
        }
    }
}
fn main() {
    let n1 = SomethingOrNothing::<i32>::Nothing;
    assert!(n1 == SomethingOrNothing::<i32>::Nothing);
}

On constate que dans notre implémentation, il est nécessaire que T implémente également le trait PartialEq. Ainsi, deux Nothing sont égaux, un Nothing et un Something sont différents, et seulement quand les deux valeurs encapsulées dans deux Something sont égales alors nous avons égalité.

On peut également vouloir construire des tests qui échouent comme dans l'exemple ci-dessous

    #[test]
    #[should_panic]
    fn test_failure_creation() {
        let n2: SomethingOrNothing<i32> = SomethingOrNothing::Something(1);
        assert!(n2 == SomethingOrNothing::Nothing);
        assert!(n2 == SomethingOrNothing::Something(2));
    }

où on a annoté le test avec un #[should_panic]. Ce test, bien qu'il panique, sera considéré comme réussi. Il faut néanmoins rester prudent avec ce type de test. Rien ne garantit que la fonction de test a paniqué au moment espéré. Le code pourrait tout à fait paniquer pour une raison autre que celle attendue. Cela est particulièrement vrai si le test est complexe.

Finalement, il y a également la possibilité de regrouper les tests comme ci-dessous (dans le fichier lib.rs dans cet exemple)

#[cfg(test)]
mod tests {
    use crate::minimum::Minimum;
    use crate::something_or_nothing::{find_min, SomethingOrNothing};

    #[test]
    #[should_panic]
    fn test_failure_creation() {
        let n2: SomethingOrNothing<i32> = SomethingOrNothing::Something(1);
        assert!(n2 == SomethingOrNothing::Nothing);
        assert!(n2 == SomethingOrNothing::Something(2));
    }

    #[test]
    fn test_min() {
        let a = [1, 5, -1, 2, 0, 10, 11, 0, 3];
        let min = find_min(&a);
        assert!(min == SomethingOrNothing::Something(-1));
    }

    #[test]
    fn test_min_something_or_nothing() {
        let x = SomethingOrNothing::Something(5i32);
        let y = SomethingOrNothing::Something(10i32);
        let z = SomethingOrNothing::Nothing;
        assert!(x.min(y) == x);
        assert!(y.min(x) == x);
        assert!(z.min(y) == y);
        assert!(y.min(z) == y);
        assert!(z.min(z) == z);
    }
}

Pour ce faire, il faut créer un module, (ici mod tests) et l'annoter avec une configuration spéciale #[cfg(test)]. Cela permet de séparer les tests totalement du reste du code et devoir importer les différentes implémentations.

Il est également possible de répartir les tests dans différents modules comme dans minimum.rs par exemple en plus de lib.rs

#[cfg(test)]
mod tests {
    use crate::minimum::Minimum;

    #[test]
    fn test_min_i32() {
        let x = 5;
        let y = 10;
        assert_eq!(Minimum::min(x, y), x);
        assert_eq!(Minimum::min(y, x), x);
        assert_eq!(Minimum::min(x, x), x);
        assert_eq!(Minimum::min(y, y), y);
    }
}

Tests de documentation

Une bonne documentation inclut des exemples de code. Mais il n'y a rien de pire que des exemples faux ou qui ne marchent pas. Ainsi, Rust offre la possibilité de compiler et exécuter le code inclut dans la documentation: ces tests sont des tests de documentation. En effet, le code entre "code fences" markdown sera compilé et exécuté (à moins qu'il soit annoté ignore où il sera pas compilé et no_run où il sera pas exécuté). Il y a deux exemples de tests de documentation dans notre code. Le premier concerne l'implémentation du trait Default pour SomethingOrNothing qui permet de construire une instance par défaut qui sera la variante Nothing (voir something_or_nothing.rs).

/// Implementation of the [Default] trait that creates a [SomethingOrNothing]
/// that is a `Nothing` variant.
///
/// # Example
///
/// ```
/// # use tooling::something_or_nothing::SomethingOrNothing;
/// # fn main() {
/// let def: SomethingOrNothing<i32> = SomethingOrNothing::default();
/// assert!(def == SomethingOrNothing::Nothing);
/// # }
/// ```
impl<T> Default for SomethingOrNothing<T> {
    /// By Default a [SomethingOrNothing] is a nothing.
    fn default() -> Self {
        SomethingOrNothing::Nothing
    }
}

On voit que pour que le test puisse compiler et s'exécuter il est nécessaire d'importer les bons modules/fonctions. Ici on importe explicitement tooling::something_or_nothing::SomethingOrNothing et on met les fonctions à exécuter dans un main. Pour que ce "bruit" n'apparaisse pas dans la documentation, on préfixe les lignes par des #. Ainsi ces lignes sont lues par le compilateur pour les tests de documentation mais sont ignorées lors du rendu de la documentation.

Il y a également un exemple sur l'utilisation de la fonction find_min() (toujours dans something_or_nothing.rs)

/// Computes the minimum of an Array of a type T which implements the [Minimum] trait.
/// Returns a [SomethingOrNothing::Something] containing the the minimum value
/// or [SomethingOrNothing::Nothing] if no minimum value was found.
///
/// # Example
///
/// ```
/// # use tooling::something_or_nothing::{SomethingOrNothing, find_min};
/// # fn main() {
/// let tab = [10, 32, 12, 43, 52, 53, 83, 2, 9];
/// let min = find_min(&tab);
/// assert!(min == SomethingOrNothing::Something(2));
/// # }
/// ```
pub fn find_min<T: Minimum>(tab: &[T; crate::SIZE]) -> SomethingOrNothing<T> {
    let mut minimum = SomethingOrNothing::Nothing;
    // Here is T is Copyable. Which means that t is not moved in the loop
    for t in tab {
        minimum = minimum.min(SomethingOrNothing::Something(*t));
    }
    minimum
}

Finalement, les tests de documentation peuvent également être mis dans les documentation de module comme dans minimum.rs

//! Contains a generic trait implementation for computing the minimum between two
//! values. It is the equivalent of the `<` operator.
//!
//! # Examples
//!
//! For integers this would look like
//!
//! ```
//! # use tooling::minimum::Minimum;
//! let one = 1;
//! let two = 2;
//! assert!(Minimum::min(one, two) == one);
//! ```

Rapport sur l'exécution des tests

Lors de l'appel à cargo test tous les tests sont exécutés et un rapport est généré. Sur ce code, on obtient

$ cargo  est                                                                                               
   Compiling tooling v0.1.0 (/home/orestis/git/projects/rust-101/codes/rust_lang/tooling)
    Finished test [unoptimized + debuginfo] target(s) in 0.37s
     Running unittests src/lib.rs (target/debug/deps/tooling-f12750c4987ae624)

running 5 tests
test test_creation ... ok
test minimum::tests::test_min_i32 ... ok
test tests::test_min ... ok
test tests::test_failure_creation - should panic ... ok
test tests::test_min_something_or_nothing ... ok

test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

     Running unittests src/main.rs (target/debug/deps/tooling-b63e62707c6aab7d)

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests tooling

running 3 tests
test src/minimum.rs - minimum (line 8) ... ok
test src/something_or_nothing.rs - something_or_nothing::SomethingOrNothing<T> (line 38) ... ok
test src/something_or_nothing.rs - something_or_nothing::find_min (line 95) ... ok

test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.14s

Les outils en plus du compilateur

La chaîne de compilation Rust vient avec deux autres outils très pratiques (en plus de cargo et rustdoc): rustfmt un formatteur de code, et clippy un linter.

rustfmt

Rust a fait le choix fasciste de définir des tas de conventions pour le nommage des variables, des types, etc. L'outil rustfmt permet de formatter automatiquement le code pour avoir un style uniforme au travers de tout votre code et ainsi améliorer sa lisibilité.

fn main() {
    const size: usize = 9;
    let tab: [i32; size] = [10, 32, 12, 
    43, 52, 53, 83, 2, 9];
    
    if (size == 0)
    {
        panic!("Size is of tab = 0.");
    }

    println!("Among the numbers in the list:");
    for i in 0..size {
        print!("{} ", tab[i]);
    }
    println!();

    let mut MinTab=tab[0];
    for i in 1..size 
    {
        if MinTab > tab[i] 
        {
            MinTab=tab[i];
        }
    }



    println!("The minimal value is: {}", MinTab);
}

Afin de le voir à l'oeuvre copier le code ci-dessus dans un src/main.rs et utilisez la commande rustfmt --check src/main.rs et observez le résultat. Par défaut rustfmt va modifier le code, ainsi l'option --check va uniquement montrer quelles sont les choses à corriger. En général, rustfmt est intégré avec les plugins dans des éditeurs de code tels que codium qui l'intègre dans le plugin rust-analyzer par exemple.

clippy

L'outil clippy est un détecteur automatique de mauvaise pratiques de codage, telles que définies par la communauté du Rust. Il permet d'écrire du code le plus idiomatique possible et en général d'éviter certaines mauvaises pratiques et/ou de simplifier le code avec des patterns connus.

Comme ci-dessus, prenez ce code et exécutez la commande cargo clippy pour voir les recommandations du linter.

Rustlings

Les rustlings à faire dans ce chapitre sont les suivants:

Les modules

$ rustlings run tests1
$ rustlings run tests2
$ rustlings run tests3
$ rustlings run tests4

Clippy

$ rustlings run clippy1
$ rustlings run clippy2
$ rustlings run clippy3

Discussion du code gestion_erreurs

Concepts

Les concepts abordés dans cet exemple sont:

Documentation

Afin de compléter ce cours, je vous recommande la lecture des ressources suivantes :

Discussion

Lors de l'exécution d'un programme, il existe en général une multitude d'endroits où des erreurs peuvent se produire. Il est important de savoir identifier les différents types d'erreurs, ainsi que les outils offerts par le langage Rust permettant de gérer ces erreurs.

Les différents types d'erreurs

Il faut savoir différentier deux principaux types d'erreurs.

Erreurs rattrapables

Dans un premier temps, il y a les erreurs rattrapables ou prévues. En général, ces erreurs surviennent lorsque l'utilisateur entre des données erronées.

Nous avons par exemple :

  • Le chemin d'un fichier fourni par l'utilisateur qui n'existe pas
  • Une entrée invalide, p.ex une lettre à la place d'un nombre
  • De mauvais identifiants pour la connexion à un serveur

Ces erreurs sont attendues, il est donc normal d'anticiper leurs occurences, et par conséquent, de s'y préparer. Elles de ne devraient pas interrompre l'exécution du programme.

Pour détecter ce type d'erreur, nous devons essayer de prévoir tous les scénarios possibles. Si je demande à l'utilisateur d'entrer une valeur comprise entre 1 et 10, je pourrais par exemple obtenir :

  • Une valeur non-numérique
  • Une valeur non comprise dans l'intervalle demandée
  • L'absence de valeur
  • Une erreur impossible à prévoir parce qu'on ne peut pas tout prévoir. Le meilleur moyen de se prémunir de ce genre d'erreur, consiste à utiliser des types adaptés.

Prenons par exemple la fonction :

#![allow(unused)]
fn main() {
fn make_color(red:i32, green:i32, blue:i32, alpha:i32) -> i32 {
    red << 24 | green << 16 | blue << 8 | alpha // Quel est le problème?
}
}

Cette fonction prend en argument une valeur par canal de couleur et construit un entier représentant une couleur rgba 32 bit. Le problème majeur dans cette fonction est le typage de nos arguments. Pour fabriquer une couleur rgba 32 bit, il faut un octet par canal. Le type i32 n'est donc pas approprié, il pourrait être la source d'erreurs.

Pour éviter cela, on pourrait par exemple le remplacer par u8 qui permet de représenter un octet.

#![allow(unused)]
fn main() {
fn make_color(red:u8, green:u8, blue:u8, alpha:u8)->u32 {
    (red as u32) << 24 | (green as u32) << 16 | (blue as u32) << 16 | (alpha as u32)
}
}

De cette manière, on évite les erreurs potentielles à la source. Vous noterez qu'il est nécessaire de caster vos arguments, afin d'avoir une expression cohérente du point de vue des types. Notre code est donc moins susceptible de contenir une erreur, mais en contrepartie, il est plus complexe.

Erreurs irrattrapables

Le deuxième type d'erreurs que l'on peut rencontrer sont les erreurs irrattrapables (les bugs par exemple).

Lorsque de notre programme se trouve dans un état incohérent, il est nécessaire d’interrompre son exécution. De manière générale, les bugs surviennent lorsque le développeur a fait une erreur dans son code. Il existe plusieurs erreurs possibles. Nous avons par exemple des erreurs :

  • algorithmiques (ex: un faute logique dans la conception de l'algorithme, un cas pas couvert)
  • du système (ex: la carte réseau n'est pas accessible)
  • humaines (ex: le développeur a fait une faute d'inattention en écrivant son code)

Il faut à tout prix éviter ces erreurs au maximum. Un programme qui interrompt constamment son exécution, n'est pas un programme robuste et par conséquent, un programme qui n'est pas fiable. La notion de fiabilité dépasse le cadre de ce cours, mais nous pouvons dire pour résumer qu'il existe plusieurs domaines et donc plusieurs niveau d'attente en terme de fiabilité. Il arrive souvent qu'un jeu soit buggé dans ses premières versions. En revanche, il est n'est pas acceptable que le pilote automatique d'un avion crash au en plein milieu d'un vol.

Le type Option

Historiquement, on représentait l'absence de valeur par une valeur dédiée (p.ex : une personne qui n'a pas renseigné son âge, possède un âge de -1, ou alors l'utilisation du pointeur NULL). Bien que cette solution fonctionne, elle peut-être la source de nombreuses erreurs et bugs en tout genre. Un type optionnel est une alternative moderne et plus robuste pour nombreuses erreurs et bugs en tout genre. Les langages modernes gèrent ces cas à l'aide de leur système de typage permettant de meilleures vérifications à la compilation et à l'exécution ce qui les rend plus robustes: on parle de types optionnels.

Il existe un grand nombre de fonctions qui ne retournent pas forcèment un résultat. On peut également penser à des structures. Le caractère optionnel d'une valeur n'est pas forcèment une erreur, mais si cet aspect n'est pas géré correctement, il peut mener à des erreurs.

Nous pouvons par exemple vouloir représenter un utilisateur qui peut s'il le veut fournir sa date de naissance et son adresse email. Prenons la structure suivante :

struct User {
    username: String,
    birth_date: i32,
    email: String,
}

Si on souhaite récupérer l'une de ces deux informations, on pourrait se retrouver dans un cas où l'utilisateur n'a pas souhaité renseigner l'information désirée. C'est là qu'intervient le type Option<T>. Il permet de représenter une valeur optionnelle.

Voici sa déclaration d'après la documentation Rust :

#![allow(unused)]
fn main() {
pub enum Option<T> {
    None,
    Some(T),
}
}

C'est tout simplement d'un type énuméré qui contient soit une valeur sous la forme Some(ma_valeur) ou pas de valeur None. Il s'agit de la version générique du type NumberOrNothing vu dans la partie 2.

Nous pouvons donc réecrire notre structure User de cette manière :

struct User {
    username: String,
    birth_date: Option<i32>,
    email: Option<String>,
}

Si nous reprenons notre exemple du minimum d'un tableau, nous pouvons écrire notre fonction de la manière suivante :

pub fn find_min_with_option<T: Minimum>(tab: &[T]) -> Option<T> {
    let mut minimum = None;
    // Here is T is Copyable. Which means that t is not moved in the loop
    for t in tab {
        minimum = Minimum::min(minimum, Some(*t));
    }
    minimum
}

Ici on commence par instancier notre minimum à None, puis on compare itérativement notre minimum avec les valeurs du tableau encapsulées dans une option avec Some(*t).

Pour comparer deux options entre elles, nous avons implémenté le trait minimum pour une Option<T>, où T implémente le trait minimum. Ce qui nous donne :

impl<T: Minimum> Minimum for Option<T> {
    fn min(self, rhs: Self) -> Self {
        match self {
            Some(val_l) => Some(match rhs {
                Some(val_r) => val_l.min(val_r),
                None => val_l,
            }),
            None => match rhs {
                Some(val_r) => Some(val_r),
                None => None,
            },
        }
    }
}

On peut voir ici que l'on décompose notre option grâce au pattern matching de Rust.

La macro panic!

Un programme peut être amené à s'arrêter de manière anormale. En C, on utilise la fonction abort. Cette dernière va indiquer au processus parent ayant exécuté le programme qu'une erreur s'est produite et que l'exécution ne peut pas se poursuivre.

En Rust, il est possible de mettre fin au programme d'une manière similaire à ce que l'on retrouve en C avec la fonction suivante :

use std::process;

fn main() {
    process::abort();
}

Néanmoins, Rust étant un langage moderne, il possède la macro panic!. Cette maco ne va pas simplement interrompre l'exécution du programme et signaler une erreur au processus appelant. Par défaut, elle va également remonter la pile des appels de fonctions et libérer la mémoire au fur à mesure.

Pour déclencher une panique du programme, il suffit d'appeler la macro avec un message en argument :

fn main() {
    panic!("!!! Oups something went wrong !!!")
}

Si on exécute ce code, on obtient l'erreur suivante :

   Compiling playground v0.0.1 (/playground)
    Finished dev [unoptimized + debuginfo] target(s) in 0.41s
     Running `target/debug/playground`
thread 'main' panicked at '!!! Oups something went wrong !!!', src/main.rs:2:5
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

On peut voir que la console nous indique que le fil d'exécution principal main a paniqué, et il nous affiche le message que nous avons passé en argument à notre macro panic!.

Si nous reprenons notre exemple du minimum d'un tableau, nous pouvons écrire notre fonction de la manière suivante :

pub fn find_min_with_panic<T: Minimum>(tab: &[T]) -> T {
    let mut minimum = None;
    // Here is T is Copyable. Which means that t is not moved in the loop
    for t in tab {
        minimum = Minimum::min(minimum, Some(*t));
    }

    // We decide that we cannot compute the minimum of an empty array
    match minimum {
        Some(val) => val,
        None => panic!("The array is empty"),
    }
}

La première chose que nous pouvons noter avec notre fonction est le type de retour. En effet, nous ne retournons pas une option, mais bien un élèment de type T. En effet, si on est certain que notre fonction va retourner un élément, il n'y a pas de raison d'encapsuler ce retour dans une strucutre quelconque.

Si malgré notre certitude, la fonction ne parvenait pas à produire de résultat, nous serions alors dans un cas indertimné, et par conséquent il faut mettre fin à l'exécution du programme. Pour cela, on peut voir dans la dernière expression que si le minimum n'a pas été trouvé None => panic!("The array is empty"),, alors le programme se termine sur une panic.

Le message d'erreur passé en argument est remonté plus haut. Il est en effet possible d'intercepter les panic grâce à la fonction std::panic::catch_unwind ou en rédéfissant manuellement le comportement de la macro panic! à l'aide de la fonction std::panic::set_hook. Néanmoins, l'usage de ces fonctions devrait être limité à des cas très spécifiques que nous ne couvrirons pas dans ce cours d'introduction au Rust.

Le développeur néophyte pourrait être tenté de simplifier (dans le sens du nombre de caractères) son code en utilisant uniquement la macro panic!. Cela va à l'encontre des bonnes pratiques du Rust et aura tendance à rendre votre code peu compréhensible et défaillant. Je vous recommande cet excellent article de la documentation Rust qui explique quand utiliser la macro panic! à bon escient.

En résumé, la macro panic devrait être utilisé à des fins de debugging ou uniquement lorsque le programme rentre dans un état indeterminé, c'est à dire un erreur irrattrapable.

Le type Result

Le Rust offre une solution plus élégante que d'interrompre l'exécution du programme pour gérer les erreurs. Il s'agit comme pour Option<T> d'un type énuméré. Voici sa définition :

#![allow(unused)]
fn main() {
enum Result<T, E> {
   Ok(T),
   Err(E),
}
}

Le type Result<T,E> peut contenir soit une valeur attendue en cas de réussite sous la forme d'un Ok(ma_valeur), ou valeur permettant de renseigner l'erreur sur forme de Err(mon_erreur). C'est un type plus complet qu'une simple Option<T>, car en cas d'absence de valeur, nous pouvons indiquer la cause de l'erreur.

Prenons par exemple la fonction suivante qui permet de télécharger une page WEB :

fn get_html(uri: String) -> String

Un problème pourrait se produire lors du téléchargement. On pourrait donc écrire :

fn get_html(uri: String) -> Option<String>

Si notre fonction nous retourne None, on se rend compte très vite que l'on a aucune information sur la raison de l'absence de données (pas de connexion, mauvaise url, accès interdit, etc...).

C'est là qu'intervient le type Result<T,E>, en cas d'absence de résultat de la fonction, nous allons pouvoir comprendre la source de l'erreur et réagir réagir en conséquence. Nous pourrions donc écrire :

enum DownloadError {
  BadURL,
  Forbidden,
  ...
}
fn get_html(uri: String) -> Result<String, DownloadError>

Reprenons maintenant notre exemple du minimum d'un tableau. La première chose que nous pouvons faire est de définir type représentant les erreurs que nous pourrions rencontrer en cherchant le minimum :

#[derive(PartialEq)]
pub enum FindMinError {
    EmptyList,
    UnsupportedError(String),
}

Ici nous envisageons deux scénarios pouvant provoquer une erreur :

  • Le minimum d'une liste vide
  • Une éventuelle erreur que nous n'aurions pas encore prévu. Cette erreur est accompagnée d'un message décrivant l'erreur.

Une fois nos erreurs définies, nous pouvons passer à l'implémentation de notre fonction de recherche du minimum :

pub fn find_min_with_result<T: Minimum>(tab: &[T]) -> Result<T, FindMinError> {
    let mut minimum = None;
    // Here is T is Copyable. Which means that t is not moved in the loop
    for t in tab {
        minimum = Minimum::min(minimum, Some(*t));
    }

    match minimum {
        Some(val) => Ok(val),
        None => Err(EmptyList),
    }
}

Cette fonction n'est pas très différente des précédentes. On remarque à la fin que pour former un Result, si nous trouvons un minimum, nous l'encapsulons dans un Ok(...), sinon nous retournons une erreur avec Err(...). Ici la seule erreur que nous retournons est la liste vide.

Pour traiter ce résultat, nous pouvons faire du pattern matching :

    let min = find_min_with_result(&tab_empty);
    match min {
        Ok(val) => print!("The minimum value is {}", val),
        Err(EmptyList) => eprintln!("The array is empty"),
        Err(UnsupportedError(msg)) => panic!("Unsupported error : {}", msg),
    }

Ici nous avons trois cas :

  • le minimum a été trouvé, on l'affiche.
  • la liste était vide, on affiche un message d'erreur.
  • le minimum n'a pas pu être trouvé à cause d'une erreur non gérée, le programme va donc se terminer sur une panic contenant le message d'erreur retourné par la fonction.

Il est commun de rencontrer un usage pouvant mener facilement à des erreurs de Result. Prenons par exemple Result<T, String> ou Result<T, i32>. En règle général, ces types concrets permettent de retourner un message ou un code en cas d'erreur. Bien que cela ne pose pas de problème du point de vue purement fonctionel, il rend aveugle le compilateur. En effet, en définissant les erreurs attendues avec un type énuméré, le compilateur peut s'assurer que toutes les erreurs ont été prises en compte lors d'une décomposition de l'erreur.

Prenons l'exemple suivant :

fn foo() -> Result<i32, String>{
  // do stuff
  match val {
    1 => Ok(42),
    2 => Err("Oupsi"),
    3 => Err("G phai une faute"),
    _ => Err("Aie aie aie")
  }
}

Imaginons que j'aie fait une faute dans mon message d'erreur. Mon message d'erreur est le seul moyen de différencier les différentes erreurs possibles. En corrigeant ma faute, je dois maintenant mettre à jour tout mon code, ainsi que le code de tous ceux qui utilisent mon code. Je peux également me tromper sur la casse de mon message d'erreur la ponctuation etc... Le compilateur ne verra pas l'erreur et je peux passer des heures à chercher pourquoi mon code ne marche pas.

Il en va de même avec les codes d'erreurs numériques, il suffit que je veuille changer un seul code pour devoir à nouveau mettre tout à jour.

Il est donc fortement recommandé d'éviter cet usage du type Result.

Néanmoins, l'usage de type énuméré pour la gestion des erreurs peut-être source de redondance. On se retrouve avec des types qui représentent les mêmes erreurs avec des noms différents dans chaque crate. Pour éviter cela, il est préférable de commencer par chercher si son erreur existe avant de créer une nouvelle erreur. Cela dépasse le cadre de ce cours, mais sachez qu'il existe deux crates populaires qui peuvent vous aider pour la gestion d'erreur :

L'opérateur ?

Le language Rust offre un sucre syntaxique, afin de simplifier la gestion des options et des erreurs imbriquées. L'opérateur ? permet de récupérer la valeur contenue ou faire remonter l'erreur ou l'absence de valeur. On s'en sert principalement pour les Option et les Result. Pour plus de détails sur l'interface Try qui permet d'utiliser l'opérateur ? sur un type quelconque, je vous recommande la documentation.

Prenons un exemple directement tiré de notre code :

pub fn find_min_amongst_arrays_by_hand<T: Minimum>(
    lhs: &[T],
    rhs: &[T],
) -> Result<T, FindMinError> {
    let min_result = find_min_with_result(lhs);
    let min_l = if let Ok(x) = min_result {
        x
    } else {
        // Since tmp is not Ok, we return the error to the caller
        return min_result;
    };

    let min_result = find_min_with_result(rhs);
    let min_r = if let Ok(x) = min_result {
        x
    } else {
        // Since tmp is not Ok, we return the error to the caller
        return min_result;
    };

    Ok(min_l.min(min_r))
}

Cette fonction prends deux tableaux en argument et va chercher quelle est la valeur minimum globale. Si on veut réutiliser le code que nous avons déjà écrit, nous avons une fonction qui retourne un Result et qui nous donne la valeur minimale d'un tableau. Il nous suffit donc de chercher la valeur minimale dans le premier tableau, puis dans le deuxième, et enfin retourner la plus petite des deux valeurs.

Seulement, ce n'est pas aussi simple puisque l'on obtient un Result pour chaque fonction, il faut systèmatiquement verifier qu'aucune erreur s'est produite. Ici, si une erreur est survenue, nous n'avons rien d'autre à faire que de retourner l'erreur telle quelle.

C'est en réalité un cas de figure que l'on retrouve souvent. On peut voir dans notre code ci-dessus, le code est répetitif et rends le code la fonction moins lisible.

Avec l'opérateur ? on peut simplement remplacer le test ainsi :

pub fn find_min_amongst_arrays_qm_op<T: Minimum>(lhs: &[T], rhs: &[T]) -> Result<T, FindMinError> {
    // The question mark operator will unpack the value if the function returns [Result::Ok]
    // or end the function and return the [Result:Err] to the caller.
    let min_l = find_min_with_result(lhs)?;
    let min_r = find_min_with_result(rhs)?;

    Ok(min_l.min(min_r))
}

Ces deux fonctions font strictement la même chose. L'opérateur agit comme un sucre syntaxique qui permet d'allèger l'écriture du code et ainsi augmenter sa lisibilité. En clair, si le résultat est Ok(val), l'expression retourne val, sinon la fonction se termine ici et retourne le résultat Err(error) contenu dans le résultat.

Rustlings

Les rustlings à faire dans ce chapitre sont les suivants:

La gestion des erreurs

$ rustlings run errors1
$ rustlings run errors2
$ rustlings run errors3
$ rustlings run errors4
$ rustlings run errors5
$ rustlings run errors6

Les options

$ rustlings run options1
$ rustlings run options2
$ rustlings run options3

Discussion du code closures

Concepts

Les concepts abordés dans cet exemple sont:

Documentation

Afin de compléter ce cours, je vous recommande la lecture des ressources suivantes :

Discussion

De plus en plus de langages proposent des fonctions anonymes (des fonctions sans identifiant). Ces fonctions sont un outil précieux de la programmation fonctionelle qui gagne elle aussi en popularité. Nous présenterons dans ce cours ce qu'elles sont et les avantages qu'elles peuvent apporter.

Fonctions anonymes

Une fonction anonyme est une fonction sans identifiant, qui permet de transformer un comportement en variable.

Prenons un exemple simple :

|x: i32| -> i32 { x + 1 }

Nous avons entre | les arguments de notre fonction anonyme, ici x qui est de type i32. À la suite du deuxième |, nous avons le type de retour et finalement entre {}, une expression qui est le corps de la fonction. Il n'est pas obligatoire d'indiquer le type de l'argument, le type de retour ou de mettre entre accolades le corps de la fonction, cela dépendra des déductions que le compilateur peut réaliser. En une phrase, on peut lire l'expression ci-dessus comme étant : "Une fonction qui pour tout entier signé de 32 bits x donne x + 1".

Cette expression en Rust est une fonction anonyme. Son équivalent en fonction standard serait :

fn add_one(x:i32) -> i32 {
  x + 1 
}

Il est possible de stocker nos fonctions anonymes dans une variable, par exemple :

    let max_op: fn(i32, i32) -> i32 = |x, y| if x >= y { x } else { y };

Fonction d'ordre supérieur

Une fonction d'ordre supérieur est une fonction qui prends en argument une ou plusieurs fonctions et/ou retourne une ou plusieurs fonctions.

L'intérêt principal de nos fonctions anonyme est de pouvoir les passer et les retourner via des fonctions.

Prenons un petit exemple :

fn plus_two(x: i32, add_one: fn (i32) -> i32) -> i32 {
  add_one(add_one(x))
}

fn main(){
  let add_one = |x| x + 1;
  println!("{}", plus_two(5, add_one));
}

Ici nous avons une fonction qui additionne 2 à un entier x, mais pour cela notre fonction doit tout d'abord savoir additionner 1. Elle prend donc en argument l'entier x auquel elle doit additionner 2, et une fonction permettant d'additioner 1 à un nombre appelée add_one.

Si on se penche sur cet argument nous avons add_one: fn (i32) -> i32, le type de cet argument est une fonction, qui prend un i32 et retourne un i32.

Puisque la fonction plus_two prends en argument une fonction, il s'agit d'une fonction d'ordre supérieur.

Dans la fonction main, on déclare une variable add_one qui va contenir une fonction anonyme qui additione 1 à un entier. A titre d'illustration nous appliquons plus_two à 5 et notre fonction anonyme stockée dans une variable nommée add_one.

Cet exemple est un peu trivial, mais il permet de saisir brièvement la syntaxe.

Nous pouvons modifier notre exemple du calcul du minimum d'une liste, pour y ajouter des fonctions d'ordre supérieur. Dans un premier temps, nous pouvons généraliser le comportement de notre fonction de recherche du minimum. Pour cela, On commence par créer un type BinaryOperator<T> :

pub type BinaryOperator<T> = fn(T, T) -> T;

Si on lit sa définition, on s'apperçoit qu'il s'agit d'une fonction qui prends deux éléments de type T et en retourne un troisième du même. Cette définition s'applique parfaitement à la fonction minimum, je prends deux éléments du même type, je détermine lequel est le plus petit et je le retourne.

Prenons maintenant le code de notre fonction du calcul du minimum généralisée :

pub fn find_with_hof<T: Copy>(tab: &[T], op: BinaryOperator<T>) -> Option<T> {
    let mut res = None;
    // Here is T is Copyable. Which means that t is not moved in the loop
    for t in tab {
        if let Some(val) = res {
            res = Some(op(val, *t))
        } else {
            res = Some(*t)
        }
    }
    res
}

Cette fonction est sensiblement la même que la fonction étudiée dans par la partie 07. Le premier changement qu'il faut remarquer, intervient au niveau des arguments.

On voit apparaître un nouvel argument op: BinaryOperator<T>. Il s'agit simplement de l'opération que nous allons utiliser itérativement sur notre tableau, afin d'obtenir un résultat. On pourrait prendre par exemple la fonction minimum.

Contrairement au trait Minimum que nous avions défini jusqu'à maintenant, nous allons gérer manuellement le résultat qui est intialisé à None. La deuxième modification que nous devons remarquer se trouve dans le corps de la boucle. Pour gérer le résultat vide que l'on recontre durant la première itération, on utilise un simple assignation conditionnelle comme nous l'avons vu dans les cours précédent.

Avec cette fonction d'ordre supérieur, nous pouvons désormais utiliser n'importe quelle opération qui réduit deux éléments en un seul.

En utilisant un autre type de fonction d'ordre supérieur, celles qui retournent un fonction, on peut générer plusieurs opérateurs de ce type.

pub fn minimum_operator<T: PartialOrd>() -> BinaryOperator<T> {
    |x: T, y: T| if x <= y { x } else { y }
}
pub fn maximum_operator<T: PartialOrd>() -> BinaryOperator<T> {
    |x: T, y: T| if x >= y { x } else { y }
}
pub fn sum_operator<T: std::ops::Add<Output = T>>() -> BinaryOperator<T> {
    |x: T, y: T| x + y
}
pub fn mul_operator<T: std::ops::Mul<Output = T>>() -> BinaryOperator<T> {
    |x: T, y: T| x * y
}

Pour l'utiliser ces fonctions rien de plus simple :

    let min = find_with_hof(&tab, minimum_operator());
    match min {
        Some(val) => println!("The minimum value is {}", val),
        None => eprintln!("There is no minimum"),
    }

Il suffit d'appeler la fonction minimum_operator qui va nous retourner notre fonction anonyme capable de déterminer le minimum entre entre deux nombres.

On voit ici tout l'intérêt de nos fonctions d'ordre supérieur. Il me suffit d'écrire une seul fois le code qui réduit mon tableau et je peux choisir mon opération en lui passant simplement le comportement en argument, comme si c'était une variable quelconque.

Closures

En Rust, les fonctions anonymes se nomment closures. On recontre parfois la traduction fermetures. Les closures ne sont pas simplement des fonctions sans identifiant, en effet, une closure capture son environement. Une closure est composée d'une fonction anonyme et des variables capturée dans son environnement.

Voici un exemple de code qui illustre le concept de caputre :

fn main(){
  let a : i32 = 50;
  let divide_by_a = |x:i32| x / a;
  println!("{}", divide_by_a(100));
}

Ici nous avons une variable a qui contient un i32 et une variable divide_by_a qui contient une closure qui prend un i32 en argument et retourne un i32. Ce qu'il faut remarquer, c'est que la variable a est capturée par la closure. Ce qui siginifie que si je passais la variable divide_by_a à une fonction, la variable a serait elle aussi passée indirectement.

Jusqu'à maintenant, nous nous sommes contentés de passer en argument des closures qui ne capturaient aucune variable. Pour passer par exemple notre closure divide_by_a qui capture une variable, il nous faudra utiliser par exemple le trait Fn. Sans entrer dans les détails, c'est l'un des trois traits qu'implémentent toutes les closures. Nous ne verrons pas les deux autres dans le cadre de ce cours, mais nous avons FnOnce et FnMut

Modifions donc notre code pour ajouter une fonction :

fn do_stuff<F: Fn(i32) -> i32>(op: F) -> i32 {
  op(100)
}
fn main(){
  let a: i32 = 50;
  let divide_by_a = |x: i32| x / a;
  println!("{}", do_stuff(divide_by_a));
}

Dans le code ci-desssus, on peut voir que la fonction do_stuff prends un argument appelé op de type générique F. Notre type générique F est un type implémentant Fn(i32) -> i32, c'est à dire une fonction qui prend en argument un i32 et retourne un i32. Il ne faut surtout pas confondre fn qui est un mot clé du langage et Fn, qui est un trait décrivant entre autre une closure qui capture des éléments de son environnement.

Exemples d'utilisation avec les options

Les options proposent également des méthodes qui prennent en argument d'autres fonctions. Nous pouvons en voir deux dans notre code.

  • map
  • filter

La fonction map permet de transformer le contenu d'une option si celle-ci n'est pas None, ou de ne rien faire dans le cas contraire. Prenons l'exemple suivant :

    let two: f32 = 2.0f32;

    let sum: Option<i32> = find_with_hof(&tab, sum_operator());
    let half: Option<f32> = sum.map(|x: i32| (x as f32) / two);
    match half {
        Some(val) => println!("The sum of the elements divided by two is {}", val),
        None => eprintln!("There is no sum"),
    }

Dans le code ci-dessus, nous pouvons voir un exemple d'utilisation de la méthode map. Nous récupérons tout d'abord la somme des éléments du tableau, sous forme d'option. Ensuite, pour transformer cette option, on utilise une closure, qui permet de diviser un i32 par deux et qui retourne un f32. On transforme donc une Option<i32> en Option<f32>. Si la méthode find_with_hof retourne une option vide, alors l'option retournée par map reste None.

    let max_val: Option<i32> = find_with_hof(&tab, max_op);
    let odd_max: Option<i32> = max_val.filter(|x| x % 2 == 1);
    match odd_max {
        Some(_) => println!("The maximum value is an odd number"),
        None => {
            if max_val.is_some() {
                println!("The maximum value is an even number")
            } else {
                eprintln!("There is no maximum")
            }
        }
    }

Ici, nous pouvons voir un exemple d'utilisation de la méthode filter. Nous cherchons le plus grand élément du tableau. Ensuite, nous essayons de déterminer sa parité à l'aide d'une closure qui retourne true si le nombre est impaire.

Ici, nous avons 2 étapes :

  • La fonction find_with_hof retourne une option max_val
  • Sur max_val, nous appliquons un filtre, ce qui nous donne odd_max. Nous avons donc 3 cas possibles
    • Si odd_max contient une valeur et que cette valeur est impaire, on affiche un message qui annonce que le maximum est impaire.
    • Sinon si l'max_val contient une valeur, on affiche un message qui annonce que le maximum est paire.
    • Sinon, max_val est None et donc il n'y a pas de maximum.

Rustlings

Il n'y a pas de rustlings à faire dans ce chapitre.

Discussion du code iterateurs

Concepts

Les concepts abordés dans cet exemple sont:

Documentation

Afin de compléter ce cours, je vous recommande la lecture des ressources suivantes :

Discussion

Le trait itérateur

L'itérateur est un patron de conception extrêment répandu en prorgrammation orientée objet. On le retrouve dans plusieurs langages comme par exemple python.

Le trait Iterator est défini ainsi en version simplifiée dans la documentation :

#![allow(unused)]
fn main() {
trait Iterator {
    type Item;
    fn next(&mut self) -> Option<Self::Item>;
}
}

Un itérateur possède donc un élément courant et une méthode next() qui permet de passer à l'élément suivant, puis de retourner l'élément courant. Un itérateur peut-être fini ou infini. Il peut permettre par exemple de parcourir une collection ou de créer un compteur.

La documentation Rust offre un exemple d'implémentation d'itérateur assez accessible.

Les fonctions iter et collect

Le Rust propose des méthodes permettant de passer d'une collection à un itérateur assez facilement.

fn main(){
    let v : Vec<i32> = vec![1,2,3,4];
    let mut v_iter = v.iter();

    println!("{}", v_iter.next().unwrap());
    println!("{}", v_iter.next().unwrap());
    println!("{}", v_iter.next().unwrap());
    println!("{}", v_iter.next().unwrap());
    assert!(v_iter.next().is_none());
}

La méthode iter() des vecteurs permet de créer un itérateur à partir d'un vecteur. Il faut noter que v_iter est mutable, car son état interne est modifié par l'appel à la méthode next(). En effet, pour pouvoir avancer dans notre itérateur, nous utilisons la fonction next() qui avance l'élément courant d'une position et retourne une Option sur l'élément courant. Pour rappel, la signature de la méthode de next() est fn next(&mut self) -> Option<Self::Item>;.

Il est aussi possible de transformer un itérateur en collection à l'aide de la méthode collect(). La fonction from_fn() permet de créer un itérateur à l'aide d'une fonction :

fn main(){
  let mut count = 0;
  let even_counter = std::iter::from_fn(|| {
    count += 2;
    Some(count)
  });

  let v : Vec<i32> = even_counter.take(3).collect();
  println!("Les 3 premiers nombres paires sont {}, {}, {}", v[0], v[1], v[2])
}

Le code ci-dessus commence par créer un itérateur infini de nombres paires. Nous verrons plus-tard que les éléments de l'itérateur infini n'est pas généré immédiatement dans la section lazy-evaluation. Nous utilisons une variable mutable count, afin de générer les valeurs de notre itérateur. Notre closure va capturer cette variable et l'incrémenter de 2 à chaque appel et retourner la valeur courante encapsulée dans une Option.

Notre itérateur étant infini, nous devons le limiter à un nombre fini d'éléments avant de pouvoir récupérer une collection. Pour cela nous utilisons la méthode take() qui permet de limiter la taille de notre itérateur. Finalement, nous pouvons récupérer un vecteur d'entier à l'aide de la méthode collect().

Quelques fonctions d'ordre supérieur sur les itérateurs

L'intérêt principal des itérateurs en Rust réside dans ses fonctions d'ordre supérieur. Elle permettent de traiter des collections de manière efficaces en apportant des garanties notament en terme de concurrence. On retrouve notament le crate Rayon-rs qui permet d'effectuer des traitement en parallèle sans apporter de modifications majeures au code.

Ici, nous nous concentrerons sur les méthodes suivantes qui sont très communes dans les langages fonctionnels (pas toujours avec les mêmes noms) :

  • map()
  • filter()
  • fold()
  • zip()

Reprenons notre code de recherche du minimum d'une liste :

pub fn find_minimum(v: &Vec<i32>) -> Option<i32> {
    v.iter().fold(None, |acc, current| {
        let next_acc = if let Some(val) = acc {
            if val <= *current {
                val
            } else {
                *current
            }
        } else {
            *current
        };
        Some(next_acc)
    })
}

Notre fonction prend en argument un vecteur v que nous transformons en itérateur avec la méthode iter(). Pour trouver le minimum de notre itérateur, nous utilisons la méthode fold(). Cette méthode permet de réduire un itérateur en appliquant itérativement, une fonction qui prend deux arguments et qui retourne un seul élément. On se retrouve donc avec une seule valeur à la fin de la réduction.

La méthode fold() prend donc deux arguments. Le premier est la valeur d'initialisation de notre réduction. On parle parfois d'élément neutre, mais ce terme est plus restrictif qu'une simple valeur initiale.

L'élément neutre d'une opération est l'élément N qui si on lui applique l'opération avec n'importe quel autre élément x retourne cet élément x. Si nous prenons l'exemple de l'addition, l'élément neutre est 0, car : $$ 0 + x = x + 0 = x $$ et ce peu importe la valeur de x. Il est préférable d'utiliser un élément neutre, plutôt qu'une valeur quelconque pour l'initialisation de notre réduction. Sans rentrer dans les détails, qui dépassent le cadre de ce cours, les algoritmes parallèles de réduction reposent sur l'usage d'un élément neutre. Utiliser un élément neutre, vous permettra donc de parallèliser votre code bien plus simplement.

En l'occurence puisqu'on veut retourner une option, notre élément neutre est None.

Le deuxième argument de notre fonction fold() est l'opération de réduction que nous utiliserons pour trouver le minimum. Pour cela nous utilisons une closure. Cette dernière prend deux argument, un accumulateur (la valeur courante de la réduction) de type Option<i32> et un l'élément courant de l'itérateur qui est de type &i32. Notre closure va simplement retourner une Option contenant l'élément le plus petit entre la valeur actuelle de l'accumulateur et la valeur courante. Cette valeur devient la nouvelle valeur de l'accumulateur pour l'appel suivant.

Le résultat de la méthode fold() est la dernière valeur de l'accumulateur. Si l'itérateur est vide, la méthode fold() retournera la valeur d'initialisation. Dans notre cas, il s'agit d'une Option vide.

Pour illustrer l'usage de la méthode filter(), nous avons une fonction qui trouve le plus petit nombre pair dans un vecteur :

pub fn find_even_minimum(v: &Vec<i32>) -> Option<i32> {
    v.iter().filter(|i| *i % 2 == 0).fold(None, |acc, current| {
        let next_acc = if let Some(val) = acc {
            if val <= *current {
                val
            } else {
                *current
            }
        } else {
            *current
        };
        Some(next_acc)
    })
}

La méthode est similaire à la recherche du minimum, mais afin de garder uniquement les nombres pairs on ajoute la fonction filter(). Quand on ajoute ainsi une série de traitements à la suite, on parle de pipelines.

La fonction filter() prend une fonction en paramètre appelée prédicat. Un prédicat et une fonction qui prend un élément et retourne un booléen. Cette fonction va déterminer quels éléments conserver. Ici nous utilisons une closure qui retourne true si le nombre est pair. La méthode filter() va retourner un nouvel itérateur composé uniquement des éléments séléctionnés par le prédicat.

Pour finir, on recherche la valeur minimum de ce nouvel itérateur, de la même façon que dans l'exemple précédent, à l'aide de la méthode fold().

Essayons maintenant de résoudre un problème un peu plus complexe en ajoutant les méthodes zip() et map(). Nous aimerions trouver quel est l'élément le plus petit en valeur absolue d'un vecteur donné.

pub fn find_absolute_minimum(v: &Vec<i32>) -> Option<i32> {
    let signs = v.iter().map(|i| i.signum());
    let abs_values = v.iter().map(|i| i.abs());
    signs
        .zip(abs_values)
        .fold(None, |acc, (c_sign, c_abs_v)| {
            let next_acc = if let Some((sign, abs_v)) = acc {
                if abs_v <= c_abs_v {
                    (sign, abs_v)
                } else {
                    (c_sign, c_abs_v)
                }
            } else {
                (c_sign, c_abs_v)
            };
            Some(next_acc)
        })
        .map(|(sign, abs_v)| sign * abs_v)
}

La première étape consiste à créer deux itérateurs. Le premier contient le signe de chaque élément et le deuxième, la valeur absolue de chaque élément.

    let signs = v.iter().map(|i| i.signum());
    let abs_values = v.iter().map(|i| i.abs());

Pour obtenir ces itérateurs, nous allons transformer nos itérateurs obtenus avec iter() en utilsant la fonction map(). Cette méthode permet d'appliquer une même transformation sur tous les éléments d'un itérateur. Pour l'itérateur signs, on appelle la méthode signum() des i32, qui retourne 1 si le nombre est positif, 0 si le nombre est 0 et -1 si le nombre est négatif. Pour abs_values, nous utilisons la méthode abs() des i32, qui retourne la valeur absolue d'un nombre.

    signs
        .zip(abs_values)

Maintenant que nous avons nos deux itérateurs, nous aimerions pouvoir itérer sur les deux simultanément. Pour cela, nous pouvons utiliser la méthode zip(). Elle permet de transformer deux itérateurs, en un unique itérateur de tuple. Ici nous avons deux itérateurs de i32, qui deviennent donc un seul itérateur de type tuple (i32, i32).

        .fold(None, |acc, (c_sign, c_abs_v)| {
            let next_acc = if let Some((sign, abs_v)) = acc {
                if abs_v <= c_abs_v {
                    (sign, abs_v)
                } else {
                    (c_sign, c_abs_v)
                }
            } else {
                (c_sign, c_abs_v)
            };
            Some(next_acc)
        })

Ensuite avec fold(), il nous suffit de comparer les valeurs absolues et de retourner une option contenant le signe et la valeur absolue.

        .map(|(sign, abs_v)| sign * abs_v)

Pour finir, on utilise la méthode map() de notre Option<(i32,i32)> pour multiplier la valeur absolue par le signe. Ce qui nous donne au final une Option<i32> contenant notre résultat.

Performances et lazy evaluation

Les transformations sur les itérateurs sont en général aussi perfomantes qu'une simple boucle for. On peut voir par exemple ce benchmark dans le livre Rust. Si on ajoute la possibilité de parallèliser facilement un code basé sur les itérateurs, leur intérêt paraît évident.

Un des éléments qui explique les perfomances des itérateurs réside dans la lazy evaluation. Lorsqu'on appelle une opération de transformation sur un itérateur, la transformation n'est pas réalisée directement. C'est uniquement lorsqu'on appelle une fonction dite terminale, comme par exemple fold() ou collect() qui doivent produire un résulat. Les transformations intermédiaires peuvent ainsi souvent être fusionnés.

Ce qui veut dire par exemple que dans le code suivant,

fn main() {
  let v = vec![0,1,2,3,4,5];
  let it = v.iter().map(|x| x + 1).map(|x| x * 3).filter(|x| x % 2 == 1);
  let res : Vec<i32> = it.collect();
}

que tant que la fonction collect() n'a pas été appelée, alors aucune transformation n'est effectuée. Si vous ne nous croyez pas, un moyen simple de vous en convaincre, consiste à appliquer une série de transformation sur un itérateur infini et de mesurer la performance.

Rustlings

Les rustlings à faire dans ce chapitre sont les suivants:

Les itérateurs

$ rustlings run iterators1
$ rustlings run iterators2
$ rustlings run iterators3
$ rustlings run iterators4
$ rustlings run iterators5

Les collections

Concepts

Les concepts abordés dans cet exemple sont:

Documentation

Afin de compléter ce cours, je vous recommande la lecture des ressources suivantes :

Discussion

En Rust, comme dans la plupart des langages modernes, il existe des structures de données qui permettent de se simplifier la vie lors de l'implémentation de divers algorithmes.

Dans ce chapitre, nous allons discuter des types Vec<T>, String, des slices.

Dans ce code nous modifions que très peu le code de la partie 6 afin d'utiliser les types Vec<i32>, String et les slices.

Le type Vec<T>

Le type Vec<T> est une collection qui permet de stocker des données d'un type unique générique, T. Ces données sont stockées dans un espace mémoire qui est garanti d'être contigu. Cet espace mémoire peut changer dynamiquement à l'exécution contrairement aux tableaux statiques. D'un certain point de vue, on peut le considérer comme un "pointeur intelligent": un Vec est un pointeur sur le tas, qui a une certaine capacité mémoire et sait combien la mémoire sur laquelle il pointe est pleine.

Dans notre code, nous avons créé une fonction read_command_line()

/// Poorly emulates the parsing of a command line.
pub fn read_command_line(len: usize) -> Vec<i32> {
    let mut rng = rand::thread_rng();
    let mut v: Vec<i32> = Vec::new();
    for _i in 0..len {
        v.push(rng.gen());
    }
    v
}

Ici, un Vec<i32> est instancié et on ajoute des éléments aléatoires dedans à l'aide de la crate rand(). Pour créer un Vec vide, on utilise la fonction associée, qui crée un vecteur vide

#![allow(unused)]
fn main() {
    let mut v: Vec<i32> = Vec::new();
}

Ensuite, on remplit le vecteur avec des nombres aléatoires à l'aide d'une boucle for

    for _i in 0..len {
        v.push(rng.gen());
    }

qui itère sur les indices allant de 0 à len-1. Comme, nous n'utilisons pas l'indice, nous l'ignorons à l'aide de l'annotation _i. On ajoute des éléments dans le vecteur avec la fonction push() et comme nous modifions v il est primordial de noter qu'il est mut-able. Le générateur de nombre aléatoire est stocké dans une variable rng qui doit elle aussi être mutable. En effet, les générateurs de nombres aléatoires stockent en général un état interne.

Le type String

Une String ou un chaîne de caractère, est une séquence de caractères UTF-8. On pourrait penser naïvement que ce n'est rien d'autre qu'un Vec<char>: en fait c'est plus compliqué que cela. Bien que la chaîne de caractères, soit également rien d'autre qu'un pointeur vers des données sur le tas, ainsi qu'une variable contenant la capacité de la mémoire sur laquelle pointe le pointeur et son remplissage.

Le problème principal vient de l'encodage UTF-8: c'est un encodage de taille variable qui englobe les caractères ASCII (qui sont stockés sur un octet) et une très grande quantité d'autres caractères (l'alphabet grec, des emojis, etc.) qui sont stockés sur 1 à 4 octets. Ainsi chaque lettre de la chaîne de caractère correspond à un nombre variable d'octets (ce qui n'est pas le cas pour un Vec). Il est donc très vivement déconseillé de tenter d'indexer une String (faire s[i]) comme on le ferait avec un Vec. Il faut plutôt utiliser la méthode .get(i) qui interprète les caractères en fonction de la longueur de leurs encodages.

Une illustration de l'utilisation d'une chaîne de caractère se trouve à la fonction

/// Poorly emulates the parsing of a command line.
pub fn read_command_line_str() -> Result<Vec<i32>, String> {
    let mut s = String::from("20 10 48 58 29 0 58 -10 39 5485 394");
    s.push_str(" -100");
    s.push(' ');
    s.push('1');
    s.push('2');
    let s: Vec<&str> = s.split_ascii_whitespace().collect();

    let mut v = Vec::new();
    for i in 0..s.len() {
        v.push(
            s.get(i)
                .ok_or(String::from("Unable to index"))?
                .parse()
                .map_err(|_| format!("Unable to parse {}", s[i]))?,
        );
    }
    Ok(v)
}

Cette fonction construit une chaîne de caractères constituées de nombres et d'espaces, puis la transforme en Vec<i32> dont on calculera ensuite le minimum.

Une String est souvent construite à partir d'une "chaîne littérale" à l'aide du trait de conversion From (on les reconnaît parce qu'elles sont entourées de guillemets, "")

    let mut s = String::from("20 10 48 58 29 0 58 -10 39 5485 394");

dont le type est str formellement (c'est une chaîne de caractères qui vit durant toute la durée de vie du programme). Néanmoins, le type str n'est jamais utilisé en tant que tel en Rust, mais on utilise plutôt son "slice" &str (plus sur les "tranches" dans la section suivante).

Nous voyons que dans le code ci-dessus nous avons déclaré la variable s comme étant mutable, car ensuite nous ajoutons une slice de chaîne de caractère (de type &str) à l'aide de la fonction push_str() dans s

    s.push_str(" -100");

On peut également ajouter des char (ils sont entourés d'apostrophes ' ') à l'aide de la fonction push()

    s.push(' ');
    s.push('1');
    s.push('2');

Ensuite cette chaîne de caractères est convertie en Vec<&str> où chaque élément du Vec est un mot, qui est une sous chaîne de s.

    let s: Vec<&str> = s.split_ascii_whitespace().collect();

Finalement, dans

    let mut v = Vec::new();
    for i in 0..s.len() {
        v.push(
            s.get(i)
                .ok_or(String::from("Unable to index"))?
                .parse()
                .map_err(|_| format!("Unable to parse {}", s[i]))?,
        );
    }

on crée un nouveau Vec<i32> dans lequel on ajoute les mots convertis en entiers. On commence par itérer sur le Vec<&str> en utilisant les indices de 0 à s.len()-1 (la longueur du Vec s). Puis nous passons à la conversion à proprement parler, bien qu'on fasse des choses un peu compliquées

            s.get(i)
                .ok_or(String::from("Unable to index"))?
                .parse()
                .map_err(|_| format!("Unable to parse {}", s[i]))?,

Ici, on commence par récupérer le i-ème index de s à l'aide de la méthode get(i) qui retourne une Option<&str> (si i est un indice valide nous avons Some(s[i]), sinon None). Puis, nous transformons l'option avec ok_or() (nous encapsulons s[i] dans un Ok() si nous avons un Some() et transformons Noneen Err("Unable to index")). Ensuite nous "parsons" s[i] et retournons une erreur si le parsing échoue. Si tout s'est bien passé nous faisons donc un push() de chaque i32 et finissons par retourner le Vec<i32> encapsulé dans un Ok().

Les slices

Un slice est une "tranche" de tableau, statique ou dynamique: une référence vers un bout de mémoire et la longueur de cette mémoire.

Ainsi, si nous créons un tableau statique, nous pouvons référencer une "tranche" ou slice avec la syntaxe suivante

#![allow(unused)]
fn main() {
let a = [1, 2, 3, 4, 5, 6, 7, 8];
let b = &a[1..4]; // on pointe vers [2, 3, 4]
}

b sera donc une référence et saura que la mémoire sur laquelle elle pointe est de longueur 3 (cette information permet d'éviter les dépassements de capacité). On notera la syntaxe x..yy est non inclus (comme pour la boucle for avec les indices). Il existe également une syntaxe sans bornes à gauche, à droite, ou à gauche et à droite.

#![allow(unused)]
fn main() {
let a = [1, 2, 3, 4, 5, 6, 7, 8];
let b = &a[1..]; // on pointe vers [2, 3, .., 8]
let b = &a[..5]; // on pointe vers [1, 2, .., 5]
let b = &a[..]; // on pointe vers [1, 2, .., 8]
}

Cette syntaxe s'applique également pour toute collection qu'on peut indexer Le type d'un slice est noté par &[T], où T est un type. On en voit un exemple lorsqu'on veut afficher un tableau par exemple

pub fn print_tab(tab: &[i32]) {
    for t in tab {
        print!("{} ", t);
    }
    println!();
}

ou encore dans la fonction find_min()

pub fn find_min<T: Minimum>(tab: &[T]) -> SomethingOrNothing<T> {
    let mut minimum = SomethingOrNothing::Nothing;
    // Here is T is Copyable. Which means that t is not moved in the loop
    for t in tab {
        minimum = minimum.min(SomethingOrNothing::Something(*t));
    }
    minimum
}

Comme on le voit dans le main() l'implémentation à l'aide d'un slice dans les fonction permet une bien plus grande généricité que si on impose un type Vec, un tableau statique, ou un slice.

// Vec
    let tab: Vec<i32> = io::read_command_line(10usize);
    println!("Among the Somethings in the list:");
    io::print_tab(&tab);
    let min = find_min(&tab);
    min.print();
// Slice
    println!("Among the Somethings in the list:");
    io::print_tab(&tab[1..9]);
    let min = find_min(&tab[1..9]);
    min.print();
// Array
    let tab = [1, 2, 3, 4, 5, 6];
    println!("Among the Somethings in the list:");
    io::print_tab(&tab);
    let min = find_min(&tab);
    min.print();

La notation &str représente ainsi une référence vers un str qui est une chaîne de caractères littérale, allouée pour la durée entière d'un programme dans une zone dédiée de la mémoire. Le type str étant "immovable" il n'est jamais utilisé tel quel, mais uniquement via des références.

Comme pour le slice utilisé pour généraliser le passage en argument des tableaux, le slice de string &str est également utilisé pour généraliser le passage de argument de chaînes de caractères.

Rustlings

Les rustlings à faire dans ce chapitre sont les suivants:

Les Vec

$ rustlings run vecs1
$ rustlings run vecs2

Les String

$ rustlings run strings1
$ rustlings run strings2
$ rustlings run strings3
$ rustlings run strings4

Lifetimes

Concepts

Les concepts abordés dans cet exemple sont:

  1. Le pattern NewType
  2. Les lifetimes
  3. Les itérateurs et la généricité

Pour plus d'informations sur le pattern NewType, vous pouvez vous référer aux chapitres 19.2 et 19.3 du livre. Pour les lifetimes, il y a le chapitre du livre correspondant, ainsi que celui du Rustonomicon.

Discussion

Dans ce chapitre, nous allons voir principalement deux concepts différents qui sont importants en Rust et qu'on retrouve dans beaucoup de code. Les lifetimes, en particulier, sont un sujet complexe et on verra deux applications différentes mais cela constitue la pointe de l'iceberg des applications possibles. Le pattern NewType est lui bien plus simple, et ne nécessite pas une très longue discussion.

Le pattern NewType

Le pattern NewType très commun en Rust consiste à encapsuler un type externe à une crate, dans un type local comme dans

#![allow(unused)]
fn main() {
#[derive(Debug)]
pub struct SomethingOrNothing<T>(Option<T>);
}

où on encapsule le type externe Option<T> dans SomethingOrNothing<T>. Ce type a un paramètre générique T et dérive le trait Debug qui permet de faire un affichage détaillé (mais pas très joli) du contenu du type. Ce pattern est nécessaire pour implémenter un trait externe sur un type extern (ce qui est interdit en Rust). Ainsi, il ne nous aurait pas été possible d'implémenter le trait Display (qui permet d'afficher une instance du type), Default (qui permet de créer une instance par défaut), ou PartialEq (qui permet de vérifier l'égalité de deux instance du type) directement pour le type Option<T>, car Display, Default, et PartialEq sont des traits externes tout comme le type Option<T>. Pour des types externes l'implémentation de traits externes est interdite (c'est la orphan rule). Cela interdit de "casser" un code externe en autorisant de multiples implémentations du même trait pour le même type. Ainsi SomethingOrNothing<T> nous permet d'implémenter ces trois traits

impl<T: Display> std::fmt::Display for SomethingOrNothing<T> {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match &self {
            SomethingOrNothing(None) => write!(f, "Nothing.")?,
            SomethingOrNothing(Some(val)) => write!(f, "Something is: {}", val)?,
        }
        Ok(())
    }
}
impl<T> Default for SomethingOrNothing<T> {
    /// By Default a [SomethingOrNothing] is a nothing.
    fn default() -> Self {
        SomethingOrNothing(None)
    }
}
impl<T: PartialEq> PartialEq for SomethingOrNothing<T> {
    fn eq(&self, other: &Self) -> bool {
        match (&self, &other) {
            (SomethingOrNothing(None), SomethingOrNothing(None)) => true,
            (SomethingOrNothing(Some(lhs)), SomethingOrNothing(Some(rhs))) => lhs == rhs,
            _ => false,
        }
    }
}

Nous aimerions attirer votre attention sur une particularité du pattern matching ici. On voit que nous pouvons faire un match sur des types imbriqués comme pour SomethingOrNothing(Some(val)), on va déstructurer les types énumérés jusqu'à obtenir val qui est la valeur qui nous intéresse.

Une deuxième utilité du pattern NewType est qu'elle permet de limiter les fonctionnalités d'un type car cela nécessite de réimplémenter les méthodes qui lui sont propres. Dans notre cas, seule la méthode unwrap() de Option<T> nous intéresse (cette fonction retourne la valeur encapsulée dans la variante Some() ou panique si on a un None). Ainsi, on n'implémente que celle-là

    pub fn unwrap(self) -> T {
        self.0.unwrap()
    }

On peut noter qu'on a à faire à une struct avec des membres anonymes. Ainsi, les membres peuvent être accédés comme pour les tuples et comme il n'y en a qu'un dans un SomethingOrNothing<T>, on y accède avec le sélecteur self.0.

Les lifetimes

Dans cette section nous discutons l'utilisation de l'annotation des lifetimes dans différents cas: les structures, les méthodes, les traits, et les fonctions.

Les structures

Dans notre programme, nous utiliserons le type CustomInt qui est une représentation d'un entier qui peut avoir une taille arbitraire (dans les limites de la mémoire de la machine).

#[derive(Debug)]
pub struct CustomInt<'a> {
    /// The data contains the unsigned integers that are read from right to left
    /// The number 1337 is stored as vec![7, 3, 3, 1]. Each number must be in the range [0,9]
    /// and no trailing 0s are allowed.
    data: &'a Vec<u8>,
    /// Contains the sign of the number +/-1;
    sign: i8,
}

Un tel entier est représenté par un signe i8 (qui peut valoir +1 ou -1), et un Vec<u8>, un tableau dynamique de u8 (les valeurs admissibles vont de 0 à 9), qui contient les chiffres du nombre stockés de droite à gauche: [7,3,3,1] est le nombre 1337.

Ces nombres pouvant être gigantesques, nous voulons éviter de les dupliquer lorsque nous les copions ou les manipulons. Une solution est de stocker uniquement une référence vers les données, c'est-à-dire que data est de type &Vec<u8>. On voit dans le code ci-dessus, qu'il est nécessaire d'annoter la durée de vie de la référence avec 'a. Si on omet l'annotation de durée de vie le compilateur nous préviendra et nous oblige à en spécifier un

error[E0106]: missing lifetime specifier
  --> src/custom_int.rs:13:11
   |
13 |     data: &Vec<u8>,
   |           ^ expected named lifetime parameter
   |
help: consider introducing a named lifetime parameter
   |
9  ~ pub struct CustomInt<'a> {
10 |     /// The data contains the unsigned integers that are read from right to left
11 |     /// The number 1337 is stored as vec![7, 3, 3, 1]. Each number must be in the range [0,9]
12 |     /// and no trailing 0s are allowed.
13 ~     data: &'a Vec<u8>,

Il est en effet impossible pour le compilateur de savoir si la référence vivra assez longtemps pour vivre plus longtemps qu'une instance de CustomInt.

Les méthodes

L'implémentation des méthodes requiert une annotation sous peine d'erreurs

impl<'a> CustomInt<'a>

Il en va de même avec la fonction associée, try_new()

    pub fn try_new(data: &'a Vec<u8>, sign: i8) -> Result<Self, String> {
        if data.is_empty() {
            Err(String::from("Data is empty."))
        } else if sign == 1 || sign == -1 {
            Ok(CustomInt { data, sign })
        } else {
            Err(String::from("Invalid sign."))
        }
    }

qui nécessite une annotation dans la définition du type de data. En effet, l'annotation permet de dire au compilateur que data, l'argument de try_new(), vit suffisamment longtemps pour permettre la création d'une instance de CustomInt.

Les traits

Il y a plusieurs traits qui sont implémentés pour CustomInt<'a>.: PartialEq, Display, et Minumum. Pour PartialEq et Display, il suffit de renseigner l'annotation 'a à l'instruction impl comme pour un paramètre générique, voir

impl<'a> PartialEq for CustomInt<'a>
impl<'a> std::fmt::Display for CustomInt<'a>

Il est possible d'écrire la même chose, en omettant l'annotation 'a et en la remplaçant par '_ pour simplifier la notation

impl PartialEq for CustomInt<'_>

Comme l'annotation n'est utilisée nulle par, Rust offre ce sucre syntaxique pour éviter d'écrire trop d'annotations.

Pour le trait Minimum les choses se compliquent un peu. Pour éviter le copies/clones, on a fait le choix de n'utiliser que des références dans les arguments, comme dans le type de retour du calcul du minimum de deux valeurs

pub trait Minimum<'a> {
    fn min(&'a self, rhs: &'a Self) -> &'a Self;
}

Ainsi, comme il y deux références en argument et une référence en sortie, il est nécessaire d'annoter les références, car sinon le compilateur ne sait pas avec que durée de vie annoter la sortie (les 2 sont possibles). Ainsi nous annotons le trait avec la durée de vie 'a, puis cette durée de vie est utilisée pour toutes les références dans la fonction min(). Ainsi toutes les références ont la même durée de vie que celle annotée dans le trait.

Il y a trois implémentation de ce trait: la première est pour les i32, la seconde pour SomthingOrNothing<T>, et finalement pour CustomInt.

  1. Pour l'implémentation pour les i32
impl<'a> Minimum<'a> for i32 {
    fn min(&'a self, rhs: &'a Self) -> &'a Self {
        if self < rhs {
            self
        } else {
            rhs
        }
    }
}

l'implémentation est triviale, il y a uniquement besoin de reprendre l'annotation pour l'implémentation dans les références en argument de la fonction et dans le retour de la fonction. En effet, comme les deux arguments peuvent être retournés, il est nécessaire de préciser au compilateur que la durée de vie sera la même, sinon il met automatiquement une durée de vie à chaque argument et reprend celle à &self comme la durée de vie de la sortie. En d'autres termes, sans annotations, on aurait

fn min(&self, rhs: &Self) -> &Self

qui serait converti automatiquement par le compilateur en

fn min(&'a self, rhs: &'b Self) -> &'a Self {
    if self < rhs {
        self
    } else {
        rhs
    }
}

et la durée de vie 'a du retour est pas compatible avec la durée de vie qui serait retournée au moment de retourner rhs (qui est 'b). Ce qui entraînerait une erreur de compilation (on aime pas ça les erreurs nous). 2. Pour l'implémentation pour SomethingOrNothing<T>, nous avons un paramètre générique.

impl<'a, T: Minimum<'a> + PartialEq> Minimum<'a> for SomethingOrNothing<T>
{
    fn min(&'a self, rhs: &'a Self) -> &'a Self {
        match (self, rhs) {
            (SomethingOrNothing(None), SomethingOrNothing(None)) => self,
            (SomethingOrNothing(Some(l)), SomethingOrNothing(Some(r))) => {
                if *l == *l.min(r) {
                    self
                } else {
                    rhs
                }
            }
            (SomethingOrNothing(None), SomethingOrNothing(Some(_))) => rhs,
            (SomethingOrNothing(Some(_)), SomethingOrNothing(None)) => self,
        }
    }
}

Ainsi nous constatons, que la ligne correspondant à la déclaration de l'implémentation du trait Minimum nécessite la déclaration de la durée de vie 'a, ainsi que du type générique T. On voit dans l'implémentation de la fonction min(), que nous faisons appel à min() sur le type T, et que donc celui-ci doit implémenter le trait Minimum (tout comme le trait PartialEq). On doit ainsi répercuter la durée de vie sur tous les Minimum présents sur la ligne impl

impl<'a, T: Minimum<'a> + PartialEq> Minimum<'a> for SomethingOrNothing<T>

Nous ne discutons pas l'implémentation à proprement parler qui est assez raisonnable pour trouver le minimum de deux valeur encapsulées dans un NewType. 3. Finalement, on a l'implémentation pour CustomInt qui n'a rien de vraiment nouveau par rapport aux implémentation précédentes (on réutilise l'annotation 'a dans min() directement), à part la complexité monumentale de la fonction (elle fait plein de lignes)

    fn min(&'a self, rhs: &'a Self) -> &'a Self {
        match self.sign.cmp(&rhs.sign) {
            Ordering::Less => return self,
            Ordering::Greater => return rhs,
            Ordering::Equal => match self.data.len().cmp(&rhs.data.len()) {
                Ordering::Less => {
                    if self.sign == 1 {
                        return self;
                    } else {
                        return rhs;
                    }
                }
                Ordering::Greater => {
                    if self.sign == 1 {
                        return rhs;
                    } else {
                        return self;
                    }
                }
                Ordering::Equal => {
                    for (l, r) in self.data.iter().rev().zip(rhs.data.iter().rev()) {
                        let ls = (*l as i8) * self.sign;
                        let rs = (*r as i8) * self.sign;
                        match ls.cmp(&rs) {
                            Ordering::Less => return self,
                            Ordering::Greater => return rhs,
                            Ordering::Equal => {}
                        }
                    }
                }
            },
        }
        self
    }

En effet, on doit faire attention au signe, à la longueur de notre CustomInt et à plusieurs autres joyeusetés. Ici, on peut utiliser le trait Ord (la fonction cmp()) pour faire les comparaisons entre le signe et les digits de nos nombres. Le trait Ord représente les opérateurs <, >, =, <=, >=, via la fonction cmp() qui retourne trois types correspondants

Ordering::Less
Ordering::Greater
Ordering::Equal

L'utilisation d'un type énuméré pour gérer chacun des cas peut sembler verbeux et complexe. Cependant, il permet de garantir à la compilation qu'on a pas oublié de traiter un cas par accident. Et ça, ça n'a pas de prix.

Les fonctions

Finalement, on utilise les lifetimes dans une fonction qui permet le calcul du minimum dans un tableau et retourne un SomethingOrNothing<&T> contenant une référence vers l'élément le plus petit.

Cette fonction est générique avec le paramètre T et prend en argument une référence qui doivent être annotée.

pub fn find_min<'a, T: Minimum<'a>>(tab: &'a [T]) -> SomethingOrNothing<&'a T> {
    // A very elegant fold applied on an iterator
    tab.iter().fold(SomethingOrNothing::default(), |res, x| {
        let r = match res {
            SomethingOrNothing(None) => x,
            SomethingOrNothing(Some(r)) => r.min(x),
        };
        SomethingOrNothing::new(r)
    })
}

La fonction find_min() prend en argument un slice de type générique T qui doit implémenter Minimum. Comme le type de retour est SomethingOrNothing<&T>, donc on encapsule une référence vers la valeur minimale, il est nécessaire d'annoter les durées de vies car sinon elles auraient deux valeur différentes ce qui poserait problème au compilateur (car elles doivent être les mêmes).

L'implémentation de cette fonction, n'est pas très complexe, mais est très intéressante. En premier lieu, pour des question de généricité de l'implémentation nous passons en argument un slice de T: cette façon de procéder permet d'avoir un argument qui serait une référence vers un tableau statique ou vers un Vec<T> sans changer l'implémentation. De plus, nous utilisons ici un itérateur sur le tableau est faisons un fold() sur cet itérateur. Le fold() prend en argument un élément neutre (quel est la valeur initiale stockée dans le fold()). Ici c'est

SomethingOrNothing::default()

puis une fonction anonyme prenant deux arguments

|res, x| {}

où la valeur retournée par cette fonction écrase la valeur de res à chaque next() de l'itérateur et qui doit avoir le même type que l'élément neutre, et où x est la valeur courante de l'itérateur. Ici, le type de res est SomethingOrNothing<&T> et le type de x est &T. La fonction anonyme

let r = match res {
    SomethingOrNothing(None) => x,
    SomethingOrNothing(Some(r)) => r.min(x),
};
SomethingOrNothing::new(r)

calcule le minimum entre la valeur actuelle stockée dans res et x en utilisant la fonction min() ce qui implique que T doit implémenter Minimum.

En pratique

Dans la fonction main() de notre programme

pub fn find_min<'a, T: Minimum<'a>>(tab: &'a [T]) -> SomethingOrNothing<&'a T> {
    // A very elegant fold applied on an iterator
    tab.iter().fold(SomethingOrNothing::default(), |res, x| {
        let r = match res {
            SomethingOrNothing(None) => x,
            SomethingOrNothing(Some(r)) => r.min(x),
        };
        SomethingOrNothing::new(r)
    })
}

on crée un tableau de CustomInt qui sont créés à partir de références sur les tableau v1, v2, etc. qui vivrons ainsi jusqu'à la fin de notre programme et qui seront promenées sans qu'on ait besoin de les copier à aucun moment. Les liens entre les durées de vie des références que nous nous sommes efforcés d'annoter dan tout au long ce code sont vérifiées par le compilateur qui vérifie qu'elles sont toutes valides à la compilation.

Interface en ligne de commande et entrées / sorties

Concepts

Les concepts abordés dans cet exemple sont:

Discussion

L'écosystème de Rust contient énormément de librairies ergonomiques et efficaces. Dans ce chapitre, nous parlerons de clap, une librairie pour construire des interfaces en ligne de commande. Nous en profiterons pour discuter également les entrées / sorties et en particulier comment écrire dans des fichiers. Finalement, nous verrons également avoir une gestion d'erreur un peu plus ergonomique à l'aide de l'opérateur ? et de la fonction map_err().

Vous pouvez trouver plus d'informations aux liens suivants:

L'interface à la ligne de commande et l'utilisation de librairies externes

Dans cette section nous allons voir une façon différente de lire la ligne de commande (par rapport à ce que nous avons fait dans la partie 07).

Cette façon de faire est trop complexe pour construire une vraie application et rajouterait beaucoup d'efforts à chaque fois qu'on veut en reconstruire une: elle demanderait un parsing long et fastidieux de la ligne de commande manuel. La librairie clap, la librairie de CLI la plus populaire pour Rust, nous permet de de construire une interface pour un programme avec arguments nommés, optionnel, et un menu d'aide de façon élégante et une bonne gestion des erreurs.

Afin d'utiliser une librairie externe, il faut l'ajouter comme dépendance dans le fichier Cargo.toml de notre projet. Pour ce faire, il y a deux méthodes, et nous allons voir comment cela fonctionne pour clap:

  1. Ajouter la ligne
clap = { version = "4.4.0", features = ["derive"] }

sous l'entrée [dependecies].

  1. Utiliser l'outil cargo qui le fait pour nous
cargo add clap --features derive

Il y a énormément de fonctionnalités dans l'outil cargo.

Ces deux façons sont équivalentes. Lors de l'ajout manuel, on doit choisir la version manuellement qu'on veut mettre dans le fichier Cargo.toml (cela permet de figer une version) ou on peut remplacer 4.4.0 par * pour avoir toujours la dernière version de la crate à utiliser. Cependant cette façon de faire n'est pas recommandée, car cela peut "casser" la compilation lors d'une mise à jour majeure (ou avoir des effets de sécurité indésirables).

On note également, qu'on a un champs features qui est optionnel, mais qui ici est mis à derive. Le langage Rust permet d'omettre une partie des fonctionnalités d'une librairies qui sont ajoutées à l'aide d'annotations lorsque la feature est activée. Nous n'entrerons pas dans les détails de ces annotations, mais avons besoin de la feature derive pour compiler notre code.

Nous pouvons à présent commencer à écrire nos fonctions pour lire la ligne de commande à l'aide de la librairie clap. Nous allons voir deux façons différentes de créer une telle interface avec la librairie: les pattern builder et derive (c'est pour ce dernier que nous avons besoin de la feature derive).

Le but de cette interface à la ligne de commande est pour l'utilisateur·trice de pouvoir choisir les options suivantes pour notre petit programme de calcul de minimum dans une liste.

  1. Entrer à la main une liste de nombres.
  2. Créer une liste de count nombres aléatoires qui seront lus depuis /dev/urandom.
  3. Écrire la liste de nombres et le minimum de la liste dans un fichier de sortie (en en fournissant le nom) ou sur l'écran.

Il faut noter que l'option 1 et 2 son mutuellement exclusives. L'option 3 écrira dans un fichier uniquement si un nom de fichier est fourni par l'utilisateur·trice.

Il est fondamental que si les entrées sont mal formatées (on ne donne pas des nombres p.ex.) ou si on essaie d'utiliser les options 1 et 2 en même temps, on ait un bon traitement de l'erreur et un message d'erreur lisible.

Des tentatives d'exécution typiques seraient

$ cargo run -- --numbers 1 2 3 4 5 6

où on donne une liste de nombres après l'option --numbers ou encore

$ cargo run -- --output fichier.txt --numbers 1 2 3 4 5 6

--output permet de spécifier un nom de fichier. En revanche, on doit avoir une erreur si on essaie de faire

$ cargo run -- --count 10 --numbers 1 2 3 4 5 6

car on ne veut pas pouvoir générer deux listes de nombres, mais une seule.

Ainsi on a trois arguments possibles et tous sont optionnels, mais deux sont exclusifs.

Le builder pattern

Nous allons voir à présent comment construire une interface en ligne de commande à proprement parler avec clap. Pour ce faire et comprendre le fonctionnement interne de la librairie nous allons d'abord étudier le builder pattern, qui consiste à construire l'interface à l'aide de fonctions qui vont construire petit à petit notre application.

La fonction qui va faire tourner notre application se trouve dans src/io.rs et a la signature suivante

pub fn read_command_line_builder() -> Result<(), String>

On remarque qu'elle ne prend aucun argument en paramètre et qu'elle retourne un Result<(), String>. En d'autres termes, si tout s'est bien passé, nous ne retournons "rien". Dans l'éventualité où quelque chose ne s'est pas passé comme prévu, nous retournons une chaîne de caractères qui contiendra un message d'erreur.

Le création de la "commande" se trouve dans le code

    let matches = 
        Command::new(COMMAND)
            .author(AUTHOR)
            .version(VERSION)
            .arg(
                Arg::new("numbers") // id
                    .short('n')         // version courte -n
                    .long("numbers")    // ou longue --numbers
                    .help("A list of i32 numbers") // l'aide
                    .num_args(1..) // combien il y a d'entrées
                    .allow_negative_numbers(true) // on peut avoir des négatifs
                    .value_parser(value_parser!(i32)) // on veut s'assurer que ça soit des nombres
                    .required(false), // optionnel
            )
            .arg(
                Arg::new("count")
                    .short('c')
                    .long("count")
                    .help("How many random numbers we want?")
                    .value_parser(value_parser!(usize))
                    .conflicts_with("numbers") // impossible d'avoir -c et -n
                    .required(false),
            )
            .arg(
                Arg::new("output")
                    .short('o')
                    .long("output")
                    .help("Should we write output in a file?")
                    .required(false),
            )
            .get_matches();

Ici nous effectuons diverses opérations. Nous commençons par créer une nouvelle commande dont le nom est cli1

const COMMAND: &str = "cli";
const AUTHOR: &str = "Orestis Malaspinas";
const VERSION: &str = "0.1.0";
        Command::new(COMMAND)
            .author(AUTHOR)
            .version(VERSION)

avec différents composants optionnels, comme le nom de l'auteur de l'application, sa version, etc. Cela permet maintenant d'ajouter les arguments sur cette application.

Comme discuté plus haut nous voulons trois arguments (numbers, output, et count) qui sont ajouté dans un ordre qui n'a aucune importance.

        Command::new(COMMAND)
            .author(AUTHOR)
            .version(VERSION)

L'appel à la méthode .arg() nous permet d'ajouter un nouvel argument, créé avec l'appel à

Arg::new(id)

id est une chaîne de caractères qui permet d'identifier de façon unique l'argument. Puis viennent toutes les propriétés de notre argument:

  • short('n'): l'option peut être nommée -n,
  • long("numbers"): l'option peut être nommée --numbers, permettant d'appeler le programme avec
$ cargo run -- --numbers 1 2 3 4 5 6 
$ cargo run -- -n 1 2 3 4 5 6 
  • help("A list of i32 numbers"): le message d'aide si nous appelons
⋊> ~/g/p/r/c/r/cli on 25-cli-i-o ⨯ cargo run -- --help    
Finished dev [unoptimized + debuginfo] target(s) in 0.01s
     Running `target/debug/cli --help`
Usage: cli [OPTIONS]

Options:
  -n, --numbers <numbers>...  A list of i32 numbers
  -c, --count <count>         How many random numbers we want?
  -o, --output <output>       Should we write output in a file?
  -h, --help                  Print help
  -V, --version               Print version
  • num_args(1..): qui permet d'avoir plusieurs valeurs dans l'argument et savons qu'il doit y en avoir plus d'un. Sans cet argument, l'appel
$ cargo run -- --numbers 1 2 3 4 5 6

considérerait le 2 comme la valeur d'un autre argument et ne ferait pas partie de numbers. Puis viennent encore

  • allow_negative_numbers(true): pour autoriser les nombres négatifs (sinon - est parsé comme nouvel argument)
  • value_parser(value_parser!(i32)): on ne veut que des i32 (les nombres à virgules, les lettres, etc sont automatiquement rejetées et un message d'erreur est affiché)
  • required(false): est-ce que l'argument est obligatoire (ici ce n'est pas le cas)? En d'autres termes est-ce l'exécution suivante est valide?
$ cargo run -- # sans option numbers

Si l'argument est required(true) alors il est nécessaire de spécifier l'option sinon on aura un message d'erreur.

Dans la suite du code on crée encore deux arguments count et output. Nous avons déjà couvert les différentes fonctions appelée, à l'exception d'une:

  • conflicts_with("numbers"): ici nous spécifions que l'argument count ne peut pas être présent en même temps que l'argument numbers (peu importe l'ordre d'appel). Ainsi si nous essayons d'exécuter
$ cargo run -- --numbers 1 2 3 4 5 6 -c 6

nous aurons le message d'erreur

error: the argument '--numbers <numbers>...' cannot be used with '--count <count>'

Usage: cli --numbers <numbers>...

For more information, try '--help'

Après avoir construit les arguments, nous devons appeler la fonction get_matches() qui termine la construction de la commande et vérifie s'il n'y a pas d'arguments qui sont contradictoires (un message d'erreur sera produit à l'exécution si cela est le cas).

Lorsque ce code est exécuté, notre programme peut maintenant parser la ligne de commande lorsqu'il est exécuté. A nous maintenant d'utiliser correctement les différents arguments. Ici, nous devons traiter deux "groupes" d'arguments:

  • numbers et count qui sont deux options exclusives,
  • output qui est optionnel également.

Le traitement de

    let numbers = 
        if let Some(count) = 
            matches.get_one::<usize>("count") 
        {
            read_from_urandom(*count)?
        } else if let Some(numbers) = 
            matches.get_many::<i32>("numbers") 
        {
            numbers.copied().collect()
        } else {
            Vec::new()
        };

crée la liste de nombre que nous voulons avoir pour calculer le minimum.

Comme les deux arguments sont optionnels, nous voyons que pour les déstructurer, il faut passer par une construction if let Some() = .... Dans le cas de l'argument count nous savons que nous voulons un usize dont l'identifiant est "count". Si l'argument est présent,

            matches.get_one::<usize>("count") 

retourne Some(&usize) (nous obtenons une référence vers l'argument) et nous appellerons la fonction read_from_urandom() (que nous discuterons plus bas). Sinon, nous devons vérifier si l'argument numbers est présents et quelles valeurs lui sont assignées. Ainsi, si l'argument est présent

            matches.get_many::<i32>("numbers") 

retournera un nombre arbitraire de références d'entiers, qui seront ensuite transformés en Vec<i32> à l'aide de la ligne

numbers.copied().collect()

qui commence par faire une copie des valeurs de la liste de références pour pouvoir en devenir les propriétaires, puis les met dans un Vec<i32> (il faut noter que le type de numbers est inféré grâce au reste du code). A la fin de ce if let Some() = ... nous retournons un Vec<i32> (qui peut être vide) qui sera utilisé dans la suite de la fonction.

Il nous reste à décider si nous allons écrire les sorties de notre programme (la liste de nombre et son minimum) dans un fichier ou dans la sortie standard à l'aide du code

    if let Some(output) = 
        matches.get_one::<String>("output") 
    {
        write_to_file(output, &numbers)?;
    } else {
        println!("Among the Somethings in the list:");
        print_tab(&numbers);
        println!("{}", find_min(&numbers).to_string());
    }

Rien de très nouveau ici, si "output" est présent

        matches.get_one::<String>("output") 

nous pouvons déstructurer le retour de la fonction et obtenir le nom du fichier dans lequel nous allons écrire dans la fonction write_to_file() ou le cas échant écrire dans la sortie standard .

Gestion d'erreur un peu simplifiée

Aussi bien les appels à read_from_urandom() que write_to_file() sont suivis d'un ?. Ces fonctions doivent manipuler des fichiers et peuvent donc échouer à n'importe quel moment (si le fichier n'existe pas, s'il ne peut être créé, etc). Elles retournent donc des Result. L'opérateur ? en Rust est très pratique. Il est utilisé pour répercuter les erreurs de façon courte dans les fonctions. En gros il répond à la question: "Est-ce que la fonction a retourné Ok() ou Err()?" Si la réponse est Ok() il retourne ce qui est contenu dans le Ok() qui peut être assigné à une variable (ou retourné de la fonction). En revanche si la réponse est Err(), on retourne l'erreur de la fonction courante. Cet opérateur permet d'éviter d'alourdi le code avec du pattern matching à chaque appel qui peut échouer et est très utilisé dans le code Rust.

Le derive pattern

Nous avons vu en grand détail comment construire une commande avec un builder design. Nous allons voir à présent de façon très succincte comment faire la même chose avec le pattern derive. Ici, tout le code écrit plus haut sera généré pour nous à l'aide de macro Rust, et nous avons uniquement besoin de spécifier ce qui doit être généré. Afin de créer une interface en ligne de commande nous devons uniquement créer une struct annotée

#[derive(Parser)]
#[command(author, version, about, long_about = None)]
struct CliMin {
    #[arg(short, long, help = "A list of i32 numbers", num_args=1.., allow_negative_numbers=true, value_parser = clap::value_parser!(i32))]
    numbers: Option<Vec<i32>>,
    #[arg(short, long, help = "How many random numbers we want?", value_parser = clap::value_parser!(usize), conflicts_with = "numbers")]
    count: Option<usize>,
    #[arg(short, long, help = "Filename for writing the numbers.")]
    output: Option<String>,
}

qui contient différent champs qui sont annotés (ou pas).

La ligne

#[derive(Parser)]

va dire au compilateur de Rust de générer automatiquement tout le parsing d'arguments en fonction de ce qu'il va trouver dans la structure en dessous, ici CliMin. Puis vient la commande préprocesseur

#[command(author, version, about, long_about = None)]

qui va créer la nouvelle commande à proprement parler. Elle correspond à

        Command::new(COMMAND)
            .author(AUTHOR)
            .version(VERSION)

A la différence de cette ligne où on spécifie explicitement l'auteur, etc., ici, le contenu du champs author, version, etc. est directement récupéré du Cargo.toml. Ensuite la structure CliMin possède trois membres: numbers, count, et output qui sont annotés avec un #[arg()].

    #[arg(short, long, help = "A list of i32 numbers", num_args=1.., allow_negative_numbers=true, value_parser = clap::value_parser!(i32))]
    numbers: Option<Vec<i32>>,

Cette annotation va dire au compilateur de générer automatiquement le code pour toutes les paires clés valeurs se trouvant entre les parenthèses. Si une valeur est absente alors un comportement par défaut est appliqué. Ainsi, long va générer automatiquement que l'option en version longue pour la variable numbers est --numbers. De façon similaire, par défaut la version short de numbers sera -n (la première lettre de numbers). Cette façon de faire par défaut permet de réduire la quantité de code. Par contre, elle est également dangereuse, car si deux champs commencent par le même nom, seul le premier aura le short qui lui correspondra.

Le reste des arguments correspondent à toutes les méthodes de la version builder vues dans le chapitre précédent. Il y a deux grandes différences:

  1. On ne trouve pas d'équivalent à required(false). En fait, l'obligation ou non de spécifier un argument est directement inféré par le type de l'argument: si c'est une Option alors l'argument est... optionnel.
  2. numbers est directement parsé en Vec<i32>, pas besoin de faire des conversions.

Le reste du code est relativement trivial. Pour utiliser notre interface en ligne de commande

pub fn read_command_line_derive() -> Result<(), String> {
    let cli = CliMin::parse();
    let numbers = if let Some(count) = cli.count {
        read_from_urandom(count)?
    } else if let Some(numbers) = cli.numbers {
        numbers
    } else {
        Vec::new()
    };
    if let Some(output) = cli.output {
        write_to_file(&output, &numbers)?;
    } else {
        println!("Among the Somethings in the list:");
        print_tab(&numbers);
        println!("{}", find_min(&numbers).to_string());
    }
    Ok(())
}

Il faut appeler la fonction parse()

    let cli = CliMin::parse();

et les champs cli.cout, cli.numbers, et cli.output seront automatiquement assignés aux valeurs dans la cli si elles sont compatibles avec les formats spécifiés dans les arguments correspondants. Sinon des erreurs seront affichées.

Les entrées / sorties

Il y a deux fonctions qui gèrent la lecture / écriture de fichiers dans notre programme.

  1. La fonction read_from_urandom() permet de lire le fichier /dev/urandom qui contient dans les systèmes unix des "vrais" nombres aléatoires générés par le bruit du système.
  2. La fonction write_to_file() qui permet d'écrire la liste de nombre, ainsi que le minimum dans un fichier. Nous allons brièvement discuter ces fonctions, afin de comprendre un peu mieux comment faire des entrées / sorties depuis le disque en Rust.

Lecture de fichier

Pour la lecture de fichier, nous nous concentrons sur la fonction

fn read_from_urandom(count: usize) -> Result<Vec<i32>, String> {
    let file = File::open("/dev/urandom").map_err(|_| "Could not open /dev/urandom")?;
    let mut buf_reader = BufReader::new(file);
    let mut numbers = vec![0; count * 4];
    buf_reader
        .read_exact(&mut numbers)
        .map_err(|_| "Could not read numbers")?;
    Ok(numbers
        .chunks(4)
        .map(|i| i32::from_be_bytes(i.try_into().unwrap()))
        .collect::<Vec<_>>())
}

L'ouverture d'un fichier en lecture se fait avec la fonction File::open() qui peut échouer si le fichier n'existe pas par exemple. Dans le cas d'une erreur, nous nous empressons de convertir l'erreur dans un message d'erreur avec la fonction map_err() qui prend en argument une fonction anonyme qui a pour argument ce qui est encapsulé dans le type Err() et qui retourne une nouvelle valeur qui sera encapsulée dans une nouvelle Err(). Cette façon de faire n'est pas très idiomatique pour Rust, mais elle nous satisfait pour le moment, afin d'avoir des types de retour homogènes et de pouvoir utiliser l'opérateur ? (voir plus haut).

Comme nous lisons dans le fichier /dev/urandom qui est un flux continu d'octets, nous définissons une mémoire tampon sur le fichier et allons lire exactement 4 * count fois octets, soit exactement l'équivalent de count entiers 32-bits soit i32

    let mut buf_reader = BufReader::new(file);
    let mut numbers = vec![0; count * 4];
    buf_reader
        .read_exact(&mut numbers)
        .map_err(|_| "Could not read numbers")?;

Finalement, notre mémoire tableau numbers (qui est rien d'autre qu'une suite d'octets) est convertie en Vec<i32> grâce à la puissance des itérateurs.

    Ok(numbers
        .chunks(4)
        .map(|i| i32::from_be_bytes(i.try_into().unwrap()))
        .collect::<Vec<_>>())

Dans un premier temps le tableau est découpé en tranches de 4 éléments grâce à la méthode .chunks(4) (l'itérateur est maintenant une suite d'itérateurs de 4 éléments). Puis chacun des éléments de l'itérateur (soit 4 octets) est transformé en i32, grâce à la méthode map(|i| i32::from_be_bytes(i.try_into().unwrap())). Il faut noter qu'ici nous utilisons la fonction try_into() qui peut échouer si nous n'avons pas 4 octets à disposition quand nous faisons la conversion. Ici, par construction cela ne peut pas se produire et pouvons unwrap() le résultat. Finalement, à l'aide de collect(), nous créons un Vec<i32> à partir de l'itérateur obtenu et l'encapsulons dans un Ok(), car le résultat de notre fonction est un succès, si tout s'est bien passé.

Écriture dans un fichier

Pour l'écriture dans un fichier, nous nous concentrons sur la fonction

fn write_to_file(output: &str, numbers: &[i32]) -> Result<(), String> {
    let mut file = File::create(output).map_err(|_| format!("Failed to create {output}"))?;
    writeln!(file, "Among the Somethings in the list:")
        .map_err(|_| "Failed to write header into file.")?;
    for n in numbers {
        write!(file, "{n} ").map_err(|_| format!("Failed to write {n} into file."))?;
    }
    writeln!(file,).map_err(|_| "Failed to write carriage return into file.")?;
    writeln!(file, "{}", find_min(numbers).to_string())
        .map_err(|_| "Failed to write minimum value into file.")?;
    Ok(())
}

Nous commençons par créer un fichier à l'aide de la fonction File::create() qui prend en argument le chemin où créer le fichier.

    let mut file = File::create(output).map_err(|_| format!("Failed to create {output}"))?;

Si un fichier existe déjà il est écrasé par défaut. Si la création est impossible, une erreur est retournée. De plus le fichier est ouvert en mode écriture. Il faut noter que nous allons modifier le fichier, et il est donc mutable. Ainsi, nous pouvons écrire dans le fichier à l'aide des macros write! et writeln! qui s'utilisent comme print! et println! à l'exception qu'elles prennent des fichier en argument et retournent une erreur en cas d'échec.

    writeln!(file, "Among the Somethings in the list:")
        .map_err(|_| "Failed to write header into file.")?;
    for n in numbers {
        write!(file, "{n} ").map_err(|_| format!("Failed to write {n} into file."))?;
    }
    writeln!(file,).map_err(|_| "Failed to write carriage return into file.")?;
    writeln!(file, "{}", find_min(numbers).to_string())
        .map_err(|_| "Failed to write minimum value into file.")?;

A nouveau, toutes les erreurs sont transformées en messages (des chaînes de caractères) pour simplifier les concepts abordés dans ce code.

1

Cet identifiant permet d'identifier de façon unique la commande dans le cas où nous en créerions plusieurs dans la même application ce qui n'est pas le cas ici.

Unsafe Rust

Concepts

Les concepts abordés dans cet exemple sont:

  1. La liste chaînée sûre
  2. Le Rust unsafe

Une partie des exemples sont très fortement inspirés ou même tirés de l'excellent livre Learn Rust With Entirely Too Many Linked Lists. Je vous recommande d'ailleurs le blog de l'auteure Aria Beingessner aka Gankra.

Discussion

Le Rust est un langage avec des contraintes de sécurité mémoire très fortes et un compilateur pointilleux. Il possède donc des règles très strictes qui ne sont pas toujours applicables pour avoir un code sûr et ergonomique. Ses créateurs·trices ont donc pensé à laisser la possibilité de relaxer les contraintes dans un environnement particulier: le unsafe Rust.

Pour plus d'informations et une description plus complète, vous pouvez vous référer:

Pour illustrer les concepts unsafe, nous allons discuter de l'une des structures de données les plus "simples" de l'informatique, mais qui est très difficile à implémenter en Rust: la liste simplement chaînée. Une discussion très détaillée de l'implémentation de la liste chaînée se trouve sur l'excellent Learn Rust With Entirely Too Many Linked Lists. Rappelez vous également que la liste simplement chaînée est une structure de donnée permettant de représenter une pile (structure de donnée abstraite bien connue).

Pour commencer, nous allons voir son implémentation sûre, et les efforts qu'il faut consentir pour faire l'implémentation, puis une implémentation unsafe ou pas sûre.

La liste chaînée

Pour simplifier, nous allons nous limiter à l'implémentations de 6 fonctionnalité dans notre liste chaînée:

  1. La fonction new() qui crée une nouvelle liste.
  2. La fonction is_empty() qui nous dit si la liste est vide.
  3. La fonction push() qui ajoute un élément en tête de liste.
  4. La fonction pop() qui retire l'élément de tête de la liste et retourne la valeur stockée.
  5. La fonction print() qui affiche tous les éléments de la liste.

Avant de voir l'implémentation de ces fonctions, nous devons définir la structure de données utilisée pour la liste chaînée. Pour rappel, une liste chaînée est une suite de nœuds ou éléments qui sont reliés entre eux par des pointeurs:

val1 --> val2 --> val3 --> fin

Ainsi chaque élément contient les données et un pointeur vers l'élément suivant. En Rust, on serait donc tenté de faire

#![allow(unused)]
fn main() {
struct Element {
    data: i32,
    next: Element,
}
}

Comme pour le C cette construction ne peut pas fonctionner, car nous avons à faire à un type récursif et dont on ne peut connaître la taille à la compilation.

error[E0072]: recursive type `Element` has infinite size
 --> src/main.rs:3:1
  |
3 | struct Element {
  | ^^^^^^^^^^^^^^
4 |     data: i32,
5 |     next: Element,
  |           ------- recursive without indirection
  |
help: insert some indirection (e.g., a `Box`, `Rc`, or `&`) to break the cycle
  |
5 |     next: Box<Element>,
  |           ++++       +

For more information about this error, try `rustc --explain E0072`.
error: could not compile `playground` (bin "playground") due to previous error

Ainsi, comme nous le recommande le compilateur, il faut utiliser un Box (voir le chapitre 10) pour faire une allocation sur le tas de next.

#![allow(unused)]
fn main() {
struct Element {
    data: i32,
    next: Box<Element>,
}
}

Nous ne sommes pas encore sortis d'affaire, car notre type élément ne permet pas de représenter la fin de la chaîne. Il faudrait que next soit un élément suivant soit pas d'élément suivant (indiquant ainsi la fin de la chaîne). Mais on connaît un tel type non? Le type Option<T> (voir le chapitre 7) fait exactement ce que nous voulons: la variante Some(element) indique la présence d'un élément suivant, alors que la variante None indique son absence.

#![allow(unused)]
fn main() {
struct Element {
    data: i32,
    next: Option<Box<Element>>,
}
}

Il ne nous reste plus qu'à créer la structure de liste chaînée, qui va juste avoir la forme suivante:

pub struct LinkedList {
    head: Option<Box<Element>>,
}

Et juste posséder la tête de la liste chaînée.

La philosophie générale de l'implémentation purement safe de la liste chaînée est qu'on ne va jamais emprunter les instances de la liste chaînée, mais toujours en prendre la propriété. Cela aura des effets non négligeables sur l'ergonomie du code (qui n'est pas forcément nécessaire), mais que l'on accepte à des fins éducatives.

La fonction associée new()

La fonction new() est triviale à écrire

    pub fn new() -> Self {
        Self { head: None }
    }

Il faut juste noter que new() retourne une nouvelle instance de LinkedList dont la tête est None (il n'y a aucun élément dans la liste).

La méthode is_empty()

La méthode is_empty() est un poil moins triviale à écrire. Pour déterminer si la liste est vide, il faut juste vérifier que que l'instance de la liste chaînée à head soit égale à None. On peut faire cela avec du pattern matching

match self.head {
    None => true,
    _ => false,
}

Mais cela ne suffit pas complètement, car il faut également retourner self (l'instance de la liste chaînée sinon elle serait détruite à la fin de is_empty()). Ainsi on retourne non seulement le booléen qui nous dit si la liste est vide ou non, mais également la list (self).

En fait, on peut faire beaucoup plus court et pratique en utilisant la méthode is_none() de la librairie standard de Rust. Mais cela reviendrait à emprunter self ce qui contreviendrait au principe qu'on s'est fixé pour cette implémentation un peu étrange.

    pub fn is_empty(self) -> (bool, Self) {
        match self.head {
            None => (true, self),
            _ => (false, self),
        }
    }

La méthode push()

La fonction push(value) doit créer un élément à placer en tête de liste qui aura comme next l'ancienne tête de liste. Il y a différentes façons de l'implémenter. Ici, nous allons prendre possession de l'instance de la liste, et retourner une nouvelle liste avec le nouvel élément ajouté

    pub fn push(self, data: i32) -> Self {
        let elem = Box::new(Element::new(data, self.head));
        Self { head: Some(elem) }
    }

Dans cette implémentation on voit que self est "move" dans la fonction push (on prend en argument self et non &self ou &mut self). Puis ont crée un nouvel élément contenant data et la tête actuelle de la liste. Le nouvel élément elem a donc la propriété de la mémoire de self.head.

La fonction pop()

La fonction pop()

    pub fn pop(self) -> (Option<i32>, Self) {
        if let Some(elem) = self.head {
            (Some(elem.data), Self { head: elem.next })
        } else {
            (None, Self { head: None })
        }
    }

retourne une Option<i32> avec éventuellement une valeur si la liste n'est pas vide, et retourne une nouvelle liste où la tête est soit l'élément suivant soit une liste vide (si on est à la fin de la liste).

Il faut noter la syntaxe if let Some(elem) = self.head ... else qui permet de se passer du pattern matching et raccourcir sensiblement le code.

La fonction print()

Finalement, la fonction print() est probablement la plus étrange de toutes.

    pub fn print(self) -> Self {
        let mut new_list = Self::new();
        let mut current = self.head;
        while let Some(tmp) = current {
            print!("{} --> ", tmp.data);
            new_list = new_list.push(tmp.data);
            current = tmp.next;
        }
        println!("∅");
        new_list
    }

En effet, on aimerait ne pas avoir à détruire notre liste en entier lorsque nous la parcourons. Ainsi, la fonction print() prend en argument self et retourne Self. Cette fonction va créer une nouvelle instance de LinkedList mutable, puis parcourir tous les éléments de la liste self à l'aide de la boucle

        let mut current = self.head;
        while let Some(tmp) = current {
            print!("{} --> ", tmp.data);
            new_list = new_list.push(tmp.data);
            current = tmp.next;
        }
        println!("∅");

où on va parcourir la liste en "consommant" les éléments: lors de l'assignation de current à tmp.next, l'ancienne valeur de current sort de la portée et est détruite automatiquement. Ainsi, il est nécessaire de push() la valeur tmp.data sur la liste nouvellement créée précédemment new_list. Dans la boucle, nous affichons également chaque valeur avec un formatage astucieux. Finalement, nous retournons la nouvelle liste.

Les défauts de cette implémentation

On constate tout d'abord que cette implémentation n'est pas très ergonomique. En effet, à chaque utilisation de la liste, il faut toujours faire un assignation à une nouvelle liste ce qui est encore acceptable pour push(). En revanche pour pop() ou print() on voit clairement que c'est pas pratique du tout.

Un autre problème plus complexe apparaît et concerne la libération de la mémoire de notre liste chaînée. Il s'avère que la libération automatique est impossible à faire en garantissant qu'on ne va pas faire exploser la pile. Comme expliqué sur ce lien la libération de la mémoire se fait de façon récursive. Ainsi, on n'a aucune garantie que la pile d'appel de la fonction de libération de la mémoire (drop()) par le compilateur ne va pas dépasser sa capacité.

fn main() {
    // Exemple de stack overflow
    let mut immutable_list = ImmutableList::new();
    for i in 0..1_000_000 {
        immutable_list = immutable_list.push(i);
    }
}

Si vous compilez et exécutez ce programme, vous constaterez probablement une erreur de type stack overflow.

Vous me direz qu'il suffit d'implémenter le trait Drop comme dans "Too many linked lists..." mais... cela ne fonctionne pas non plus pour des raisons trop complexes à expliquer ici. La seule solution est d'implémenter une fonction vidant la liste et de l'appeler à la main et perd un peu la raison d'être du Rust.

    pub fn clear(self) {
        let mut current = self.head;
        while let Some(tmp) = current {
            current = tmp.next;
        }
    }
fn main() {
    // Exemple de stack overflow
    let mut immutable_list = ImmutableList::new();
    for i in 0..1_000_000 {
        immutable_list = immutable_list.push(i);
    }
    immutable_list.clear();
}

Le Rust unsafe

Tous les efforts du chapitre précédent peuvent être largement évités avec un tout petit peu de Rust unsafe. Le Rust un peu moins sûr nous permet de relaxer certaines contraintes du compilateur et d'écrire un code plus joli tout en annotant les régions problématiques. Nous créons ici deux codes différents:

  • Un code safe qui est inspiré de Learn Rust With Entirely Too Many Linked Lists mais qui on le verra n'est pas complètement safe quand on gratte un peu.
  • Un code unsafe qui ressemble à la liste simplement chaînée que nous écririons en C.

Les deux contraintes qui nous intéressent ici, et qui sont relaxées sont:

  1. Le déréférencement d'un "pointeur cru" (raw pointer).
  2. L'appel à une fonction unsafe.

Ainsi tout code qui fait l'une ou l'autre de ces opérations, doit être annoté unsafe suivit par un bloc:

unsafe {
    // les opérations pas sûres ici
}

Comme tout bloc, il peut ainsi retourner une valeur.

Il a d'autres contraintes qui sont relaxées, mais nous n'en profiterons pas ici, donc nous ne les mentionnons pas. En revanche toutes les règles concernant la propriété et les prêts restent valides. On peut pas faire n'importe quoi tout de même!

La version safe

La structure de données de la liste chaînée reste identique à celle que nous avons vue plus haut, ainsi que la création d'une nouvelle liste.

struct Element {
    data: i32,
    next: Option<Box<Element>>,
}
pub struct LinkedList {
    head: Option<Box<Element>>,
}
    pub fn new() -> Self {
        Self { head: None }
    }

Ici, nous ne nous interdisons pas d'utiliser des références et d'emprunter les instances de notre liste. Ainsi, la fonction is_empty() est simplement

    pub fn is_empty(&self) -> bool {
        self.head.is_none()
    }

où on a bien une référence vers self en argument de is_empty.

La fonction push()

La fonction dont nous discuterons le plus en détails est la fonction push(). Au fur et à mesure que nous la modifierons, nous étudierons ce qu'elle fait.

Naïvement, nous voudrions que push() fasse

fn push(&mut self, data: i32) {
    let new_element = Box::new(Element::new(data, self.head));
    self.head = Some(new_element);
}

Évidemment, cela ne peut pas être aussi simple, car on "move" self.head dans new_element ce qui est interdit, car self est derrière une référence mutable...

On doit donc ruser beaucoup plus pour faire croire à l'infâme compilateur que ce qu'on fait est sûr.

La solution simple et élégante est d'utiliser la méthode take() implémentée pour les Option<T> qui retourne la valeur de l'option et la remplace par None. Ainsi, le compilateur est content: on a pas move self.head, mais on l'a juste muté.

    pub fn push(&mut self, data: i32) {
        // let new_element = Box::new(Element::new(data, self.head));
        // Cela ne peut pas fonctionner, pace qu'on est derrière une référence partagée
        // et donc on peut pas "move" self.head

        let new_head = Box::new(Element::new(data, self.head.take()));
        // take retourne la valeur qui se trouve dans Some et laisse un None
        // à la place de l'option.
        // C'est strictement équivalent au replace (ci-dessous)
        self.head = Some(new_head);
    }

On a donc un push() fonctionnel. Mais on ne sait pas vraiment ce qui se passe dans le take() et ça semble un peu trop "magique" pour être safe. En étudiant l'implémentation de take() on se rencontre que `

self.head.take()

est équivalent à

std::mem::replace(&mut self.head, None)

qui lui-même est équivalent à

unsafe {
    let result = std::ptr::read(&self.head);
    std::ptr::write(&mut self.head, None);
    result
}

En fait, tout au fond des choses, take() effectue des appels aux fonctions unsafe read() et write(). En fait read() crée un copie "bit à bit" de self.head et comme self.head n'est pas Copy on a deux moyens d'accéder à la mémoire: soit par self.head soit par result (c'est de l'aliasing, ce qui est pas super sûr...). En particulier, si on essaie d'assigner self.head (self.head = ...) le compilateur va libérer la mémoire qui était liée à self.head et ainsi rendre les données de result invalides! Pour empêcher cela on doit utiliser la fonction write() qui va écrire sans libérer la mémoire les données liées à self.head (ce qui non plus n'est pas très sûr, parce qu'on gère pas la libération de la mémoire de self.head).

Combinées ces deux opérations read()/write() sont sûres, mais le compilateur n'a aucun moyen de le déduire et nous avons dû recourir à des opérations unsafe bien qu'emballées dans des fonctions parfaitement safe! Nous n'avons donc plus de garantie de la part du compilateur, et par conséquent, la responsabilité de la gestion de la mémoire nous revient (cf. les travaux du Pr. Ben).

    pub fn push_replace(&mut self, data: i32) {
        let old_head = std::mem::replace(&mut self.head, None);
        let new_head = Box::new(Element::new(data, old_head));
        // replace retourne self.head et remplace l'ancienne valeur par None (comme ça le compilateur est content)
        self.head = Some(new_head);
    }
    pub fn push_unsafe(&mut self, data: i32) {
        let old_head = unsafe {
            // De la documentation:
            // `read` crée une copie bit à bit de `T`, que `T` soit [`Copy`] ou non.
            // Si `T` n'est pas [`Copy`], utiliser à la fois la valeur renvoyée et la valeur de
            // `*src` peut violer la sécurité de la mémoire. Notez que l'assignation à `*src` compte comme une
            // utilisation parce qu'elle tentera de `drop` la valeur à `*src`.
            let result = std::ptr::read(&self.head);
            std::ptr::write(&mut self.head, None);
            // Ce `write` est en fait un "truc" pour enlever l'aliasing entre
            // self.head et result. Il écrase la valeur à self.head avec None
            // sans `drop` self.head et donc result.
            result
        };
        let new_head = Box::new(Element::new(data, old_head));
        self.head = Some(new_head);
    }

Dans ce code, nous voyons dans le commentaire l'utilisation d'un mot peut-être inconnu: aliasing. L'aliasing décrit une situation dans laquelle un emplacement de données en mémoire peut être accessible par différents noms dans le programme: dans notre cas result et self.head.

La morale de cette histoire est qu'il et très important d'essayer de faire son maximum pour faire du code safe. Comme on peut le voir dans le code ci-dessus, certaines fois cela est impossible (ou impose une complexité beaucoup trop grande à la personne qui développe), car les règles du compilateur sont telles qu'il ne peut garantir que ce qu'on fait est sûr. Dans ces rares cas, après avoir bien réfléchi et qu'on s'est assuré que le code est sûr quoi qu'il arrive, on peut écrire des parties de code unsafe. Une bonne pratique reste d'emballer ce code dans une fonction safe afin de limiter les cas d'utilisation.

La fonction pop()

La fonction pop() prend en argument &mut self et modifie donc l'instance de la liste chaînée sur laquelle elle s'applique. Elle a aussi recours à la fonction take() comme push().

    pub fn pop(&mut self) -> Option<i32> {
        // map prend la valeur dans Some, lui applique la fonction anonyme
        // et remballe la valeur obtenue dans un Some. Si l'Option
        // originale est None, il se passe rien.
        self.head.take().map(|element| {
            self.head = element.next;
            element.data
        })
    }

Si nous nous limitions à self.head.take(), nous retournerions la tête de la liste, après l'avoir remplacée par None. Cela casserait évidemment la liste chaînée de plus d'un élément... Ainsi, nous utilisons la fonction map() qui va appliquer la fonction anonyme qui lui est donnée en argument à la variante Some(elem) et emballer le retour dans une variante Some() (elle ne fera rien si l'option est None). Ici, nous retournons les données stockées dans l'élément de la tête, puis assignons l'élément suivant à la tête.

La fonction print()

La fonction print() est également bien plus élégante que celle vue précédemment.

    pub fn print(&self) {
        let mut current = &self.head;
        while let Some(tmp) = &current {
            print!("{} --> ", tmp.data);
            current = &tmp.next;
        }
        println!("∅");
    }

En effet, elle ne prend qu'une référence vers la liste et va se contenter de parcourir tous les éléments sans libérer la mémoire.

La version unsafe

Nous avons utilisé l'appel de fonctions unsafe à l'intérieur de fonction safe pour faire le code précédent. Dans cette partie, nous allons manipuler des "raw" pointers, qui ressemblent beaucoup aux pointeurs du C.

Les pointeurs sont très semblables aux références, sauf qu'ils n'obéissent pas aux mêmes règles de sûreté ou de durée de vie. C'est pour cela que ce genre de pointeurs sont utilisés bien souvent pour faire du Rust unsafe. Ces pointeurs se créent communément à partir de références

#![allow(unused)]
fn main() {
let value = 2;
let p_to_value: *const i32 = &value;
}

Ici, nous partons d'une variable immutable value dont nous prenons la référence (immutable) et nous l'assignons à un pointeur constant de i32. Ce type de pointeur peut uniquement être lu et il est impossible de modifier la valeur pointée (le code suivant ne compile pas).

#![allow(unused)]
fn main() {
let value = 2;
let p_to_value: *const i32 = &value;
*p_to_value = 3; // argl
}

Pour créer un pointeur pouvant modifier les données sur lesquelles il pointe, il est nécessaire que value et la référence créée sur value soient mutables. Mais il faut également se rappeler que pour modifier une valeur, il faut déréférencer le pointeur ce qui est une opération très dangereuse. Ainsi, il faut annoter tout code contenant le déréférencement d'un pointeur cru avec unsafe

#![allow(unused)]
fn main() {
let mut value = 2;
let p_to_value: *mut i32 = &mut value;
unsafe {
    *p_to_value = 10;
}
}

Sans l'annotation unsafe on a l'erreur suivante

error[E0133]: dereference of raw pointer is unsafe and requires unsafe function or block
 --> /tmp/mdbook-1nmQ54/unsafe.md:450:5
  |
5 |     *p_to_value = 10;
  |     ^^^^^^^^^^^^^^^^ dereference of raw pointer
  |
  = note: raw pointers may be null, dangling or unaligned; they can violate aliasing rules and cause data races: all of these are undefined behavior

error: aborting due to previous error

For more information about this error, try `rustc --explain E0133`

La structure de données

La structure de données d'un élément ressemble beaucoup à ce qu'on écrirait en C

#![allow(unused)]
fn main() {
struct Element {
    data: i32,
    next: *mut Element,
}
}

Un Element contient, data, les données qui sont stockées dans chaque élément (un i32 ici) et un raw pointer mutable sur l'élément suivant: next. Il est très important que ce pointeur soit mutable pour qu'on puisse le modifier (pour ajouter ou supprimer les éléments). Notez qu'en comparaison avec les Element des sections précédentes, nous n'utilisons pas de type Option pour signaler la présence ou absence d'un élément suivant. Cela sera représenté, comme en C, par un pointeur null.

Un nouvel Element est créé à l'aide de la fonction new()

    fn new(data: i32, next: *mut Element) -> *mut Element {
        let layout = Layout::new::<Element>();
        let e = unsafe { alloc(layout) as *mut Element };
        if e.is_null() {
            handle_alloc_error(layout);
        }
        unsafe {
            (*e).data = data;
            (*e).next = next;
        }
        e
    }

qui prend en argument les données à stocker dans l'élément et le pointeur suivant. On constate que pour allouer un élément, on doit préciser son "layout" en mémoire, puis utiliser la fonction alloc() qui est une fonction unsafe qui alloue un espace mémoire correspondant au layout spécifié et retourne un pointeur d'octets (u8). Ce pointeur est immédiatement converti en pointeur mutable d'Element. Lors d'une tentative d'allocation manuelle, si l'allocation échoue le programme panique par défaut. Ici, on doit gérer l'échec de l'allocation manuellement en vérifiant si le pointeur est nul e.is_null(). Ensuite, il est nécessaire de déréférencer le nouvel élément e et d'assigner chacun de ses champs à data et next respectivement. Cette opération nécessitant un déréférencement est unsafe et est annotée de façon adéquate.

L'allocation manuelle nécessite également une libération de la mémoire manuelle à l'aide de la fonction dealloc(). Cette opération est faite dans le trait Drop qui implémente la fonction drop(). Pour des raw pointers, la fonction drop() n'est pas appelée automatiquement et il faut l'appeler explicitement lors de la sortie de la portée du pointeur (voir la section sur pop()). De plus, pour la plupart des types, la fonction drop() est implémentée automatiquement, mais ici il est nécessaire d'appeler dealloc() manuellement et donc il est nécessaire de faire l'implémentation explicitement.

impl Drop for Element {
    fn drop(&mut self) {
        let elem = self as *mut Element;
        if !elem.is_null() {
            let layout = Layout::new::<Element>();
            unsafe {
                dealloc(elem as *mut u8, layout);
            }
        }
    }
}

On voit que pour libérer la mémoire, on doit vérifier que le pointeur qu'on essaie de libérer n'est pas null afin d'éviter de tenter de désallouer une zone mémoire interdite, puis on appelle la fonction dealloc() qui est de façon inhérente unsafe (on a aucune garantie que la mémoire qu'on libère est toujours valide) il est nécessaire d'annoter le code unsafe également (comme pour alloc() on a besoin de connaître le layout mémoire de ce qu'on va libérer).

Les fonctions new() et drop() sont deux fonctions qui appellent du code unsafe , mais n'ont pas besoin d'être annotées unsafe: c'est des abstractions permettant de cacher la dangerosité, tout en permettant à l'utilisateur·trice de chercher les bugs mémoire plus facilement, car ils se trouvent toujours liés à ces parties unsafe.

Maintenant que nous pouvons créer des nouveaux éléments (et les détruire), nous pouvons passer à la liste chaînée qui n'est rien d'autre qu'un pointeur d'Element mutable nommé astucieusement head.

pub struct LinkedList {
    head: *mut Element,
}

Pour créer une nouvelle liste chaînée, nous avons uniquement besoin de signaler que la liste est vide en assignant à head un pointeur nul mutable (ptr::nul_mut()).

    pub fn new() -> LinkedList {
        LinkedList {
            head: ptr::null_mut(),
        }
    }

La fonction is_empty()

Naturellement, la fonction is_empty() va uniquement vérifier que la tête de la liste est nulle et est trivialement implémentée

    fn is_empty(&self) -> bool {
        self.head.is_null()
    }

La fonction push()

La fonction push() est très simple à écrire et équivalente à ce qu'on ferait en C

    pub fn push(&mut self, data: i32) {
        let new_head = Element::new(data, self.head);
        self.head = new_head;
    }

La relaxation des règles très strictes sur les références permet de déplacer le pointeur de tête dans le nouvel élément qui devient ainsi la nouvelle tête de la liste.

La fonction pop()

La fonction pop() est un peu plus complexe, mais également très similaire à ce qu'on ferait en C.

    pub fn pop(&mut self) -> Option<i32> {
        if self.is_empty() {
            None
        } else {
            let old_head = self.head;
            unsafe {
                self.head = (*self.head).next;
            }
            let val = unsafe { (*old_head).data };
            unsafe {
                old_head.drop_in_place();
            }
            Some(val)
        }
    }

Si la liste est vide, on retourne un None (aucune valeur) car il n'y a rien à retourner. En revanche, si un élément est présent en tête de liste on garde un pointeur sur la copie de l'élément de tête, old_head, puis on déplace le pointeur de tête sur l'élément suivant (cette dernière opération nécessite un déréférencement et est donc unsafe). Puis ont récupère la data stockée dans old_head (opération unsafe car on fait un déréférencement) et finalement on appelle explicitement drop_in_place() (qui appelle drop()). Suite à la libération de la mémoire (qui empêche les fuites mémoire) on termine par retourner la valeur qui était stockée au sommet de la liste chaînée.

La fonction print()

La fonction print() est relativement simple et très similaire à ce qu'on fait en C

    pub fn print(&self) {
        let mut current_head = self.head;
        while !current_head.is_null() {
            unsafe {
                print!("{} --> ", (*current_head).data);
                current_head = (*current_head).next;
            }
        }
        println!("∅");
    }

On crée un pointeur mutable qui va parcourir tous les éléments de la liste chaînée et en afficher le contenu, jusqu'à atteindre la fin de la liste (le pointeur devient null). Les raw pointers n'appelant jamais drop() quand ils sortent de la portée ne libèrent jamais la mémoire sur laquelle ils pointent donc la liste chaînée reste intacte.

La fonction drop()

Comme on l'a dit tout à l'heure pour les Element, il est nécessaire d'implémenter le trait Drop

impl Drop for LinkedList {
    fn drop(&mut self) {
        while !self.is_empty() {
            let _ = self.pop();
        }
    }
}

Ici toutes les désallocations sont cachées dans la fonction pop(), et on parcourt toute la liste comme on l'a fait pour la fonction print().

Aller plus loin

Il y a plusieurs exercices que vous pouvez effectuer à partir de ces codes. Dans un ordre aléatoire:

  • Rendez-la liste générique (transformez i32 en T),
  • Ajoutez la fonction peek() qui permet de jeter un œil à la tête de la liste,
  • Ajoutez une fonction remove(i) permettant d'enlever le i-ème élément de la liste,
  • Ajoutez une fonction insert(i, data) permettant d'ajouter data à la i-ème place dans la liste,
  • Ajouter les fonctions permettant d'itérer sur la liste en implémentant les trait Iter et IntoIter.

Rustlings

Les rustlings à faire dans ce chapitre sont les suivants:

Les Box

$ rustlings run box1