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 licence 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 iront en augmentant pour illustrer de façon itérative les différents concepts 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/src/main.rs (et un dépôt git).

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 ouvrage de référence sur le Rust.
  • Rust by example. Une série d'exemples 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 immutable (les valeurs contenues dans tab ou sa taille ne pourront plus changer). On dit qu'on lie (ou bind en anglais) tab à la valeur [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 valeurs 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ères dans le terminal. Les macros sont toujours identifiées à l'aide du ! se trouvant à la fin du nom de la macro (juste avant la parenthèse), comme pour panic!(), print!() (affiche la chaîne de caractères 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ères se fait à l'aide des accolades {} et les macros print!() / println!() prennent un nombre variable d'arguments. À 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 bases2

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 suivis 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'entiers 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 qu'en Rust un tableau statique est une valeur : il est copié (ou déplacé) au moment du retour, sans risque de pointeur pendouillant. En C, un tableau se transforme en pointeur et renvoyer un pointeur vers une variable locale serait un bug.

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-virgule 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 à l'opérateur ternaire ?: du 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, après l'accolade fermante du if/else, un point-virgule termine l'instruction let (la liaison de min). 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 telles que le if sont des expressions. 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 suivantes :

#![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 n'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. À 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" ce 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 types_avances

Concepts

Les concepts abordés dans cet exemple sont:

  1. Les types énumérés.
  2. Fonctions associées et méthodes.
  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 formes (de variantes) 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 NumberOrNothing il est nécessaire d'utiliser la syntaxe Type::Variante.

Fonctions associées et méthodes

Sur ce type énuméré, nous définissons deux fonctions dans un bloc impl

impl NumberOrNothing {
// une fonction associée et une méthode
}

Ici nous avons deux fonctions. La première, fn new(val), est une fonction associée (elle ne prend pas self en paramètre) : elle crée 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, fn print(self), est une méthode (elle prend self en paramètre) et 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 son premier paramètre, self, qui désigne l'instance sur laquelle la méthode est appelée (self est un raccourci pour self: Self). Ainsi, pour appeler print() on utilise l'opérateur . sur l'instance : 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 ont été détruites 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 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 [i32; SIZE] 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ée 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 la suivante

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

où le dernier _ est un remplacement pour toutes les variantes non 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

Les 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, f64, 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 notre type, plus rien ne 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 associées. En effet, si on omet les <T> on a 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'en-tê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),

Or 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, if 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 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 fonctions dans un trait est arbitraire, il peut même être nul). L'en-tête de la fonction nous dit que la fonction min a deux arguments, la variable sur laquelle elle est appelée, et une variable du même type (annotée 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 adapter 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 matching

match (self, rhs)

on va créer un tuple avec les valeurs de self et de rhs et examiner toutes les combinaisons de variantes possibles pour ces types énumérés. 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 est 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 l'emplacement mémoire de 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 Copy, like other primitive types (f32, f64, etc.).
// Fixed-size arrays [T; N] are also Copy when T is Copy.
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, if 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 copiables.

On peut aussi remarquer qu'il est possible d'indiquer la nécessité que les types implémentant un trait soient 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 Copy, like other primitive types (f32, f64, etc.).
// Fixed-size arrays [T; N] are also Copy when T is Copy.
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, if 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 Copy, like other primitive types (f32, f64, etc.).
// Fixed-size arrays [T; N] are also Copy when T is Copy.
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, if 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 Copy, like other primitive types (f32, f64, etc.).
// Fixed-size arrays [T; N] are also Copy when T is Copy.
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, if 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 l'on peut écrire notre propre fonction de clonage, 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 é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 Copy, like other primitive types (f32, f64, etc.).
// Fixed-size arrays [T; N] are also Copy when T is Copy.
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, if 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 le chapitre Les types avancés, 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 n'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 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 mutable (deux accès modifiables simultanés à la même valeur). On retrouve cette difficulté quand on passe un argument à une fonction. Si le type de la variable passée 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 si 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 une valeur (y accéder) sans en prendre la propriété. 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>, qui ressemble à celle du C, mais une référence Rust n'est pas un pointeur nu (elle ne peut pas être nulle et sa validité est vérifiée par le compilateur).

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). Conceptuellement, t a donc le type &T dans la boucle for (l'annotation ci-dessous n'est pas une syntaxe Rust valide, c'est juste pour fixer les idées) :

for t: &T in tab

Ce n'est d'ailleurs pas une devinette du compilateur : la bibliothèque standard fournit une implémentation de Display pour &T dès que T implémente Display, si bien que print!("{}", t) affiche directement la valeur pointée (et non l'adresse de la référence)

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, if 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 constate qu'on doit déréférencer t (appeler *t), car t est une référence vers T (il est de type &T), puisque 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 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, if 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

Un module se déclare avec le mot-clé mod. On peut le faire directement dans main.rs (mod io;, le code allant dans io.rs), sans aucun autre fichier. Ici on choisit plutôt de déclarer les modules dans un fichier lib.rs placé à côté de main.rs (dans src/), pour que le code soit réutilisable à la fois par main.rs, par les tests et par d'autres crates. Ce lib.rs 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;

Une crate est une unité de compilation. Un main.rs produit une crate binaire (un exécutable) ; un lib.rs produit une crate bibliothèque, réutilisable et publiable sur crates.io. Les deux peuvent contenir des modules.

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 dans des modules tout le code qui se trouvait dans le main.rs du chapitre La propriété. 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;

il faut 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 champ 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).

Un module peut aussi être défini en ligne, entre accolades, sans fichier séparé :

mod geometrie {
    pub struct Point { pub x: f64, pub y: f64 }
    pub fn origine() -> Point { Point { x: 0.0, y: 0.0 } }
}
fn main() {
    let o = geometrie::origine();
    println!("({}, {})", o.x, o.y);
}

Afin 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 leurs champs, méthodes et fonctions associées), 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 point en deux dimensions 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 accessibles 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() {
pub 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 de la documentation et des 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 les 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 un 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 using 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 méthode
impl<T: std::fmt::Display> SomethingOrNothing<T> {
    /// A method 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
/// A 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 generated 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 Common Markdown syntax.
//!
//! Below we will use the `///` comments that will comment the code directly below.
//! We can also use `//` but they will not be rendered.
//! All the lines written here could be enclosed in `/*! ... */` instead of being prefixed by `//!`.
//!
//! For more information 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 official 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 du 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 principale 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 échoue 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

/// A 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::{SomethingOrNothing, find_min};

    #[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 inclus 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, auquel cas il ne sera pas compilé, ou no_run, auquel cas il ne 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 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, if T is Copyable, 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 test
   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 formateur de code, et clippy un linter.

rustfmt

Rust a fait le choix fasciste de définir des tas de conventions pour le formatage du code et pour le nommage des variables, des types, etc. L'outil rustfmt s'occupe du formatage : il reformate automatiquement le code pour avoir un style uniforme au travers de tout votre code et ainsi améliorer sa lisibilité. Il ne renomme rien : les identifiants mal nommés (comme size au lieu de SIZE, ou MinTab au lieu de min_tab ci-dessous) sont signalés par le compilateur lui-même (non_upper_case_globals, non_snake_case) ou par clippy.

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, copiez le code ci-dessus dans un src/main.rs et lancez rustfmt --check src/main.rs pour voir ce que rustfmt corrigerait (la mise en forme uniquement) ; l'option --check montre les changements sans les appliquer. Lancez ensuite cargo build pour voir les avertissements du compilateur sur le nommage. 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 mauvaises 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 tests

$ 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érencier 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 occurrences, et par conséquent, de s'y préparer. Elles 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é
  • 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) << 8 | (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 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: une faute logique dans la conception de l'algorithme, un cas non 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 niveaux d'attente en termes de fiabilité. Il arrive souvent qu'un jeu soit buggé dans ses premières versions. En revanche, il n'est pas acceptable que le pilote automatique d'un avion s'arrête 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. 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 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 le chapitre Les types avancés.

Nous pouvons donc réécrire 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, if T is Copyable, 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 macro 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 et à 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, if T is Copyable, 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 structure quelconque.

Si malgré notre certitude, la fonction ne parvenait pas à produire de résultat, nous serions alors dans un cas indéterminé, 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 redéfinissant 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ée à des fins de debugging ou uniquement lorsque le programme rentre dans un état indéterminé, c'est-à-dire une 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), soit une valeur permettant de renseigner l'erreur sous la forme d'un 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 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évue. 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, if T is Copyable, 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érale, 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 fonctionnel, 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(val: i32) -> Result<i32, String> {
  // do stuff
  match val {
    1 => Ok(42),
    2 => Err("Oupsi".to_string()),
    3 => Err("G phai une faute".to_string()),
    _ => Err("Aie aie aie".to_string()),
  }
}

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 langage 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 prend 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 vérifier qu'aucune erreur ne 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épétitif et rend le code de 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 fonctionnelle 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 prend en argument une ou plusieurs fonctions et/ou retourne une ou plusieurs fonctions.

L'intérêt principal de nos fonctions anonymes 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'additionner 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 prend 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 additionne 1 à un entier. À 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'aperçoit qu'il s'agit d'une fonction qui prend deux éléments de type T et en retourne un troisième du même type. 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, if T is Copyable, 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 le chapitre La gestion des erreurs en Rust. 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 initialisé à 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 rencontre durant la première itération, on utilise une simple assignation conditionnelle comme nous l'avons vu dans les cours précédents.

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 une 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 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 deux nombres.

On voit ici tout l'intérêt de nos fonctions d'ordre supérieur. Il me suffit d'écrire une seule 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 rencontre parfois la traduction fermetures. Les closures ne sont pas simplement des fonctions sans identifiant, en effet, une closure capture son environnement. Une closure est composée d'une fonction anonyme et des variables capturées dans son environnement.

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

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 signifie 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, il fait partie d'une famille de trois traits (Fn, FnMut et FnOnce), selon la façon dont la closure utilise son environnement. Toute closure implémente au moins FnOnce ; nous ne verrons pas les deux autres en détail dans le cadre de ce cours.

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-dessus, on peut voir que la fonction do_stuff prend 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 autres 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 impair.

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 impair.
    • Sinon si max_val contient une valeur, on affiche un message qui annonce que le maximum est pair.
    • 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êmement répandu en programmation 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 produit donc une suite d'éléments un à un : la méthode next() renvoie l'élément suivant, encapsulé dans un Some(...), ou None quand il n'y a plus rien. 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 avancer dans notre itérateur, nous utilisons la méthode next() qui renvoie l'élément suivant sous la forme d'une Option (Some(x) s'il reste un élément, None sinon). Pour rappel, la signature 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 pairs sont {}, {}, {}", v[0], v[1], v[2])
}

Le code ci-dessus commence par créer un itérateur infini de nombres pairs. Nous verrons plus tard que les éléments de l'itérateur infini ne sont pas générés 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'entiers à l'aide de la méthode collect().

iter(), iter_mut(), into_iter()

Une collection donne trois façons de produire un itérateur :

  • iter() : itère sur des références partagées &T (la collection reste utilisable après) ;
  • iter_mut() : itère sur des références mutables &mut T (pour modifier les éléments en place) ;
  • into_iter() : consomme la collection et itère sur les T par valeur.

C'est pour cela que, plus bas, la closure de fold() reçoit un &i32 : on a utilisé iter().

Les consommateurs prêts à l'emploi

La bibliothèque standard fournit déjà de nombreuses méthodes qui consomment un itérateur pour produire un résultat : min(), max(), sum(), count(), min_by_key(), any(), all(), find()... Calculer le minimum d'un vecteur tient donc en une ligne :

fn main() {
let v = vec![10, 3, 42, 7];
let min = v.iter().min(); // Option<&i32>
assert_eq!(min, Some(&3));
}

Dans la suite, nous ré-implémentons min "à la main" avec fold() : non pas parce qu'il faut le faire, mais pour comprendre comment fonctionne une réduction.

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. Elles permettent de traiter des collections de manière concise et efficace. De plus, un traitement écrit sous forme d'itérateurs se parallélise facilement : le crate Rayon-rs permet d'effectuer ces traitements 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 algorithmes 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'occurrence 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 arguments, un accumulateur (la valeur courante de la réduction) de type Option<i32> et 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 est 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électionné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 utilisant 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 performantes 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 performances 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 fold() ou collect(), que le calcul est réellement effectué pour produire un résultat. Les transformations intermédiaires peuvent ainsi souvent être fusionnées.

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 transformations 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 ne modifions que très peu le code du chapitre sur l'outillage 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::rng();
    let mut v: Vec<i32> = Vec::new();
    for _i in 0..len {
        v.push(rng.random());
    }
    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.random());
    }

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 chaîne de caractères, 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. La chaîne de caractères n'est en réalité rien d'autre qu'un pointeur vers des données sur le tas, accompagné d'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ères correspond à un nombre variable d'octets (ce qui n'est pas le cas pour un Vec). C'est pourquoi Rust n'autorise pas l'indexation d'une String par un entier : écrire s[i] ne compile pas (le type String n'implémente pas Index<usize>). Pour obtenir le i-ème caractère, on utilise s.chars().nth(i). On peut aussi extraire une sous-chaîne avec une plage d'octets, &s[a..b], qui panique si les bornes ne tombent pas sur une frontière de caractère.

Une illustration de l'utilisation d'une chaîne de caractères 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ée 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 &'static str : une référence vers une chaîne de caractères (str) qui vit pendant toute la durée du programme. Le type str seul (non dimensionné) n'est presque jamais manipulé directement ; on passe par la référence &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ères (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 élément 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 à 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, if T is Copyable, 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 fonctions 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 en 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 externe (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 instances 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 affaire à 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 obligera à en spécifier une

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 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 Minimum. 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 part, Rust offre ce sucre syntaxique pour éviter d'écrire trop d'annotations.

Pour le trait Minimum les choses se compliquent un peu. Pour éviter les 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 a 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 quelle 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émentations de ce trait: la première est pour les i32, la seconde pour SomethingOrNothing<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 interprété par le compilateur (élision) comme la signature

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

Le corps, lui, reste celui qu'on écrit : if self < rhs { self } else { rhs }. Or la durée de vie 'a du retour n'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 n'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 valeurs 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émentations 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 chiffres de nos nombres. Les opérateurs <, >, <=, >= proviennent du trait PartialOrd et == du trait PartialEq ; le trait Ord ajoute un ordre total via la fonction cmp(), qui retourne une valeur du type énuméré Ordering, lequel a trois variantes

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 n'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 doit ê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. Le type de retour SomethingOrNothing<&T> encapsule une référence vers la valeur minimale, qui pointe dans le slice d'entrée : elle doit donc avoir la même durée de vie que lui, d'où l'annotation 'a.

L'implémentation de cette fonction n'est pas très complexe, mais est très intéressante. En premier lieu, pour des questions 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 et faisons un fold() sur cet itérateur. Le fold() prend en argument un élément neutre (quelle 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

fn main() -> Result<(), String> {
    let v1 = vec![1, 3, 6, 9];
    let v2 = vec![2, 4, 2, 1];
    let v3 = vec![7, 4, 5, 3];
    let v4 = vec![4, 1, 1, 1];
    let v5 = vec![2, 5, 1, 8];
    let v6 = vec![5, 1, 5, 2];
    let v7 = vec![7, 6, 6, 7];
    let v8 = vec![8, 2, 2, 2];
    let lhs = vec![
        CustomInt::try_new(&v1, 1)?,
        CustomInt::try_new(&v2, -1)?,
        CustomInt::try_new(&v3, 1)?,
        CustomInt::try_new(&v4, -1)?,
        CustomInt::try_new(&v5, 1)?,
        CustomInt::try_new(&v6, 1)?,
        CustomInt::try_new(&v7, 1)?,
        CustomInt::try_new(&v8, 1)?,
    ];

    println!("Among the custom ints in the list:");
    io::print_tab_custom_int(&lhs);
    let min = find_min(&lhs);
    println!("The minimum is {min}");
    Ok(())
}

on crée un tableau de CustomInt qui sont créés à partir de références sur les tableaux v1, v2, etc. qui vivront 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 tout au long de ce code sont vérifiés par le compilateur qui vérifie qu'elles sont toutes valides à la compilation.

Rustlings

$ rustlings run lifetimes1
$ rustlings run lifetimes2
$ rustlings run lifetimes3

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 le chapitre La gestion des erreurs en Rust).

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 construire une interface pour un programme avec arguments nommés, optionnels, 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 [dependencies].

  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 manuellement la version qu'on veut mettre dans le fichier Cargo.toml. Écrire version = "4.4.0" signifie en réalité "au moins 4.4.0, mais strictement inférieur à 5.0.0" (c'est la sémantique par défaut de Cargo, aussi notée ^4.4.0) : les mises à jour compatibles restent autorisées. Pour figer exactement une version, il faut écrire version = "=4.4.0" (le Cargo.lock fige de toute façon les versions résolues). On peut aussi remplacer 4.4.0 par * pour prendre toujours la dernière version disponible ; cette dernière 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 champ features qui est optionnel, mais qui ici est mis à derive. Le langage Rust permet d'omettre une partie des fonctionnalités d'une librairie 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 de permettre à l'utilisateur·trice de 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 les options 1 et 2 sont 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.

La 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és 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 l'on sait qu'il doit y en avoir au moins 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és 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ées, à 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é. À 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ésent 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). À 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 échéant é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'alourdir 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érents 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 l'attribut

#[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)

À la différence de cette ligne où on spécifie explicitement l'auteur, etc., ici, le contenu du champ 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é-valeur se trouvant entre les parenthèses. Si une valeur est absente alors un comportement par défaut est appliqué. Ainsi, long demande la génération de l'option longue (--numbers), et short celle de la forme courte (-n, la première lettre du nom). Ces deux formes ne sont pas générées par défaut : il faut les demander explicitement, comme dans notre code. Si deux champs demandent short et commencent par la même lettre, clap signale une erreur.

Le reste des arguments correspond à 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ée 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.count, 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 fournit, sur les systèmes Unix, un flux de nombres pseudo-aléatoires de qualité cryptographique (un CSPRNG initialisé à partir de l'entropie 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 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 tampon mémoire numbers (qui n'est rien d'autre qu'une suite d'octets) est converti 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 octets grâce à la méthode .chunks(4) (on obtient un itérateur de slices &[u8] 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 nous 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 un 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.")?;

À 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ées permettant de représenter une pile (structure de données 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émentation de 5 fonctionnalités 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 noeuds 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 affaire à 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 (le pointeur propriétaire de la bibliothèque standard, qui alloue son contenu sur le tas) pour 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 La gestion des erreurs en Rust) 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 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 liste (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 on 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 {
        // Attention : new_list est reconstruite en ré-empilant chaque élément en
        // tête, elle est donc renvoyée dans l'ordre inverse de `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 une assignation à une nouvelle liste ce qui est encore acceptable pour push(). En revanche pour pop() ou print() on voit clairement que ce n'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...". C'est effectivement possible, mais à condition d'écrire un Drop itératif (qui déroule la liste dans une boucle) et non le Drop récursif généré par défaut ; sinon on ne fait que déplacer le débordement de pile dans le destructeur. Une autre option est d'implémenter une fonction clear() vidant la liste et de l'appeler à la main.

    pub fn clear(self) {
        let mut current = self.head;
        while let Some(tmp) = current {
            current = tmp.next;
        }
    }
fn main() {
    // clear() vide la liste dans une boucle, ce qui évite le débordement de pile au drop
    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 de la section précédente 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 suivi d'un bloc:

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

Comme tout bloc, il peut ainsi retourner une valeur.

Il y a d'autres contraintes qui sont relaxées, mais nous n'en profiterons pas ici, donc nous ne les mentionnons pas. En revanche, unsafe ne désactive pas le vérificateur d'emprunts : les références (&T, &mut T) obéissent toujours aux mêmes règles. Ce sont les pointeurs bruts (*const T, *mut T) qui échappent à ces vérifications : on peut en avoir plusieurs vers la même donnée, les déréférencer, etc. C'est alors à vous de garantir qu'on ne crée pas d'aliasing illégal.

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 n'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, parce qu'on est derrière une référence partagée
        // et donc on ne 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 rend compte 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
}

On peut exprimer la même opération à l'aide des fonctions unsafe ptr::read() et ptr::write() (c'est d'ailleurs proche de l'implémentation historique de mem::replace). En fait read() crée une 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 n'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 ne 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 est très important d'essayer de faire son maximum pour faire du code safe. Comme on peut le voir dans le code ci-dessus, parfois 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 s'être 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);
        }
        // On écrit dans la mémoire fraîchement allouée, encore non initialisée.
        // L'opérateur `=` tenterait normalement de `drop` l'ancienne valeur du champ :
        // c'est sans danger ici, `i32` et `*mut Element` n'ayant pas de destructeur.
        // Pour un champ avec destructeur, il faudrait passer par `ptr::write`.
        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 allocation manuelle, si l'allocation échoue, alloc() renvoie un pointeur nul (il ne panique pas ; ce sont Box et Vec qui appellent handle_alloc_error en cas d'échec). Ici, on doit donc gérer l'échec de l'allocation manuellement en vérifiant si le pointeur est nul avec 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 les valeurs 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 n'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: ce sont 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::null_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 on 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 oeil à 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 traits Iterator et IntoIterator.

Rustlings

Les rustlings à faire dans ce chapitre sont les suivants:

Les Box

$ rustlings run box1