Introduction
Crédits et licence
Ce cours est fortement inspiré de l'excellent tutoriel Rust-101 écrit par Ralf Jung https://www.ralfj.de/projects/rust-101/main.html. Il est publié sous la license CC BY-SA 4.0.
Préambule
Ce court texte n'a pas vocation à remplacer un cours complet, mais à rappeler les concepts importants en les illustrant à l'aide de codes courts.
Les codes discutés ont tous pour but de calculer la valeur minimale d'entiers contenus dans une liste. La difficulté et l'élégance de ces codes ira en augmentant pour illustrer de façon itératives les différents concepts du présents dans le langage.
Installation du compilateur Rust
Pour pratiquer le Rust, il est nécessaire d'installer le compilateur Rust. Il n'est pas recommandé d'utiliser votre gestionnaire de paquet, mais plutôt de télécharger toute la chaîne de compilation grâce à l'outil rustup. Ou alors d'exécuter la commande suivante dans un terminal
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
Ce script installera pour vous le gestionnaire de paquet et de compilation cargo
,
le compilateur rustc
, ainsi que le linter clippy
et l'outil de formatage de code
rustfmt
.
Vous pouvez maintenant créer un nouveau projet rust (astucieusement nommé new_project
) avec la commande
cargo new new_project
Cette commande crée un répertoire new_project
, ainsi que les fichiers new_project/Cargo.toml
et new_project/main.rs
.
Vous pouvez compiler votre programme avec
cargo build
Puis l'exécuter à l'aide de la commande
cargo run
La commande cargo run dépend de l'étape de compilation, par conséquent si le code n'est pas compilé, alors la commande cargo run
lancera la compilation avant d'exécuter votre programme.
Il est également possible de nettoyer les artéfacts de compilation ainsi que l'exécutable à l'aide de la commande
cargo clean
Génération
Il est possible de générer ce cours. Pour ce faire, vous pouvez télécharger les sources depuis le repo du cours rust-101. Puis il faut installer le programme mdbook (voir ce lien pour l'installation).
Depuis la racine du repo, il suffit d'exécuter les commandes
$ cd book
$ mdbook build
et vous retrouverez l'index dans le fichier
$ book/index.html
Références
Il existe un grand nombre de références pour le Rust. Vous en trouverez quelques-unes ci-dessous.
- The book. Il s'agit de l'excellent de référence sur le Rust.
- Rust by example. Une série d'exemple illustrant la syntaxe du langage et ses concepts.
- Gentle introduction to Rust. Une introduction au Rust pour les personnes connaissant divers autres langages.
- Rustlings. De courts exercices sur le langage Rust.
Discussion du code bases1
Concepts
Les concepts abordés dans cet exemple sont:
- Les variables mutables ou non, les constantes.
- Les structures de contrôle
if ... else
etfor
. - L'utilisation de tableaux statiques.
- L'utilisation de macros pour la gestion d'erreurs ou les sorties dans le terminal.
Discussion
Chaque code Rust a un unique point d'entrée: la fonction fn main() {}
.
Ainsi, le code le plus simple (qui ne fait absolument rien) est.
fn main() { }
Le corps de votre programme se trouvera donc entre les accolades.
Variables, variables mutables, et constantes
Dans l'ordre d'apparition, nous avons d'abord une constante nommée SIZE, dont le type est usize
(entier non signé dont la taille dépend de l'architecture, 8 octets sur une architecture 64 bits) et qui vaut 9
. Le nom du type vient après :
.
#![allow(unused)] fn main() { const SIZE: usize = 9; }
Ensuite nous déclarons un tableau statique (alloué sur la pile) d'entiers 32 bits et de taille SIZE
.
#![allow(unused)] fn main() { const SIZE: usize = 9; let tab: [i32; SIZE] = [10, 32, 12, 43, 52, 53, 83, 2, 9]; }
Vous notez le mot clé let
qui permet de déclarer une variable immutables (les valeurs contenues dans tab
ou sa taille ne pourront plus changer). On dit qu'on lie (ou bind en anglais) tab
à la valeur du [10, 32, 12, 43, 52, 53, 83, 2, 9]
. Plus bas nous déclarons au contraire une variable mutable (qui elle pourra changer de valeur au cours de l'exécution du programme).
#![allow(unused)] fn main() { const SIZE: usize = 9; let tab: [i32; SIZE] = [10, 32, 12, 43, 52, 53, 83, 2, 9]; let mut min = tab[0]; }
et l'initialisons avec la valeur du 1er élément de tab
(ici la valeur est 10
).
Structures de contrôle
Voici deux extraits de code. Dans le premier
#![allow(unused)] fn main() { const SIZE: usize = 0; if SIZE == 0 { panic!("Size is of tab = 0."); } }
nous testons si SIZE == 0
et utilisons la macro panic!
qui lorsqu'elle est exécutée fait quitter le programme et
affiche le message d'erreur en argument. Ainsi le code ci-dessus retourne:
Compiling playground v0.0.1 (/playground)
Finished dev [unoptimized + debuginfo] target(s) in 0.56s
Running `target/debug/playground`
thread 'main' panicked at 'Size is of tab = 0.', src/main.rs:5:5
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
Dans le second cas, nous sommes dans une boucle for
#![allow(unused)] fn main() { const SIZE: usize = 9; let tab: [i32; SIZE] = [10, 32, 12, 43, 52, 53, 83, 2, 9]; let mut min = tab[0]; for i in 1..SIZE { if min > tab[i] { min = tab[i]; } } }
où l'indice i
prend successivement les valeur 1
à SIZE-1
(la notation a..b
veut dire de a
à b
non inclus) et assignons
la valeur tab[i]
à la variable mutable min
. Si nous avions omis le mot clé mut
lors de la déclaration de min
l'assignation
donnerait une erreur (cliquez sur "play" pour la démonstration)
#![allow(unused)] fn main() { const SIZE: usize = 9; let tab: [i32; SIZE] = [10, 32, 12, 43, 52, 53, 83, 2, 9]; let min = tab[0]; for i in 1..SIZE { if min > tab[i] { min = tab[i]; } } }
Macros
Outre la macro panic!()
nous utilisons ici deux macros permettant d'afficher des chaînes de caractère
dans le terminal. Les macros sont toujours identifiées à l'aide du !
se trouvant à la fin de l'appel,
comme pour panic!()
, print!()
(affiche la chaîne de caractère en argument) ou println!()
(qui est comme print!()
mais
retourne à la ligne après avoir affiché). Comme on le voit dans les lignes suivantes
#![allow(unused)] fn main() { const SIZE: usize = 9; let tab: [i32; SIZE] = [10, 32, 12, 43, 52, 53, 83, 2, 9]; let mut min = tab[0]; for i in 1..SIZE { if min > tab[i] { min = tab[i]; } } println!("Among the numbers in the list:"); for i in 0..SIZE { print!("{} ", tab[i]); } println!(); }
le formatage de la ligne de caractère se fait à l'aide des accolades {}
et les macros print!()
/ println!()
prennent un nombre
d'arguments variables. A chaque {}
doit correspondre une variable dont on veut afficher le contenu.
Il est également possible de numéroter chaque {}
. Par exemple
#![allow(unused)] fn main() { println!("{1} {0}", "abc", "def"); }
Affichera def abc
.
Rustlings
Les rustlings à faire dans ce chapitre sont les suivants:
Introduction
$ rustlings run intro1
$ rustlings run intro2
Les variables
$ rustlings run variables1
$ rustlings run variables2
$ rustlings run variables3
$ rustlings run variables4
$ rustlings run variables5
Les types primitifs
$ rustlings run primitive_types1
$ rustlings run primitive_types2
$ rustlings run primitive_types3
Discussion du code part01
Concepts
Les concepts abordés dans cet exemple sont:
- Les fonctions.
- Les
if ... else
sont des expressions. - Itérer sur les éléments d'un tableaux directement avec la boucle for.
Discussion
Les fonctions
Dans cette seconde itération de notre programme calculant le minimum d'une liste de nombres, nous introduisons les fonctions en Rust.
La syntaxe d'une fonction est la suivante:
fn function_name(arg1: type, arg2: type, ...) -> ReturnType
Une fonction est annotée avec le mot-clé fn
suivi de son identifiant.
Son type de retour se trouve à droite après la flèche (en ASCII-art) ->
.
Si une fonction n'a pas de type de retour explicite (il n'y a pas de flèche) le type de retour est ()
qui
rappelle le type void
de C (aka sans type, mais oui c'est un type en Rust). Entre parenthèses se trouvent les arguments de la fonction suivi de leur type. Le nombre d'arguments est supérieur ou égal à zéro.
Une fonction est ensuite appelée par son identifiant et ses éventuels arguments.
const SIZE: usize = 9; fn read_command_line() -> [i32; SIZE] { [10, 32, 12, 43, 52, 53, 83, 2, 9] // équivalent à return [10, 32, 12, 43, 52, 53, 83, 2, 9]; } fn main() { read_command_line(); }
La fonction read_command_line()
ne prend ainsi aucun argument et retourne un tableau statique d'entier 32 bits et de taille SIZE
.
Pour qu'une fonction retourne une valeur, on a deux solutions:
- 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.
- On peut, de façon similaire au C, utiliser le mot clé
return val;
On constate ici que les tableaux statiques (contrairement à ce qui se passe dans le langage C) peuvent être retournés par une fonction (le tableau n'est pas désalloué au moment du retour).
La vérification de la taille du tableau se fait dans la fonction check_size(size: usize)
qui prend donc un argument de
type usize
et ne retourne rien.
fn check_size(size: usize) { if size == 0 { panic!("Size is of tab = 0."); } } fn main() { check_size(10); // runs alright check_size(0); // panics }
À l'inverse la fonction print_tab(tab)
prend en argument un tableau statique et ne retourne rien.
const SIZE: usize = 9; fn read_command_line() -> [i32; SIZE] { [10, 32, 12, 43, 52, 53, 83, 2, 9] } fn print_tab(tab: [i32; SIZE]) { for t in tab { print!("{} ", t); } println!(); } fn main() { let tab = read_command_line(); print_tab(tab); }
Les fonctions min_i32(lhs, rhs)
retourne la plus petite valeur de deux entiers signés de 32 bits.
fn min_i32(lhs: i32, rhs: i32) -> i32 { if lhs < rhs { lhs } else { rhs } } fn main() { let min = min_i32(2, 10); println!("{min}"); }
En Rust le if
est une expression
Mais si vous étudiez attentivement ce code, on constate que le corps de la fonction est étrange.
On voit qu'aucune ligne ne possède de point terminal. Cela voudrait dire que lhs
ou rhs
sont la dernière expression
de la fonction. En fait ce qui se passe c'est que le if ... else
est une expression. De façon similaire au
if
ternaire en C
, le if ... else
en Rust retourne une valeur. On peut écrire de façon équivalente au code ci-dessus
fn min_i32(lhs: i32, rhs: i32) -> i32 { let min = if lhs < rhs { lhs } else { rhs }; min } fn main() { let min = min_i32(2, 10); println!("{min}"); }
On voit qu'ici à la fin du bloc du else
nous avons un point virgule qui indique la fin de la ligne.
Ainsi lhs
ou rhs
sont liés à la variable immutable min
.
Cette notion d'expression est très importante. Comme nous l'avons expliqué précédemment la dernière expression d'une fonction est évaluée puis retournée par cette dernière.
Un bloc de code qui produit un résultat est une expression.
En Rust, plusieurs structures de contrôle tel que le If
sont des expression. Il n'est pas nécessaire de connaître la valeur de l'expression pour la retourner.
Nous pourrions considérer par exemple les fonctions suivante :
#![allow(unused)] fn main() { fn add(lhs: i32, rhs: i32) -> i32 { lhs + rhs } fn add_one(val: i32) -> i32 { add(1, val) } }
La boucle for
revisitée
La dernière fonction à discuter est find_min(tab)
qui retourne la valeur minimale se trouvant dans tab
.
const SIZE: usize = 9; fn read_command_line() -> [i32; SIZE] { [10, 32, 12, 43, 52, 53, 83, 2, 9] } fn check_size(size: usize) { if size == 0 { panic!("Size is of tab = 0."); } } fn min_i32(lhs: i32, rhs: i32) -> i32 { if lhs < rhs { lhs } else { rhs } } fn find_min(tab: [i32; SIZE]) -> i32 { check_size(SIZE); let mut min = i32::MAX; for t in tab { min = min_i32(min, t); } min } fn main() { let min = find_min(read_command_line()); println!("The minimal value is: {min}"); }
Cette version du calcul du minimum contient une différence importante dans la syntaxe de la boucle for
.
En effet, ici on itère pas sur un indice et on accède ensuite aux éléments du tableau.
La syntaxe
for val in tab
permet d'itérer directement sur les valeurs contenues dans tab
. Au début de chaque tour de la boucle for
,
val prend la valeur "courante" du tableau. A la fin du bloc du for
, la valeur courante prend la valeur "suivante"
dans le tableau si elle existe. En fait le tableau est implicitement converti en itérateur (plus de détails plus tard sur le sujet).
Dans d'autres langages on appelle ça une boucle "for each" qui traduit en français signifie pour chaque.
Le code ci-dessus est strictement équivalent à
const SIZE: usize = 9; fn read_command_line() -> [i32; SIZE] { [10, 32, 12, 43, 52, 53, 83, 2, 9] } fn check_size(size: usize) { if size == 0 { panic!("Size is of tab = 0."); } } fn min_i32(lhs: i32, rhs: i32) -> i32 { if lhs < rhs { lhs } else { rhs } } fn find_min(tab: [i32; SIZE]) -> i32 { check_size(SIZE); let mut min = i32::MAX; for i in 0..SIZE { min = min_i32(min, tab[i]); } min } fn main() { let min = find_min(read_command_line()); println!("The minimal value is: {min}"); }
Le code
const SIZE: usize = 9; fn read_command_line() -> [i32; SIZE] { [10, 32, 12, 43, 52, 53, 83, 2, 9] } fn check_size(size: usize) { if size == 0 { panic!("Size is of tab = 0."); } } fn print_tab(tab: [i32; SIZE]) { for t in tab { print!("{} ", t); } println!(); } fn min_i32(lhs: i32, rhs: i32) -> i32 { if lhs < rhs { lhs } else { rhs } } fn find_min(tab: [i32; SIZE]) -> i32 { check_size(SIZE); let mut min = i32::MAX; for t in tab { min = min_i32(min, t); } min } fn main() { let tab = read_command_line(); println!("Among the numbers in the list:"); print_tab(tab); let min = find_min(tab); println!("The minimal value is: {min}"); }
Rustlings
Les rustlings à faire dans ce chapitre sont les suivants:
Les if
s
$ rustlings run if1
$ rustlings run if2
$ rustlings run if3
Les fonctions
$ rustlings run functions1
$ rustlings run functions2
$ rustlings run functions3
$ rustlings run functions4
$ rustlings run functions5
Quiz
$ rustlings run quiz1
Discussion du code bases2
Concepts
Les concepts abordés dans cet exemple sont:
- Les types énumérés.
- Les fonctions statiques.
- Les arguments des fonctions en Rust.
- Le pattern matching.
Discussion
Les types énumérés
Dans ce code, nous introduisons un type complexe: le type énuméré NumberOrNothing
#![allow(unused)] fn main() { enum NumberOrNothing { Nothing, Number(i32), } }
Un type énuméré est très utile quand on veut décrire un objet ayant un nombre fini de valeurs possibles.
Ici nous avons deux variantes pour le type NumberOrNothing
:
NumberOrNothing::Nothing
qui décrit l’absence de valeur,NumberOrNothing::Number(i32)
qui décrit la présence d'une valeur et qui encapsule un entier 32 bits.
On note ici, que pour accéder à une variante de NumberOfNothing
il est nécessaire d'utiliser la syntaxe Type::Variante
.
Les fonctions statiques
Sur ce type énuméré, nous définissons deux fonctions statiques à l'aide de la syntaxe
impl NumberOrNothing {
// fonction statiques
}
Ici nous avons deux fonctions. La première, fn new(val)
, créer une instance de NumberOrNothing
à partir d'un entier et le stocke dans la variante NumberOrNothing::Number(val)
. En général la fonction new
sur un type est une sorte de "constructeur": elle permet de créer une nouvelle instance d'un type. La notion d'instance est très importante. Si nous prenons un type connu i32
, il s'agit du type et let i:i32 = 5;
est une instance de ce type.
enum NumberOrNothing { Nothing, Number(i32), } impl NumberOrNothing { fn new(val: i32) -> Self { NumberOrNothing::Number(val) } } fn main() { let num = NumberOrNothing::new(10); }
On voit ici que pour appeler la fonction new
on la préfixe par le nom du type
suivi de ::
, soit NumberOrNothing::new(val)
.
La seconde fonction, fn print(self)
, sert à afficher le contenu d'une instance d'un NumberOrNothing
.
enum NumberOrNothing { Nothing, Number(i32), } impl NumberOrNothing { fn new(val: i32) -> Self { NumberOrNothing::Number(val) } fn print(self) { match self { NumberOrNothing::Nothing => println!("No number."), NumberOrNothing::Number(val) => println!("The number is: {}", val), } } } fn main() { let num = NumberOrNothing::new(10); num.print(); // NumberOrNothing::print(num); équivalent }
Les arguments des fonctions en Rust
Il y a plusieurs choses à noter dans cette fonction. La première est qu'elle prend en argument le mot-clé self
qui se réfère à l'instance sur laquelle la fonction est appelée. Ainsi quand on veut appeler la fonction print()
on utilise la syntaxe du sélecteur sur la variable sur laquelle s'appelle la fonction: num.print()
. Il existe une syntaxe équivalente qui serait:
NumberOrNothing::print(num);
Avant de nous intéresser au code qui est présent dans le corps de la fonction print()
, essayons d'appeler deux fois de suite la fonction print()
:
enum NumberOrNothing { Nothing, Number(i32), } impl NumberOrNothing { fn new(val: i32) -> Self { NumberOrNothing::Number(val) } fn print(self) { match self { NumberOrNothing::Nothing => println!("No number."), NumberOrNothing::Number(val) => println!("The number is: {}", val), } } } fn main() { let num = NumberOrNothing::new(10); NumberOrNothing::print(num); NumberOrNothing::print(num); }
On constate que la compilation échoue avec un message intéressant
Compiling playground v0.0.1 (/playground)
error[E0382]: use of moved value: `num`
--> src/main.rs:19:28
|
17 | let num = NumberOrNothing::new(10);
| --- move occurs because `num` has type `NumberOrNothing`, which does not implement the `Copy` trait
18 | NumberOrNothing::print(num);
| --------------------------- `num` moved due to this method call
19 | NumberOrNothing::print(num);
| ^^^ value used here after move
|
note: consider changing this parameter type in method `print` to borrow instead if owning the value isn't necessary
--> src/main.rs:9:14
|
9 | fn print(self) {
| ----- ^^^^ this parameter takes ownership of the value
| |
| in this method
note: `NumberOrNothing::print` takes ownership of the receiver `self`, which moves `num`
--> src/main.rs:9:14
|
9 | fn print(self) {
| ^^^^
For more information about this error, try `rustc --explain E0382`.
En fait, cela signifie que la fonction est devenue propriétaire de la valeur de num
lors du premier appel à print()
, puis lorsque la fonction s'est terminée, la variable et les données qu'elle contient a été détruite automatiquement. Ainsi la variable num
ne contient "plus rien" (la variable n'est plus liée à la valeur Number(10)
) et ne peut pas rappeler print()
. En fait, il s'agit du comportement par défaut des fonctions en Rust. Elles deviennent propriétaires (owners) des
valeurs passées en argument et que la valeur est déplacée (moved).
Mais alors comment cela se fait que les fonctions
print_tab(tab);
let min = find_min(tab);
ne donnent pas de message d'erreur à la compilation?
En fait, ici tab
est copié implicitement avant d'être passé à ces deux fonctions. Ainsi, c'est cette valeur copiée qui est déplacée dans la fonction et qui est détruite à la fin.
Cela est le cas pour des types "simples" comme tous les types de base de Rust et les tableaux statiques si le type des éléments est également un type "copiable" (on dit qu'ils sont Copy
). Ainsi, les types i32
, usize
et [N; i32]
utilisés dans ce programme sont Copy
. On verra dans les chapitres suivants plus de détails sur ce fonctionnement.
Le pattern matching
L'autre nouveauté introduite dans ce code est le pattern matching.
match self {
NumberOrNothing::Nothing => println!("No number."),
NumberOrNothing::Number(val) => println!("The number is: {}", val),
}
Ici, on vérifie quelle variante du type est encapsulé par notre instance représentée par la variable self
. Dans le cas où
c'est NumberOrNothing::Nothing
, on affiche No number.
, et dans le cas où c'est
NumberOrNothing::Number(val)
on lie la valeur encapsulée dans la variante Number
et on affiche cette valeur. Le compilateur détectera s'il manque une variante
du type énuméré et produira une erreur si cela est le cas.
La syntaxe en général est a suivante
match variable {
variante_1 => {
expression
},
variante_2 => {
expression
},
// ...
variante_n => {
expression
},
_ => {
expression
},
}
où le dernier _
est un remplacement pour toutes les variantes pas traitées du
type énuméré (il est similaire au default
du switch ... case
de C).
enum NumberOrNothing { Nothing, Number(i32), } impl NumberOrNothing { fn new(val: i32) -> Self { NumberOrNothing::Number(val) } fn print(self) { match self { NumberOrNothing::Nothing => println!("No number."), NumberOrNothing::Number(val) => println!("The number is: {}", val), } } } const SIZE: usize = 9; fn read_command_line() -> [i32; SIZE] { [10, 32, 12, 43, 52, 53, 83, 2, 9] } fn print_tab(tab: [i32; SIZE]) { for t in tab { print!("{} ", t); } println!(); } fn min_i32(lhs: i32, rhs: i32) -> i32 { if lhs < rhs { lhs } else { rhs } } fn find_min(tab: [i32; SIZE]) -> NumberOrNothing { let mut min = NumberOrNothing::Nothing; for t in tab { match min { NumberOrNothing::Nothing => min = NumberOrNothing::new(t), NumberOrNothing::Number(val) => min = NumberOrNothing::new(min_i32(val, t)), } } min } fn main() { let tab = read_command_line(); println!("Among the numbers in the list:"); print_tab(tab); let min = find_min(tab); min.print(); let nothing = NumberOrNothing::Nothing; NumberOrNothing::print(nothing); }
Rustlings
Les rustlings à faire dans ce chapitre sont les suivants:
Les types énumérés
$ rustlings run enums1
$ rustlings run enums2
$ rustlings run enums3
La structures
$ rustlings run structs1
$ rustlings run structs2
$ rustlings run structs3
Discussion du code gen_types_composes
Dans cette partie nous discutons de ce code.
Concepts
Les concepts abordés dans cet exemple sont:
Discussion
La généricité et les traits
En Rust (comme dans beaucoup de langages) on a un moyen d'éviter de dupliquer du code en utilisant le concept de généricité. Il s'agit ici de remplacer un type par un caractère générique lors de la définition d'un type ou d'une fonction.
Jusqu'ici nous avions une structure NumberOrNothing
qui contenait soit Nothing
soit Number(i32)
qui encapsule un entier 32 bits. Afin d'éviter de devoir réécrire tout le code pour chaque
type (u64
, double
, ou n'importe quel autre type) on va réécrire notre type énuméré
en le renommant astucieusement SomethingOrNothing
(en effet, il se pourrait que nous ne voulions
plus uniquement trouver le plus petit nombre dans une liste, mais on pourrait vouloir
trouver le mot le plus "petit" dans l'ordre lexicographique). Ainsi on a
#![allow(unused)] fn main() { enum SomethingOrNothing<T> { Nothing, Something(T), } }
Il faut noter ici la présence du caractère générique T
, qui est déclaré comme générique
lors de la définition du type SomethingOrNothing<T>
, puis est utilisé à la place de i32
dans Something(T)
.
Maintenant qu'on a changé la définition de noter type, plus rien fonctionne et on doit adapter
le reste du code. Pour aller dans l'ordre, on doit modifier l'implémentation de la fonction SomethingOrNothing::new(val)
#![allow(unused)] fn main() { enum SomethingOrNothing<T> { Nothing, Something(T), } impl<T> SomethingOrNothing<T> { fn new(val: T) -> SomethingOrNothing<T> { SomethingOrNothing::Something(val) } } }
On voit ici qu'il faut annoter tout le bloc impl<T> SomethingOrNothing<T>
avec le type générique
afin qu'il puisse être réutilisé dans les fonctions statiques. En effet,
si on omet les <T>
on une erreur de compilation
#![allow(unused)] fn main() { enum SomethingOrNothing<T> { Nothing, Something(T), } impl SomethingOrNothing<T> { fn new(val: T) -> SomethingOrNothing<T> { SomethingOrNothing::Something(val) } } }
Afin d'illustrer une particularité de la généricité de Rust, nous avons également réécrit la fonction
print(val)
#![allow(unused)] fn main() { enum SomethingOrNothing<T> { Nothing, Something(T), } // Print function // We know the generic type T must be Displayable fn print<T: std::fmt::Display>(val: SomethingOrNothing<T>) { match val { SomethingOrNothing::Nothing => println!("Nothing."), SomethingOrNothing::Something(val) => println!("Something is: {}", val), } } }
On voit ici qu'il y a une annotation particulière dans l'entête de la fonction, T: std::fmt::Display
fn print<T: std::fmt::Display>(val: SomethingOrNothing<T>)
Cette syntaxe dit au compilateur que le type générique implémente une fonctionnalité
particulière, nommée trait, qui dit au programme comment afficher une variable du type
générique T
. On voit qu'on doit afficher la valeur val
encapsulée dans Something(val)
SomethingOrNothing::Something(val) => println!("Something is: {}", val),
Hors si on ne dit pas à notre programme comment faire cet affichage, il sera bien embêté. Ici,
nous devons donc préciser qu'il est nécessaire que T
implémente le trait Display
sinon le programme ne compilera pas (cliquez sur play pour le vérifier)
#![allow(unused)] fn main() { enum SomethingOrNothing<T> { Nothing, Something(T), } fn print<T>(val: SomethingOrNothing<T>) { match val { SomethingOrNothing::Nothing => println!("Nothing."), SomethingOrNothing::Something(val) => println!("Something is: {}", val), } } }
On retrouve la contrainte d'implémenter le trait Display
dans la fonction print_tab(tab)
.
Corriger le code ci-dessous pour qu'il compile
const SIZE: usize = 9; fn read_command_line() -> [i32; SIZE] { [10, 32, 12, 43, 52, 53, 83, 2, 9] } fn print_tab<T>(tab: [T; SIZE]) { for t in tab { print!("{} ", t); } println!(); } fn main() { print_tab(read_command_line()); }
Le trait Minimum
La fonctionnalité principale dont nous avons besoin pour que notre code fonctionne est de pouvoir trouver
le minimum d'une liste de SomethingOrNothing<T>
(voir l'appel à la fonction current_minimum.min(SomethingOrNothing::new(t))
).
fn find_min<T: Minimum>(tab: [T; SIZE]) -> SomethingOrNothing<T> {
let mut current_minimum = SomethingOrNothing::Nothing;
// Here is T is not Copyable tab is consumed and cannot be returned
for t in tab {
current_minimum = current_minimum.min(SomethingOrNothing::new(t));
}
current_minimum
}
Ainsi on doit annoter T
pour qu'il puisse calculer la plus petite valeur entre deux SomethingOrNothing<T>
. On va donc devoir écrire définir notre premier trait.
On définit un trait à l'aide de la syntaxe suivante
#![allow(unused)] fn main() { trait Minimum: Copy { fn min(self, rhs: Self) -> Self; } }
Ici le trait Minimum
sera implémenté sur un type qui implémente le trait Copy
(un trait qui
garantit qu'on sait comment copier une valeur). Notre trait n'a que la fonction min
(le nombre
de fonction dans un trait est arbitraire, il peut même être nul).
L'entête de la fonction nous dit que la fonction min
a deux argument, la variable sur laquelle
elle est appelée, et une variable du même type (annoté avec le type Self
, avec un "s"
majuscule, contrairement à self
qui fait référence à une variable) et retourne également une
valeur du même type.
Il est important de vous rappeler qu'ici on ne sait pas encore quel est le type sur lequel on implémente cette fonction.
L'implémentation de Minimum
pour SomethingOrNothing<T>
se fait comme suit
trait Minimum: Copy {
fn min(self, rhs: Self) -> Self;
}
impl<T: Minimum> Minimum for SomethingOrNothing<T> {
fn min(self, rhs: Self) -> Self {
match (self, rhs) {
(SomethingOrNothing::Nothing, SomethingOrNothing::Nothing) => {
SomethingOrNothing::Nothing
}
(SomethingOrNothing::Something(lhs), SomethingOrNothing::Something(rhs)) => {
SomethingOrNothing::new(lhs.min(rhs))
}
(SomethingOrNothing::Nothing, SomethingOrNothing::Something(rhs)) => {
SomethingOrNothing::new(rhs)
}
(SomethingOrNothing::Something(lhs), SomethingOrNothing::Nothing) => {
SomethingOrNothing::new(lhs)
}
}
}
}
Pour implémenter un trait sur un type on utilise la syntaxe
impl Trait for Type
puis implémenter toutes les fonctions se trouvant dans la définition du trait.
Ici, nous n'avons que la fonction min
à implémenter.
Le type SomethingOrNothing
est générique, donc il faut à nouveau notre code
pour en tenir compte. On voit que le type générique doit lui-même
implémenter le trait Minimum
pour que cette fonction compile: on voit l'utilisation
de la fonction min
SomethingOrNothing::new(lhs.min(rhs))
Nous verrons le détail de la syntaxe de cette fonction dans la section sur les tuples.
Comme nous utilisons des SomethingOrNothing<i32>
dans ce code, nous devons implémenter
le trait Minimum
pour des entiers. Ce qui est fait dans le bout de code suivant
#![allow(unused)] fn main() { trait Minimum: Copy { fn min(self, rhs: Self) -> Self; } impl Minimum for i32 { fn min(self, rhs: Self) -> Self { if self < rhs { self } else { rhs } } } }
Les tuples
On découvre ici, une syntaxe inconnue: les tuples. Un tuple est une collection d'un nombre arbitraire de valeurs dont les types peuvent être différents. Un tuple lui-même est une valeur dont le type est
(T1, T2, T3, ...)
où T1
, T2
, T3
, ... sont les types des membres du tuple. Un tuple peut être utilisé pour retourner plusieurs valeurs depuis une fonction par exemple.
Ainsi quand on fait du pattern mathching
match (self, rhs)
on va créer un tuple avec les valeurs de self
et de rhs
et vérifier les types de toutes les valeurs possibles pour ces types énumérées. On a donc 4 cas différents:
match (self, rhs) {
(SomethingOrNothing::Nothing, SomethingOrNothing::Nothing) => {
SomethingOrNothing::Nothing
}
(SomethingOrNothing::Something(lhs), SomethingOrNothing::Something(rhs)) => {
SomethingOrNothing::new(lhs.min(rhs))
}
(SomethingOrNothing::Nothing, SomethingOrNothing::Something(rhs)) => {
SomethingOrNothing::new(rhs)
}
(SomethingOrNothing::Something(lhs), SomethingOrNothing::Nothing) => {
SomethingOrNothing::new(lhs)
}
}
quand on a deux variantes Nothing
, on retourne Nothing
, quand on a une variante Nothing
et une valeur Something(val)
on retourne Something(val)
et finalement quand on a
Something(lhs)
et un Something(rhs)
, c'est Something(lhs.min(rhs))
qui est retourné (donc
la valeur la plus petite qui encapsulée). D'où la nécessité que T
implémente le trait Minimum
.
Il faut noter à nouveau qu'il n'y a pas de ;
dans les blocs des variantes du pattern matching et que les valeurs sont retournées (la structure de contrôle match
est une expression).
Les traits Clone
et Copy
En Rust, il existe deux traits essentiels Copy
et Clone
. Le trait Copy
permet de copier les instances d'un type bit à bit. La copie est une action implicite. Par exemple, dans le code ci-dessous
let y : i32 = 5;
let x = y;
la valeur de y
(ici 5
) est copiée dans une zone mémoire nouvellement allouée qui ensuite est liée à la variable x
.
Dans notre code nous décidons d'autoriser la copie de notre type énuméré en implémentant le trait Copy
/// In gen_types_composes we introduce genericity through traits and in particular, [Copy], /// [Clone], [std::fmt::Display] . enum SomethingOrNothing<T> { Nothing, Something(T), } impl<T> SomethingOrNothing<T> { fn new(val: T) -> SomethingOrNothing<T> { SomethingOrNothing::Something(val) } } // Print function // We know the generic type T must be Displayable fn print<T: std::fmt::Display>(val: SomethingOrNothing<T>) { match val { SomethingOrNothing::Nothing => println!("Nothing."), SomethingOrNothing::Something(val) => println!("Something is: {}", val), } } impl<T: Clone> Clone for SomethingOrNothing<T> { fn clone(&self) -> Self { match self { SomethingOrNothing::Nothing => SomethingOrNothing::Nothing, SomethingOrNothing::Something(val) => SomethingOrNothing::new(val.clone()), } } } impl<T: Copy> Copy for SomethingOrNothing<T> {} // If we remove Copy, we have a problem with the t in tab // in the computation of the minimum. trait Minimum: Copy { fn min(self, rhs: Self) -> Self; } impl<T: Minimum> Minimum for SomethingOrNothing<T> { fn min(self, rhs: Self) -> Self { match (self, rhs) { (SomethingOrNothing::Nothing, SomethingOrNothing::Nothing) => { SomethingOrNothing::Nothing } (SomethingOrNothing::Something(lhs), SomethingOrNothing::Something(rhs)) => { SomethingOrNothing::new(lhs.min(rhs)) } (SomethingOrNothing::Nothing, SomethingOrNothing::Something(rhs)) => { SomethingOrNothing::new(rhs) } (SomethingOrNothing::Something(lhs), SomethingOrNothing::Nothing) => { SomethingOrNothing::new(lhs) } } } } // i32 is Copyable as a very basic type as f32, f64, etc. // Arrays for example are not copyable. impl Minimum for i32 { fn min(self, rhs: Self) -> Self { if self < rhs { self } else { rhs } } } const SIZE: usize = 9; fn read_command_line() -> [i32; SIZE] { [10, 32, 12, 43, 52, 53, 83, 2, 9] } // Prints tab and returns tab. // Tab would be destructed at the end of the function otherwise. fn print_tab<T: std::fmt::Display>(tab: [T; SIZE]) { for t in tab { print!("{} ", t); } println!(); } fn find_min<T: Minimum>(tab: [T; SIZE]) -> SomethingOrNothing<T> { let mut current_minimum = SomethingOrNothing::Nothing; // Here is T is not Copyable tab is consumed and cannot be returned for t in tab { current_minimum = current_minimum.min(SomethingOrNothing::new(t)); } current_minimum } fn main() { let tab = read_command_line(); println!("Among the Somethings in the list:"); print_tab(tab); let min = find_min(tab); print(min); }
Comme on peut le voir ici, il n'y a pas de fonction à implémenter avec Copy
, ce trait permet uniquement d'effectuer une copie binaire des données.
Il est également important de noter qu'afin que notre type SomethingOrNothing<T>
implémente le trait Copy
, il est nécessaire que le type paramètrique T
implémente lui aussi Copy
. Ce qui veut dire plus simplement qu'un type complexe ne peut pas implémenter le trait Copy
si les types qu'il contient ne sont pas copiable.
On peut aussi remarquer qu'il est possible d'indiquer la nécessité que les types implémentant un trait soit copiables. Par exemple
/// In gen_types_composes we introduce genericity through traits and in particular, [Copy], /// [Clone], [std::fmt::Display] . enum SomethingOrNothing<T> { Nothing, Something(T), } impl<T> SomethingOrNothing<T> { fn new(val: T) -> SomethingOrNothing<T> { SomethingOrNothing::Something(val) } } // Print function // We know the generic type T must be Displayable fn print<T: std::fmt::Display>(val: SomethingOrNothing<T>) { match val { SomethingOrNothing::Nothing => println!("Nothing."), SomethingOrNothing::Something(val) => println!("Something is: {}", val), } } impl<T: Clone> Clone for SomethingOrNothing<T> { fn clone(&self) -> Self { match self { SomethingOrNothing::Nothing => SomethingOrNothing::Nothing, SomethingOrNothing::Something(val) => SomethingOrNothing::new(val.clone()), } } } impl<T: Copy> Copy for SomethingOrNothing<T> {} // If we remove Copy, we have a problem with the t in tab // in the computation of the minimum. trait Minimum: Copy { fn min(self, rhs: Self) -> Self; } impl<T: Minimum> Minimum for SomethingOrNothing<T> { fn min(self, rhs: Self) -> Self { match (self, rhs) { (SomethingOrNothing::Nothing, SomethingOrNothing::Nothing) => { SomethingOrNothing::Nothing } (SomethingOrNothing::Something(lhs), SomethingOrNothing::Something(rhs)) => { SomethingOrNothing::new(lhs.min(rhs)) } (SomethingOrNothing::Nothing, SomethingOrNothing::Something(rhs)) => { SomethingOrNothing::new(rhs) } (SomethingOrNothing::Something(lhs), SomethingOrNothing::Nothing) => { SomethingOrNothing::new(lhs) } } } } // i32 is Copyable as a very basic type as f32, f64, etc. // Arrays for example are not copyable. impl Minimum for i32 { fn min(self, rhs: Self) -> Self { if self < rhs { self } else { rhs } } } const SIZE: usize = 9; fn read_command_line() -> [i32; SIZE] { [10, 32, 12, 43, 52, 53, 83, 2, 9] } // Prints tab and returns tab. // Tab would be destructed at the end of the function otherwise. fn print_tab<T: std::fmt::Display>(tab: [T; SIZE]) { for t in tab { print!("{} ", t); } println!(); } fn find_min<T: Minimum>(tab: [T; SIZE]) -> SomethingOrNothing<T> { let mut current_minimum = SomethingOrNothing::Nothing; // Here is T is not Copyable tab is consumed and cannot be returned for t in tab { current_minimum = current_minimum.min(SomethingOrNothing::new(t)); } current_minimum } fn main() { let tab = read_command_line(); println!("Among the Somethings in the list:"); print_tab(tab); let min = find_min(tab); print(min); }
où la notation trait Minimum: Copy
spécifie que Copy
doit être implémenté quand on implémente Minimum
.
Ici tous les types qui implémentent le trait Minimum
doivent également implémenter Copy
. C'est le cas par exemple du type i32
/// In gen_types_composes we introduce genericity through traits and in particular, [Copy], /// [Clone], [std::fmt::Display] . enum SomethingOrNothing<T> { Nothing, Something(T), } impl<T> SomethingOrNothing<T> { fn new(val: T) -> SomethingOrNothing<T> { SomethingOrNothing::Something(val) } } // Print function // We know the generic type T must be Displayable fn print<T: std::fmt::Display>(val: SomethingOrNothing<T>) { match val { SomethingOrNothing::Nothing => println!("Nothing."), SomethingOrNothing::Something(val) => println!("Something is: {}", val), } } impl<T: Clone> Clone for SomethingOrNothing<T> { fn clone(&self) -> Self { match self { SomethingOrNothing::Nothing => SomethingOrNothing::Nothing, SomethingOrNothing::Something(val) => SomethingOrNothing::new(val.clone()), } } } impl<T: Copy> Copy for SomethingOrNothing<T> {} // If we remove Copy, we have a problem with the t in tab // in the computation of the minimum. trait Minimum: Copy { fn min(self, rhs: Self) -> Self; } impl<T: Minimum> Minimum for SomethingOrNothing<T> { fn min(self, rhs: Self) -> Self { match (self, rhs) { (SomethingOrNothing::Nothing, SomethingOrNothing::Nothing) => { SomethingOrNothing::Nothing } (SomethingOrNothing::Something(lhs), SomethingOrNothing::Something(rhs)) => { SomethingOrNothing::new(lhs.min(rhs)) } (SomethingOrNothing::Nothing, SomethingOrNothing::Something(rhs)) => { SomethingOrNothing::new(rhs) } (SomethingOrNothing::Something(lhs), SomethingOrNothing::Nothing) => { SomethingOrNothing::new(lhs) } } } } // i32 is Copyable as a very basic type as f32, f64, etc. // Arrays for example are not copyable. impl Minimum for i32 { fn min(self, rhs: Self) -> Self { if self < rhs { self } else { rhs } } } const SIZE: usize = 9; fn read_command_line() -> [i32; SIZE] { [10, 32, 12, 43, 52, 53, 83, 2, 9] } // Prints tab and returns tab. // Tab would be destructed at the end of the function otherwise. fn print_tab<T: std::fmt::Display>(tab: [T; SIZE]) { for t in tab { print!("{} ", t); } println!(); } fn find_min<T: Minimum>(tab: [T; SIZE]) -> SomethingOrNothing<T> { let mut current_minimum = SomethingOrNothing::Nothing; // Here is T is not Copyable tab is consumed and cannot be returned for t in tab { current_minimum = current_minimum.min(SomethingOrNothing::new(t)); } current_minimum } fn main() { let tab = read_command_line(); println!("Among the Somethings in the list:"); print_tab(tab); let min = find_min(tab); print(min); }
Le deuxième trait que nous retrouvons dans ce code est le trait Clone
. Clone
est un supertrait de Copy
, ce qui signifie qu'un type qui implémente Copy
doit nécessairement implémenter Clone
.
Le trait Clone
permet de dupliquer explicitement une instance. En effet, pour cloner une instance, il faut appeler la méthode clone()
explicitement
/// In gen_types_composes we introduce genericity through traits and in particular, [Copy], /// [Clone], [std::fmt::Display] . enum SomethingOrNothing<T> { Nothing, Something(T), } impl<T> SomethingOrNothing<T> { fn new(val: T) -> SomethingOrNothing<T> { SomethingOrNothing::Something(val) } } // Print function // We know the generic type T must be Displayable fn print<T: std::fmt::Display>(val: SomethingOrNothing<T>) { match val { SomethingOrNothing::Nothing => println!("Nothing."), SomethingOrNothing::Something(val) => println!("Something is: {}", val), } } impl<T: Clone> Clone for SomethingOrNothing<T> { fn clone(&self) -> Self { match self { SomethingOrNothing::Nothing => SomethingOrNothing::Nothing, SomethingOrNothing::Something(val) => SomethingOrNothing::new(val.clone()), } } } impl<T: Copy> Copy for SomethingOrNothing<T> {} // If we remove Copy, we have a problem with the t in tab // in the computation of the minimum. trait Minimum: Copy { fn min(self, rhs: Self) -> Self; } impl<T: Minimum> Minimum for SomethingOrNothing<T> { fn min(self, rhs: Self) -> Self { match (self, rhs) { (SomethingOrNothing::Nothing, SomethingOrNothing::Nothing) => { SomethingOrNothing::Nothing } (SomethingOrNothing::Something(lhs), SomethingOrNothing::Something(rhs)) => { SomethingOrNothing::new(lhs.min(rhs)) } (SomethingOrNothing::Nothing, SomethingOrNothing::Something(rhs)) => { SomethingOrNothing::new(rhs) } (SomethingOrNothing::Something(lhs), SomethingOrNothing::Nothing) => { SomethingOrNothing::new(lhs) } } } } // i32 is Copyable as a very basic type as f32, f64, etc. // Arrays for example are not copyable. impl Minimum for i32 { fn min(self, rhs: Self) -> Self { if self < rhs { self } else { rhs } } } const SIZE: usize = 9; fn read_command_line() -> [i32; SIZE] { [10, 32, 12, 43, 52, 53, 83, 2, 9] } // Prints tab and returns tab. // Tab would be destructed at the end of the function otherwise. fn print_tab<T: std::fmt::Display>(tab: [T; SIZE]) { for t in tab { print!("{} ", t); } println!(); } fn find_min<T: Minimum>(tab: [T; SIZE]) -> SomethingOrNothing<T> { let mut current_minimum = SomethingOrNothing::Nothing; // Here is T is not Copyable tab is consumed and cannot be returned for t in tab { current_minimum = current_minimum.min(SomethingOrNothing::new(t)); } current_minimum } fn main() { let tab = read_command_line(); println!("Among the Somethings in the list:"); print_tab(tab); let min = find_min(tab); print(min); }
Comme on peut le voir dans le code ci-dessus, il est possible de définir un comportement arbitraire en redéfinissant la méthode clone()
.
En effet, le trait Clone
nous permet de définir librement le comportement attendu lors d'un clonage, ce qui n'est pas le cas avec Copy
.
Cette liberté a un coût, puisque que l'on peut écrire notre propre fonction de clonnage, ce dernier peut facilement devenir beaucoup plus coûteux que la simple copie binaire des données.
Le langage Rust offre un attribut afin de pouvoir implémenter simplement les traits Copy
et Clone
. Il s'agit de #[derive(...)]
.
Par exemple avec le code suivant
#![allow(unused)] fn main() { #[derive(Clone,Copy)] struct MyStruct; }
l'annotation permet d'indiquer au compilateur que notre struct MyStruct
nécessite une implémentation par défaut des traits Copy
et Clone
.
Ce qui est équivaut à écrire
#![allow(unused)] fn main() { struct MyStruct; impl Copy for MyStruct { } impl Clone for MyStruct { fn clone(&self) -> MyStruct { *self } } }
Le code
/// In gen_types_composes we introduce genericity through traits and in particular, [Copy], /// [Clone], [std::fmt::Display] . enum SomethingOrNothing<T> { Nothing, Something(T), } impl<T> SomethingOrNothing<T> { fn new(val: T) -> SomethingOrNothing<T> { SomethingOrNothing::Something(val) } } // Print function // We know the generic type T must be Displayable fn print<T: std::fmt::Display>(val: SomethingOrNothing<T>) { match val { SomethingOrNothing::Nothing => println!("Nothing."), SomethingOrNothing::Something(val) => println!("Something is: {}", val), } } impl<T: Clone> Clone for SomethingOrNothing<T> { fn clone(&self) -> Self { match self { SomethingOrNothing::Nothing => SomethingOrNothing::Nothing, SomethingOrNothing::Something(val) => SomethingOrNothing::new(val.clone()), } } } impl<T: Copy> Copy for SomethingOrNothing<T> {} // If we remove Copy, we have a problem with the t in tab // in the computation of the minimum. trait Minimum: Copy { fn min(self, rhs: Self) -> Self; } impl<T: Minimum> Minimum for SomethingOrNothing<T> { fn min(self, rhs: Self) -> Self { match (self, rhs) { (SomethingOrNothing::Nothing, SomethingOrNothing::Nothing) => { SomethingOrNothing::Nothing } (SomethingOrNothing::Something(lhs), SomethingOrNothing::Something(rhs)) => { SomethingOrNothing::new(lhs.min(rhs)) } (SomethingOrNothing::Nothing, SomethingOrNothing::Something(rhs)) => { SomethingOrNothing::new(rhs) } (SomethingOrNothing::Something(lhs), SomethingOrNothing::Nothing) => { SomethingOrNothing::new(lhs) } } } } // i32 is Copyable as a very basic type as f32, f64, etc. // Arrays for example are not copyable. impl Minimum for i32 { fn min(self, rhs: Self) -> Self { if self < rhs { self } else { rhs } } } const SIZE: usize = 9; fn read_command_line() -> [i32; SIZE] { [10, 32, 12, 43, 52, 53, 83, 2, 9] } // Prints tab and returns tab. // Tab would be destructed at the end of the function otherwise. fn print_tab<T: std::fmt::Display>(tab: [T; SIZE]) { for t in tab { print!("{} ", t); } println!(); } fn find_min<T: Minimum>(tab: [T; SIZE]) -> SomethingOrNothing<T> { let mut current_minimum = SomethingOrNothing::Nothing; // Here is T is not Copyable tab is consumed and cannot be returned for t in tab { current_minimum = current_minimum.min(SomethingOrNothing::new(t)); } current_minimum } fn main() { let tab = read_command_line(); println!("Among the Somethings in the list:"); print_tab(tab); let min = find_min(tab); print(min); }
Rustlings
Les rustlings à faire dans ce chapitre sont les suivants:
Les traits
$ rustlings run traits1
$ rustlings run traits2
$ rustlings run traits3
$ rustlings run traits4
$ rustlings run traits5
Les tuples
$ rustlings run primitive_types5
$ rustlings run primitive_types6
Discussion du code propriete
Dans cette partie nous discutons de ce code.
Concepts
Les concepts abordés dans cet exemple sont:
Discussion
Bind et move
On l'a déjà brièvement mentionné dans la partie 2 l'instruction
#![allow(unused)] fn main() { let a = 2; }
fait plusieurs choses:
- Elle alloue l'espace pour un entier 32 bits.
- Initialise la mémoire avec la valeur
2
. - Lie (bind en anglais) la variable (l'identifiant)
a
avec cet espace mémoire.
Quand on sort de la portée (scope en anglais) la variable est plus accessible et la mémoire est automatiquement libérée (l'espace mémoire
où est contenue la valeur 2
est libéré automatiquement). Cela se passe aussi en C, sauf quand on a fait un malloc()
explicite.
Si on écrit le code un peu plus compliqué
enum SomethingOrNothing<T> { Nothing, Something(T), } impl<T> SomethingOrNothing<T> { fn new(val: T) -> SomethingOrNothing<T> { SomethingOrNothing::Something(val) } } fn main() { let a = SomethingOrNothing::new(1); let _b = a; let _c = a; }
Ici ce code ne compile pas, car nous ne pouvons lier une valeur qu'à une seule variable par défaut. On dit que les données ne peuvent avoir qu'un seul propriétaire (owner en anglais). Ainsi, quand on fait
let a = SomethingOrNothing::new(1);
let _b = a;
on donne la propriété de la valeur SomethingOrNothing::new(1)
à la variable a
. Puis, quand
on transfère la propriété de la valeur
SomethingOrNothing::new(1)
à la variable _b
. Ainsi, a
n'est plus lié à SomethingOrNothing::new(1)
et ne peut plus rien transférer
comme données. Donc quand on fait
let _c = a;
le compilateur refuse de nous laisser faire, car les données liées à a
ont déjà été déplacées (moved en anglais) dans _b
.
Pour certains types, comme les i32
(ou tous les types qui implémentent le trait Copy
)
une copie implicite est faite, et on ne déplace ainsi pas la propriété de la valeur mais on en crée
une nouvelle qui est liée aux variables.
Cette règle est très importante et a des implications très fortes en Rust (c'est aussi le cas dans tous les langages mais ça passe un peu plus inaperçu, car le compilateur ne se plaint pas). On dit que Rust empêche l'aliasing. On retrouve cette difficulté quand on passe un argument à une fonction. Si le type de la variable passé en argument n'est pas copiable, la propriété de la valeur est passée à l'argument de la fonction. Ainsi au moment de quitter la fonction la valeur est détruite et la mémoire liée à la valeur libérée comme dans le code suivant
enum SomethingOrNothing<T> { Nothing, Something(T), } impl<T> SomethingOrNothing<T> { fn new(val: T) -> SomethingOrNothing<T> { SomethingOrNothing::Something(val) } } fn print<T: std::fmt::Display>(val: SomethingOrNothing<T>) { match val { SomethingOrNothing::Nothing => println!("Nothing."), SomethingOrNothing::Something(val) => println!("Something is: {}", val), } } fn main() { let a = SomethingOrNothing::new(1); print(a); print(a); }
Ici lors du premier appel à print(a)
la propriété de la valeur SomethingOrNothing::new(1)
est transférée à l'argument val
de la fonction
print(val)
. Lorsqu'on sort de la portée de la fonction la variable val
est détruite et toutes les données qui vont avec.
Comme la variable a
n'est plus propriétaire d'aucune donnée, le second appel à print(a)
échoue à la compilation.
Une solution est d'implémenter le trait Copy
pour SomethingOrNothing<T>
, mais bien que cela puisse être acceptable
pour un petit type comme celui-ci, en général le coût de performance serait énorme on devait répéter des copies
qui prennent beaucoup de place en mémoire.
Mais alors comment faire pour afficher un SomethingOrNothing<T>
sans implémenter le trait Copy
?
Ownership et borrowing
Le Rust nous offre un moyen d'emprunter (borrow) les variables pour éviter d'avoir à faire des copies dans tous les sens ou de devoir toujours retourner les variables passées en argument.
enum SomethingOrNothing<T> { Nothing, Something(T), } impl<T> SomethingOrNothing<T> { fn new(val: T) -> SomethingOrNothing<T> { SomethingOrNothing::Something(val) } } fn print<T: std::fmt::Display>(val: &SomethingOrNothing<T>) { match val { SomethingOrNothing::Nothing => println!("Nothing."), SomethingOrNothing::Something(val) => println!("Something is: {}", val), } } fn main() { let a = SomethingOrNothing::new(1); print(&a); print(&a); }
Dans ce code on voit que la fonction print
a la signature suivante
fn print<T: std::fmt::Display>(val: &SomethingOrNothing<T>)
et le type de val
est une référence vers un SomethingOrNothing<T>
(il faut noter le &
avant le type). La référence en Rust est un moyen d'emprunter
la propriété d'une valeur. Ainsi quand on va sortir de la portée de la fonction, la variable val
, qui est une référence
va être détruite, mais les données qui sont liées à cette référence ne le seront pas. C'est pour cela que contrairement à
ce qui se passe dans la section Bind et move on peut appeler deux fois la fonction print(&val)
.
Il faut bien noter que la référence a la syntaxe &<variable>
comme en C.
Exercice
Corrigez ce code pour qu'il compile (sans enlever de lignes évidemment)
enum SomethingOrNothing<T> { Nothing, Something(T), } impl<T> SomethingOrNothing<T> { fn new(val: T) -> SomethingOrNothing<T> { SomethingOrNothing::Something(val) } } fn main() { let a = SomethingOrNothing::new(1); { let b = a; // imagine that some thing may happen here with b } let c = a; }
On constate dans notre code qu'il y a assez peu de modifications pour éviter les copies
implicites et passer par des emprunts. Pour résumer, on a changé la signature de trois fonctions:
print()
, print_tab()
, et find_min()
.
La fonction print()
a déjà été discutée plus haut, et nécessite simplement
l'ajout d'une référence vers un SomethingOrNothing<T>
pour que la valeur soit
prêtée à val
dans la fonction
fn print<T: std::fmt::Display>(val: &SomethingOrNothing<T>) {
match val {
SomethingOrNothing::Nothing => println!("Nothing."),
SomethingOrNothing::Something(val) => println!("Something is: {}", val),
}
}
Ainsi, les valeurs de SomethingOrNothing<T>
ne sont pas détruites à l'appel de print()
.
La fonction print_tab()
, de façon similaire, nécessite le passage de la référence
vers le tableau statique &[T; SIZE]
fn print_tab<T: std::fmt::Display>(tab: &[T; SIZE]) {
for t in tab {
print!("{} ", t);
}
println!();
}
La boucle for nécessite une discussion plus approfondie. En effet,
dans cette fonction tab
est une référence vers un tableau. Ainsi,
on ne peut pas itérer sur ses éléments de façon standard. En effet,
cela nécessiterait de "move" ou transférer la propriété de la valeur
sur laquelle on itère.
Le compilateur de Rust ne l'autoriserait jamais. Le langage nous facilite donc la vie en itérant sur
les références des valeurs contenues dans le tableau (dont le type est générique, rappelons le).
Donc si on annotait explicitement le type de t
dans la boucle for
on aurait:
for t: &T in tab
On constate aussi que le langage devine qu'on ne veut pas afficher la valeur de la référence (son adresse mémoire)
mais affiche directement la valeur elle même quand on appelle la macro print!()
print!("{} ", t);
Finalement, nous avons également modifié la fonction find_min()
.
fn find_min<T: Minimum>(tab: &[T; SIZE]) -> SomethingOrNothing<T> {
let mut current_minimum = SomethingOrNothing::Nothing;
// Here is T is not Copyable tab is consumed and cannot be returned
for t in tab {
current_minimum = current_minimum.min(SomethingOrNothing::new(*t));
}
current_minimum
}
Outre l'argument qui est une référence à un tableau statique comme dans les deux autres fonctions
discutées précédemment, on a une nouvelle syntaxe dans la boucle for
for t in tab {
current_minimum = current_minimum.min(SomethingOrNothing::new(*t));
}
On constante qu'on doit déréférencer t
(appeler *t
), car t
est une référence vers T
(il est de type &T
), car la fonction new()
s'attend à un T
et non à un &T
.
L'opérateur *<variable>
est l'opérateur de déréférencement et cette syntaxe est la même que celle du C.
Si par malheur on oubliait le *t
on aurait une erreur de compilation, car
le vérificateur de type du Rust ne laisse pas passer ce genre de choses.
Le code
/*! propriete illustrates the concepts of **Ownership** and **Borrowing**. It also presents the manual implementation of [Clone] and [Copy]. */ enum SomethingOrNothing<T> { Nothing, Something(T), } impl<T> SomethingOrNothing<T> { fn new(val: T) -> SomethingOrNothing<T> { SomethingOrNothing::Something(val) } } fn print<T: std::fmt::Display>(val: &SomethingOrNothing<T>) { match val { SomethingOrNothing::Nothing => println!("Nothing."), SomethingOrNothing::Something(val) => println!("Something is: {}", val), } } // Manual implementation of [Clone] impl<T: Clone> Clone for SomethingOrNothing<T> { fn clone(&self) -> Self { match self { SomethingOrNothing::Nothing => SomethingOrNothing::Nothing, SomethingOrNothing::Something(val) => SomethingOrNothing::Something(val.clone()), } } } // Manual implementation of [Copy] impl<T: Copy> Copy for SomethingOrNothing<T> {} // If we remove Copy, we have a problem with the t in tab // in the computation of the minimum. trait Minimum: Copy { fn min(self, rhs: Self) -> Self; } impl<T: Minimum> Minimum for SomethingOrNothing<T> { fn min(self, rhs: Self) -> Self { match (self, rhs) { (SomethingOrNothing::Nothing, SomethingOrNothing::Nothing) => { SomethingOrNothing::Nothing } (SomethingOrNothing::Something(lhs), SomethingOrNothing::Something(rhs)) => { SomethingOrNothing::Something(lhs.min(rhs)) } (SomethingOrNothing::Nothing, SomethingOrNothing::Something(rhs)) => { SomethingOrNothing::Something(rhs) } (SomethingOrNothing::Something(lhs), SomethingOrNothing::Nothing) => { SomethingOrNothing::Something(lhs) } } } } impl Minimum for i32 { fn min(self, rhs: Self) -> Self { if self < rhs { self } else { rhs } } } const SIZE: usize = 9; // Poorly emulates the parsing of a command line. fn read_command_line() -> [i32; SIZE] { [10, 32, 12, 43, 52, 53, 83, 2, 9] } // Prints all the elements of the `tab`. // Tab is borrowed here fn print_tab<T: std::fmt::Display>(tab: &[T; SIZE]) { for t in tab { print!("{} ", t); } println!(); } // Computes the minimum of a borrowed Array of a type T which implements the [Minimum] trait. // Returns a [SomethingOrNothing::Something] containing the the minimum value // or [SomethingOrNothing::Nothing] if no minimum value was found. fn find_min<T: Minimum>(tab: &[T; SIZE]) -> SomethingOrNothing<T> { let mut current_minimum = SomethingOrNothing::Nothing; // Here is T is not Copyable tab is consumed and cannot be returned for t in tab { current_minimum = current_minimum.min(SomethingOrNothing::new(*t)); } current_minimum } fn main() { let tab = read_command_line(); println!("Among the Somethings in the list:"); print_tab(&tab); let min = find_min(&tab); print(&min); }
Rustlings
Les rustlings à faire dans ce chapitre sont les suivants:
La move semantic
$ rustlings run move_semantics1
$ rustlings run move_semantics2
$ rustlings run move_semantics3
$ rustlings run move_semantics4
$ rustlings run move_semantics5
$ rustlings run move_semantics6
Discussion du code modules_visibilite
Dans cette partie nous discutons de ce code.
Concepts
Les concepts abordés dans cet exemple sont:
Discussion
Jusqu'ici, tout le code était contenu dans le fichier main.rs
. Au fur et à mesure que le code devient plus complexe et long, il est nécessaire de le séparer en plusieurs fichiers (modules) et de gérer
la visibilité des structures, types énumérés, fonctions, etc. Nous allons voir dans ce chapitre comment
cela se passe pour le Rust. Pour plus d'informations vous pouvez vous référer à La Bible du Rust.
Les modules
Afin de séparer le code en plusieurs fichiers il est nécessaire de créer un fichier lib.rs
qui se trouve dans le même répertoire que le fichier main.rs
. Ce répertoire est en général le répertoire src
de votre projet. Ici c'est dans projet05/src
. Dans notre cas, il contient très peu de lignes
/*!
modules_visibilite illustrates the concepts of **modules** and **visibility**.
*/
// The size of the tab
const SIZE: usize = 9;
pub mod io;
mod minimum;
pub mod something_or_nothing;
La présence d'un fichier lib.rs
indique que vous avez créé une librairie, appelée crate
en Rust.
Toutes les libraries publiées en Rust sont des crate
et peuvent se télécharger depuis le site
crates.io.
On voit qu'il y a dans le fichier lib.rs
la définition de la constante SIZE
, ainsi que trois lignes
contenant le mot-clé mod
qui indique la présence d'un module. Par défaut, le compilateur Rust va aller chercher
le contenu de ces modules dans les fichiers io.rs
, minimum.rs
, et something_or_nothing.rs
(ou io/mod.rs
, minimum/mod.rs
, et something_or_nothing/mod.rs
). Dans ce chapitre, nous avons simplement réparti tout le code qui se trouvait dans main.rs
dans la partie 4. Le mot-clé
pub
indique la visibilité du module à l'intérieur de votre librairie.
Ainsi, le module minimum
n'est pas exposé à vos utilisatrices et utilisateurs, alors que io
et something_or_nothing
le sont. Nous verrons un peu plus bas les règles sur la visibilité.
Afin d'utiliser les fonctions définies dans notre librairie dans notre programme principal (le main.rs
)
comme dans le code suivant
use modules_visibilite::io;
use modules_visibilite::something_or_nothing::find_min;
Pour importer les modules avec la syntaxe suivante
use nom_de_la_crate::nom_du_module;
où le nom_de_la_crate
est défini dans le fichier modules_visibilite/Cargo.toml
(le champs name
),
le nom du module ici est io
et chaque module est séparé par le symbole ::
.
On a également importé la fonction find_min
spécifiquement
avec la syntaxe
use nom_de_la_crate::nom_du_module::nom_de_la_fonction;
Ce n'est pas fait dans cet exemple, mais il est tout à fait possible de définir des sous-modules (voir Le Livre).
Afin d'utiliser de partager des fonctions entre les modules, il faut également les importer comme dans le module something_or_nothing
qui nécessite l'import du trait Minimum
à l'aide de la syntaxe
use crate::minimum::Minimum;
On voit la nécessité d'utiliser le mot-clé crate
pour indiquer que le module est importé depuis
l'intérieur de notre librairie. Puis il faut suivre l'arborescence habituelle avec le module minimum
et le trait Minimum
le tout séparé par des séparateurs, ::
.
Observations
- Observez ce qui se passe si vous commentez la ligne
mod minimum;
danslib.rs
et tentez de compiler le code. Que vous dit le compilateur? - Que se passe-t-il, si vous enlevez le mot clé
pub
de la lignepub mod io;
danslib.rs
et tentez de compiler le code? Quel message s'affiche?
On constate que pour la partie 1
, le compilateur n'est pas content, car il ne trouve pas
le module minimum
dans notre crate
error[E0432]: unresolved import `crate::minimum`
--> src/something_or_nothing.rs:2:12
|
2 | use crate::minimum::Minimum;
| ^^^^^^^ could not find `minimum` in the crate root
For more information about this error, try `rustc --explain E0432`.
error: could not compile `modules_visibilite` (lib) due to previous error
Pour la partie 2
, on a deux messages un peu différents
Compiling modules_visibilite v0.1.0 (/home/orestis/git/projects/rust-101/codes/rust_lang/modules_visibilite)
warning: function `read_command_line` is never used
--> src/io.rs:2:8
|
2 | pub fn read_command_line() -> [i32; crate::SIZE] {
| ^^^^^^^^^^^^^^^^^
|
= note: `#[warn(dead_code)]` on by default
warning: function `print_tab` is never used
--> src/io.rs:8:8
|
8 | pub fn print_tab(tab: &[i32; crate::SIZE]) {
| ^^^^^^^^^
warning: `modules_visibilite` (lib) generated 2 warnings
error[E0603]: module `io` is private
--> src/main.rs:2:13
|
2 | use modules_visibilite::io;
| ^^ private module
|
note: the module `io` is defined here
--> /home/orestis/git/projects/rust-101/codes/rust_lang/modules_visibilite/src/lib.rs:9:1
|
9 | mod io;
| ^^^^^^
For more information about this error, try `rustc --explain E0603`.
error: could not compile `modules_visibilite` (bin "modules_visibilite") due to previous error
Le compilateur commence par nous prévenir par des warnings que les fonctions print_tab
et read_command_line()
ne sont jamais utilisées. Puis, nous avons un message nous prévenant que io
est privé.
Visibilité
Par défaut, Rust rend privées toutes les structures (et ses membres ou fonctions statiques), traits, fonctions, etc. Cela signifie qu'elles ne sont pas visibles en dehors du
module dans lequel elles sont définies. Pour les rendre visibles (en dehors du module dans lequel elles sont définies), il faut les rendre publiques à l'aide du préfixe pub
.
Il y a plusieurs exemples de l'utilisation dans ce chapitre.
- Pour les fonctions:
pub fn print_tab(tab: &[i32; crate::SIZE]) {
for t in tab {
print!("{} ", t);
}
println!();
}
on voit qu'on préfixe pub
devant le mot-clé fn
pour rendre la fonction publique. Si on le retire, le compilateur donnera le message d'erreur suivant
error[E0603]: function `print_tab` is private
--> src/main.rs:11:9
|
11 | io::print_tab(&tab);
| ^^^^^^^^^ private function
|
note: the function `print_tab` is defined here
--> /home/orestis/git/projects/rust-101/codes/rust_lang/modules_visibilite/src/io.rs:9:1
|
9 | fn print_tab(tab: &[i32; crate::SIZE]) {
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
For more information about this error, try `rustc --explain E0603`.
- Pour un type énuméré:
#[derive(Clone, Copy)]
pub enum SomethingOrNothing<T> {
Nothing,
Something(T),
}
où on préfixe le mot clé enum
avec un pub
.
- Pour les méthodes
impl<T: std::fmt::Display> SomethingOrNothing<T> {
pub fn print(&self) {
match self {
SomethingOrNothing::Nothing => println!("Nothing."),
SomethingOrNothing::Something(val) => println!("Something is: {}", val),
}
}
}
où comme pour les fonctions, on préfixe fn
avec un pub
.
- Pour les traits:
pub trait Minimum: Copy {
fn min(self, rhs: Self) -> Self;
}
il faut noter que seule la définition du trait a besoin d'être publique. L'implémentation pour un type particulier n'a pas besoin de l'être.
Remarque
Tous les champs d'une structure (même publique) sont privés par défaut. Ainsi une structure Point
contenant les coordonnées d'un points en deux dimension se définit comme
#![allow(unused)] fn main() { pub struct Point { x: f32, y: f32, } }
En dehors du module où cette structure serait définie, il est impossible d'accéder aux champs de la structure.
L'instantiation et initialisation d'un Point
let p = Point { x: 1.0, y: 0.2 };
donnerait une erreur de compilation, car x
et y
sont privés. Il est donc nécessaire de préfixer les champs publics par un pub
pour qu'ils soient accessible en dehors du module.
error[E0451]: field `x` of struct `Point` is private
--> src/main.rs:15:43
|
15 | let p = something_or_nothing::Point { x: 1.0, y: 0.5 };
| ^^^^^^ private field
error[E0451]: field `y` of struct `Point` is private
--> src/main.rs:15:51
|
15 | let p = something_or_nothing::Point { x: 1.0, y: 0.5 };
| ^^^^^^ private field
For more information about this error, try `rustc --explain E0451`.
On aurait pour rendre x
et y
publics besoin de définir la structure comme
#![allow(unused)] fn main() { struct Point { pub x: f32, pub y: f32, } }
Rustlings
Les rustlings à faire dans ce chapitre sont les suivants:
Les modules
$ rustlings run modules1
$ rustlings run modules2
$ rustlings run modules3
Discussion du code tooling
Concepts
Les concepts abordés dans cet exemple sont:
Discussion
Le Rust étant un langage moderne, il vient avec tout un tas de features qui sont très appréciables pour écrire du code robuste, propre et réutilisable. On va voir quelle est la syntaxe nécessaire pour écrire documentation et tests.
La documentation
Il y a différents moyens de documenter un code. Pour un guide bien plus complet que ce qui est résumé ici, vous pouvez vous référer à ce site.
Les commentaires
Le plus simple est d'y ajouter des commentaires. En Rust, tous caractères qui suivent des //
sur la même ligne sont considérés comme un commentaire (ignorés par le compilateur)
#![allow(unused)] fn main() { // Ceci est un commentaire // Et ceci un autre let a = 2; // Ici on assigne 2 à a }
On peut écrire également des commentaires sur plusieurs lignes sans avoir à mettre des //
sur chacune. Pour ce faire on utilise la syntaxe /* ... */
#![allow(unused)] fn main() { /* Ceci est un commentaire Et ceci un autre Et en voici un dernier */ let a = 2; /* Ici on assigne 2 à a */ }
Ce type de documentation se prête très bien à des commentaires sur les détails du code, mais n'est pas très adapté à écrire une documentation plus générale sur le comportement du code. Ainsi, on a autre type de commentaires, qui seront utilisés pour générer automatiquement de la documentation à l'aide de l'outil rustdoc
.
La documentation par composants
La documentation d'un composant, que ça soit une struct
, un enum
, une fonction, etc. se fait en préfixant ///
devant la ligne de documentation et la plaçant
directement au dessus du composant à commenter. Ainsi les exemples suivants permettent de:
- Documenter une constante
/// The SIZE constant allows to use statically sized arrays
const SIZE: usize = 9;
- Documenter une fonction
/// Poorly emulates the parsing of a command line.
pub fn read_command_line() -> [i32; crate::SIZE] {
[10, 32, 12, 43, 52, 53, 83, 2, 9]
}
- Documenter une fonction statique
impl<T: std::fmt::Display> SomethingOrNothing<T> {
/// A static function that prints the content of a SomethingOrNothing.
pub fn print(&self) {
match self {
SomethingOrNothing::Nothing => println!("Nothing."),
SomethingOrNothing::Something(val) => println!("Something is: {}", val),
}
}
}
- Documenter un trait
/// The [Minimum] trait computes the minimum value between two values of a type
pub trait Minimum: Copy {
fn min(self, rhs: Self) -> Self;
}
- Documenter un type énuméré et ses variantes
/// An generic enumerated type that has two variants.
///
/// - Nothing
/// - Something
#[derive(Clone, Copy)]
pub enum SomethingOrNothing<T> {
/// A [SomethingOrNothing::Nothing]
Nothing,
/// A [SomethingOrNothing::Something] encapsulating a T
Something(T),
}
La documentation d'une crate
et des modules
Une librairie est appelée crate
Rust. Afin de donner des informations de haut niveau
sur le fonctionnement d'une librairie, on peut utiliser une syntaxe spéciale //!
à mettre au début de chaque
ligne de documentation (on peut également utiliser la syntaxe /*! ... */
), comme ci-dessous
//! This is an example of Rust crate comments (or inner comments).
//! They will be rendered in the front page of your (crate) library.
//!
//! # How to generate the documentation
//!
//! In this program we wrote an algorithm that computes the minimum of
//! a sequence of integers.
//!
//! To create the documentation run the command
//! ```bash
//! cargo doc
//! ```
//! The obtain documentation can be found in the `target/doc/tooling/index.html` directory
//!
//! To view the documentation type
//! ```bash
//! cargo doc --open
//! ```
//! which will open the browser and show you the documentation.
//!
//! The documentation supports the CommonMarkdown syntax.
//!
//! Below we will use the `///` comments that will comment the code directly below.
//! We can also sue `//` but they will not be rendered.
//! All the lines written here could be enclosed in `/*! ... */` instead of being prefixed by `//!`.
//!
//! For more informations about writing documentation [follow that link](https://doc.rust-lang.org/rustdoc/what-is-rustdoc.html).
//!
//! # Tooling
//!
//! Also Rust comes with great tooling.
//! - [Clippy](https://doc.rust-lang.org/stable/clippy/): The officiel Rust linter.
//! - [Rustfmt](https://github.com/rust-lang/rustfmt): The official Rust code formatter.
Cette documentation se met dans le fichier lib.rs
qui est également l'endroit où on importe les différents modules.
On peut également documenter les modules individuellement. Pour ce faire, il faut utiliser la même syntaxe que pour la documentation de la crate, mais mettre cette documentation au début du fichier contenant chaque module comme par exemple au début du fichier io.rs
//! Contains functions to interact with the user, either
//! by reading inputs from the terminal, either by writing values
//! in it.
Markdown
La documentation supporte la syntaxe du Common Markdown comme on peut le voir dans le code ci-dessus. On a en particulier
la possibilité de mettre des titres avec des #
ou du code avec des code fences. Il est également possible de mettre des liens vers
d'autres parties de la documentation (avec les annotations tu type [MyStruct]
) ce qui fait de la syntaxe un outil très puissant et intégré fortement au processus de développement.
Génération de la documentation
Tout ce travail d'annotation du code source permet d'utiliser rustdoc
qui est un outil puissant de génération de documentation
sous la forme principal d'un site web. Dans le répertoire où se trouve le fichier Cargo.toml
, on peut exécuter la commande
cargo doc
et cela va générer la documentation dans un sous répertoire du projet. On peut également automatiquement ouvrir la documentation dans un navigateur à l'aide de la commande
cargo doc --open
Les tests
La documentation aide grandement à la (ré-)utilisation d'une librairie et à son développement (collaboratif ou non).
Durant le processus de développement, il est également très utile (et important) d'écrire des tests pour le code.
Rust propose un framework de tests totalement intégré au langage. On peut ainsi très facilement écrire
des fonctions de test en ajoutant l'annotation #[test]
directement au dessus de n'importe quelle fonction
#[test]
fn test_creation() {
use something_or_nothing::SomethingOrNothing;
let n1: SomethingOrNothing<i32> = SomethingOrNothing::default();
assert!(n1 == SomethingOrNothing::Nothing);
let n2: SomethingOrNothing<i32> = SomethingOrNothing::Something(1);
assert!(n2 == SomethingOrNothing::Something(1));
}
La fonction test_creation()
sera automatiquement appelée lors de l'appel à la commande
cargo test
Aucune fonction non annotée par #[test]
n'est appelée quand on exécute cargo test
.
Si la fonction se termine sans erreur, le test est réussi (il est échoué si la fonction
s'arrête en cours de route).
On voit dans la fonction test_creation()
l'appel à la macro assert!()
qui prend en argument
une expression booléenne, ici n1 == SomethingOrNothing::Nothing
dans le premier appel.
Si l'expression prend la valeur true
, le code continue normalement son exécution, sinon
il fait appel à la macro panic!()!
et l'exécution est interrompue et un code d'erreur est retourné par le programme.
Dans l'appel à n1 == SomethingOrNothing::Nothing
, on constate qu'on a besoin de vérifier
si la valeur n1
est égale à SomethingOrNothing::Nothing
. L'opérateur ==
n'est pas implémenté
pour les types complexes, ainsi le code suivant ne compile pas
enum SomethingOrNothing<T> { Nothing, Something(T), } fn main() { let n1 = SomethingOrNothing::<i32>::Nothing; let b = n1 == SomethingOrNothing::<i32>::Nothing; }
et nous donne un message d'erreur incluant
note: an implementation of `PartialEq<_>` might be missing for `SomethingOrNothing<i32>`
Ainsi, on doit implémenter le trait PartialEq
pour le type SomethingOrNothing<T>
,
qui permet de tester l'égalité entre deux instances d'un type
/// An generic enumerated type that has two variants. /// /// - Nothing /// - Something #[derive(Clone, Copy)] pub enum SomethingOrNothing<T> { /// A [SomethingOrNothing::Nothing] Nothing, /// A [SomethingOrNothing::Something] encapsulating a T Something(T), } /// Implementation of the [PartialEq] trait that is useful for tests. impl<T: PartialEq> PartialEq for SomethingOrNothing<T> { fn eq(&self, other: &Self) -> bool { match (self, other) { (SomethingOrNothing::Nothing, SomethingOrNothing::Nothing) => true, (SomethingOrNothing::Something(lhs), SomethingOrNothing::Something(rhs)) => { *lhs == *rhs } _ => false, } } } fn main() { let n1 = SomethingOrNothing::<i32>::Nothing; assert!(n1 == SomethingOrNothing::<i32>::Nothing); }
On constate que dans notre implémentation, il est nécessaire que T
implémente également
le trait PartialEq
. Ainsi, deux Nothing
sont égaux, un Nothing
et un Something
sont différents, et seulement quand les deux valeurs encapsulées dans deux Something
sont égales
alors nous avons égalité.
On peut également vouloir construire des tests qui échouent comme dans l'exemple ci-dessous
#[test]
#[should_panic]
fn test_failure_creation() {
let n2: SomethingOrNothing<i32> = SomethingOrNothing::Something(1);
assert!(n2 == SomethingOrNothing::Nothing);
assert!(n2 == SomethingOrNothing::Something(2));
}
où on a annoté le test avec un #[should_panic]
. Ce test, bien qu'il panique, sera considéré
comme réussi. Il faut néanmoins rester prudent avec ce type de test. Rien ne garantit que la fonction de test a paniqué au moment espéré. Le code pourrait tout à fait paniquer pour une raison autre que celle attendue. Cela est particulièrement vrai si le test est complexe.
Finalement, il y a également la possibilité de regrouper les tests
comme ci-dessous (dans le fichier lib.rs
dans cet exemple)
#[cfg(test)]
mod tests {
use crate::minimum::Minimum;
use crate::something_or_nothing::{find_min, SomethingOrNothing};
#[test]
#[should_panic]
fn test_failure_creation() {
let n2: SomethingOrNothing<i32> = SomethingOrNothing::Something(1);
assert!(n2 == SomethingOrNothing::Nothing);
assert!(n2 == SomethingOrNothing::Something(2));
}
#[test]
fn test_min() {
let a = [1, 5, -1, 2, 0, 10, 11, 0, 3];
let min = find_min(&a);
assert!(min == SomethingOrNothing::Something(-1));
}
#[test]
fn test_min_something_or_nothing() {
let x = SomethingOrNothing::Something(5i32);
let y = SomethingOrNothing::Something(10i32);
let z = SomethingOrNothing::Nothing;
assert!(x.min(y) == x);
assert!(y.min(x) == x);
assert!(z.min(y) == y);
assert!(y.min(z) == y);
assert!(z.min(z) == z);
}
}
Pour ce faire, il faut créer un module, (ici mod tests
) et l'annoter avec une configuration spéciale #[cfg(test)]
. Cela permet de séparer les tests totalement du reste du code et devoir
importer les différentes implémentations.
Il est également possible de répartir les tests dans différents modules comme dans minimum.rs
par exemple en plus de lib.rs
#[cfg(test)]
mod tests {
use crate::minimum::Minimum;
#[test]
fn test_min_i32() {
let x = 5;
let y = 10;
assert_eq!(Minimum::min(x, y), x);
assert_eq!(Minimum::min(y, x), x);
assert_eq!(Minimum::min(x, x), x);
assert_eq!(Minimum::min(y, y), y);
}
}
Tests de documentation
Une bonne documentation inclut des exemples de code. Mais il n'y a rien de pire que des
exemples faux ou qui ne marchent pas. Ainsi, Rust offre la possibilité de compiler
et exécuter le code inclut dans la documentation: ces tests sont des tests de documentation. En effet, le code entre "code fences"
markdown sera compilé et exécuté (à moins qu'il soit annoté ignore
où il sera pas compilé et no_run
où il sera pas exécuté). Il y a deux exemples de tests de documentation dans notre code. Le premier concerne l'implémentation du trait
Default pour SomethingOrNothing
qui permet
de construire une instance par défaut qui sera la variante Nothing
(voir something_or_nothing.rs
).
/// Implementation of the [Default] trait that creates a [SomethingOrNothing]
/// that is a `Nothing` variant.
///
/// # Example
///
/// ```
/// # use tooling::something_or_nothing::SomethingOrNothing;
/// # fn main() {
/// let def: SomethingOrNothing<i32> = SomethingOrNothing::default();
/// assert!(def == SomethingOrNothing::Nothing);
/// # }
/// ```
impl<T> Default for SomethingOrNothing<T> {
/// By Default a [SomethingOrNothing] is a nothing.
fn default() -> Self {
SomethingOrNothing::Nothing
}
}
On voit que pour que le test puisse compiler et s'exécuter il est nécessaire d'importer les bons modules/fonctions. Ici on importe
explicitement tooling::something_or_nothing::SomethingOrNothing
et on met les fonctions à exécuter dans un main.
Pour que ce "bruit" n'apparaisse pas dans la documentation, on préfixe les lignes par des #
. Ainsi ces lignes
sont lues par le compilateur pour les tests de documentation mais sont ignorées lors du rendu de la documentation.
Il y a également un exemple sur l'utilisation de la fonction find_min()
(toujours dans something_or_nothing.rs
)
/// Computes the minimum of an Array of a type T which implements the [Minimum] trait.
/// Returns a [SomethingOrNothing::Something] containing the the minimum value
/// or [SomethingOrNothing::Nothing] if no minimum value was found.
///
/// # Example
///
/// ```
/// # use tooling::something_or_nothing::{SomethingOrNothing, find_min};
/// # fn main() {
/// let tab = [10, 32, 12, 43, 52, 53, 83, 2, 9];
/// let min = find_min(&tab);
/// assert!(min == SomethingOrNothing::Something(2));
/// # }
/// ```
pub fn find_min<T: Minimum>(tab: &[T; crate::SIZE]) -> SomethingOrNothing<T> {
let mut minimum = SomethingOrNothing::Nothing;
// Here is T is Copyable. Which means that t is not moved in the loop
for t in tab {
minimum = minimum.min(SomethingOrNothing::Something(*t));
}
minimum
}
Finalement, les tests de documentation peuvent également être mis dans les documentation de module comme dans minimum.rs
//! Contains a generic trait implementation for computing the minimum between two
//! values. It is the equivalent of the `<` operator.
//!
//! # Examples
//!
//! For integers this would look like
//!
//! ```
//! # use tooling::minimum::Minimum;
//! let one = 1;
//! let two = 2;
//! assert!(Minimum::min(one, two) == one);
//! ```
Rapport sur l'exécution des tests
Lors de l'appel à cargo test
tous les tests sont exécutés et un rapport est généré. Sur
ce code, on obtient
$ cargo est
Compiling tooling v0.1.0 (/home/orestis/git/projects/rust-101/codes/rust_lang/tooling)
Finished test [unoptimized + debuginfo] target(s) in 0.37s
Running unittests src/lib.rs (target/debug/deps/tooling-f12750c4987ae624)
running 5 tests
test test_creation ... ok
test minimum::tests::test_min_i32 ... ok
test tests::test_min ... ok
test tests::test_failure_creation - should panic ... ok
test tests::test_min_something_or_nothing ... ok
test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Running unittests src/main.rs (target/debug/deps/tooling-b63e62707c6aab7d)
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Doc-tests tooling
running 3 tests
test src/minimum.rs - minimum (line 8) ... ok
test src/something_or_nothing.rs - something_or_nothing::SomethingOrNothing<T> (line 38) ... ok
test src/something_or_nothing.rs - something_or_nothing::find_min (line 95) ... ok
test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.14s
Les outils en plus du compilateur
La chaîne de compilation Rust vient avec deux autres outils très pratiques (en plus de cargo
et rustdoc
): rustfmt
un formatteur de code, et clippy
un linter.
rustfmt
Rust a fait le choix fasciste de définir des tas de conventions pour le nommage
des variables, des types, etc. L'outil rustfmt
permet de formatter automatiquement
le code pour avoir un style uniforme au travers de tout votre code et ainsi améliorer
sa lisibilité.
fn main() { const size: usize = 9; let tab: [i32; size] = [10, 32, 12, 43, 52, 53, 83, 2, 9]; if (size == 0) { panic!("Size is of tab = 0."); } println!("Among the numbers in the list:"); for i in 0..size { print!("{} ", tab[i]); } println!(); let mut MinTab=tab[0]; for i in 1..size { if MinTab > tab[i] { MinTab=tab[i]; } } println!("The minimal value is: {}", MinTab); }
Afin de le voir à l'oeuvre copier le code ci-dessus dans un src/main.rs
et utilisez la commande rustfmt --check src/main.rs
et observez le résultat. Par défaut rustfmt
va modifier le code, ainsi l'option --check
va uniquement montrer
quelles sont les choses à corriger. En général, rustfmt
est intégré avec les plugins dans des éditeurs de code
tels que codium
qui l'intègre dans le plugin rust-analyzer
par exemple.
clippy
L'outil clippy
est un détecteur automatique de mauvaise pratiques de codage, telles que définies
par la communauté du Rust. Il permet d'écrire du code le plus idiomatique possible
et en général d'éviter certaines mauvaises pratiques et/ou de simplifier
le code avec des patterns connus.
Comme ci-dessus, prenez ce code et exécutez la commande cargo clippy
pour voir les recommandations du linter.
Rustlings
Les rustlings à faire dans ce chapitre sont les suivants:
Les modules
$ rustlings run tests1
$ rustlings run tests2
$ rustlings run tests3
$ rustlings run tests4
Clippy
$ rustlings run clippy1
$ rustlings run clippy2
$ rustlings run clippy3
Discussion du code gestion_erreurs
Concepts
Les concepts abordés dans cet exemple sont:
Documentation
Afin de compléter ce cours, je vous recommande la lecture des ressources suivantes :
- La gestion des erreurs en Rust
- Les options
- Les résultats
- La macro panic
- Le trait Try et l'opérateur ?
Discussion
Lors de l'exécution d'un programme, il existe en général une multitude d'endroits où des erreurs peuvent se produire. Il est important de savoir identifier les différents types d'erreurs, ainsi que les outils offerts par le langage Rust permettant de gérer ces erreurs.
Les différents types d'erreurs
Il faut savoir différentier deux principaux types d'erreurs.
Erreurs rattrapables
Dans un premier temps, il y a les erreurs rattrapables ou prévues. En général, ces erreurs surviennent lorsque l'utilisateur entre des données erronées.
Nous avons par exemple :
- Le chemin d'un fichier fourni par l'utilisateur qui n'existe pas
- Une entrée invalide, p.ex une lettre à la place d'un nombre
- De mauvais identifiants pour la connexion à un serveur
Ces erreurs sont attendues, il est donc normal d'anticiper leurs occurences, et par conséquent, de s'y préparer. Elles de ne devraient pas interrompre l'exécution du programme.
Pour détecter ce type d'erreur, nous devons essayer de prévoir tous les scénarios possibles. Si je demande à l'utilisateur d'entrer une valeur comprise entre 1 et 10, je pourrais par exemple obtenir :
- Une valeur non-numérique
- Une valeur non comprise dans l'intervalle demandée
- L'absence de valeur
- Une erreur impossible à prévoir parce qu'on ne peut pas tout prévoir. Le meilleur moyen de se prémunir de ce genre d'erreur, consiste à utiliser des types adaptés.
Prenons par exemple la fonction :
#![allow(unused)] fn main() { fn make_color(red:i32, green:i32, blue:i32, alpha:i32) -> i32 { red << 24 | green << 16 | blue << 8 | alpha // Quel est le problème? } }
Cette fonction prend en argument une valeur par canal de couleur et construit
un entier représentant une couleur rgba 32 bit.
Le problème majeur dans cette fonction est le typage de nos arguments. Pour fabriquer une couleur rgba 32 bit,
il faut un octet par canal. Le type i32
n'est donc pas approprié, il pourrait être la source d'erreurs.
Pour éviter cela, on pourrait par exemple le remplacer par u8
qui permet de représenter un octet.
#![allow(unused)] fn main() { fn make_color(red:u8, green:u8, blue:u8, alpha:u8)->u32 { (red as u32) << 24 | (green as u32) << 16 | (blue as u32) << 16 | (alpha as u32) } }
De cette manière, on évite les erreurs potentielles à la source. Vous noterez qu'il est nécessaire de caster vos arguments, afin d'avoir une expression cohérente du point de vue des types. Notre code est donc moins susceptible de contenir une erreur, mais en contrepartie, il est plus complexe.
Erreurs irrattrapables
Le deuxième type d'erreurs que l'on peut rencontrer sont les erreurs irrattrapables (les bugs par exemple).
Lorsque de notre programme se trouve dans un état incohérent, il est nécessaire d’interrompre son exécution. De manière générale, les bugs surviennent lorsque le développeur a fait une erreur dans son code. Il existe plusieurs erreurs possibles. Nous avons par exemple des erreurs :
- algorithmiques (ex: un faute logique dans la conception de l'algorithme, un cas pas couvert)
- du système (ex: la carte réseau n'est pas accessible)
- humaines (ex: le développeur a fait une faute d'inattention en écrivant son code)
Il faut à tout prix éviter ces erreurs au maximum. Un programme qui interrompt constamment son exécution, n'est pas un programme robuste et par conséquent, un programme qui n'est pas fiable. La notion de fiabilité dépasse le cadre de ce cours, mais nous pouvons dire pour résumer qu'il existe plusieurs domaines et donc plusieurs niveau d'attente en terme de fiabilité. Il arrive souvent qu'un jeu soit buggé dans ses premières versions. En revanche, il est n'est pas acceptable que le pilote automatique d'un avion crash au en plein milieu d'un vol.
Le type Option
Historiquement, on représentait l'absence de valeur par une valeur dédiée (p.ex : une personne qui n'a pas
renseigné son âge, possède un âge de -1, ou alors l'utilisation du pointeur NULL
). Bien que cette solution fonctionne, elle peut-être la source de
nombreuses erreurs et bugs en tout genre. Un type optionnel est une alternative moderne et plus robuste pour
nombreuses erreurs et bugs en tout genre. Les langages modernes gèrent ces cas à l'aide de leur système de typage permettant de meilleures vérifications à la compilation et à l'exécution ce qui les rend plus robustes: on parle de types optionnels.
Il existe un grand nombre de fonctions qui ne retournent pas forcèment un résultat. On peut également penser à des structures. Le caractère optionnel d'une valeur n'est pas forcèment une erreur, mais si cet aspect n'est pas géré correctement, il peut mener à des erreurs.
Nous pouvons par exemple vouloir représenter un utilisateur qui peut s'il le veut fournir sa date de naissance et son adresse email. Prenons la structure suivante :
struct User {
username: String,
birth_date: i32,
email: String,
}
Si on souhaite récupérer l'une de ces deux informations, on pourrait se retrouver dans un cas où l'utilisateur n'a pas
souhaité renseigner l'information désirée. C'est là qu'intervient le type Option<T>
. Il permet de représenter une valeur
optionnelle.
Voici sa déclaration d'après la documentation Rust :
#![allow(unused)] fn main() { pub enum Option<T> { None, Some(T), } }
C'est tout simplement d'un type énuméré qui contient soit une valeur sous la forme Some(ma_valeur)
ou pas de valeur None
. Il s'agit de la version générique du type NumberOrNothing
vu dans la partie 2.
Nous pouvons donc réecrire notre structure User
de cette manière :
struct User {
username: String,
birth_date: Option<i32>,
email: Option<String>,
}
Si nous reprenons notre exemple du minimum d'un tableau, nous pouvons écrire notre fonction de la manière suivante :
pub fn find_min_with_option<T: Minimum>(tab: &[T]) -> Option<T> {
let mut minimum = None;
// Here is T is Copyable. Which means that t is not moved in the loop
for t in tab {
minimum = Minimum::min(minimum, Some(*t));
}
minimum
}
Ici on commence par instancier notre minimum à None
, puis on compare itérativement notre minimum avec
les valeurs du tableau encapsulées dans une option avec Some(*t)
.
Pour comparer deux options entre elles, nous avons implémenté le trait minimum pour une Option<T>
, où T
implémente le trait minimum. Ce qui nous donne :
impl<T: Minimum> Minimum for Option<T> {
fn min(self, rhs: Self) -> Self {
match self {
Some(val_l) => Some(match rhs {
Some(val_r) => val_l.min(val_r),
None => val_l,
}),
None => match rhs {
Some(val_r) => Some(val_r),
None => None,
},
}
}
}
On peut voir ici que l'on décompose notre option grâce au pattern matching de Rust.
La macro panic!
Un programme peut être amené à s'arrêter de manière anormale. En C
, on utilise la fonction abort
.
Cette dernière va indiquer au processus parent ayant exécuté le programme qu'une erreur s'est produite et que
l'exécution ne peut pas se poursuivre.
En Rust, il est possible de mettre fin au programme d'une manière similaire à
ce que l'on retrouve en C
avec la fonction suivante :
use std::process; fn main() { process::abort(); }
Néanmoins, Rust étant un langage moderne, il possède la macro panic!
. Cette maco ne va pas simplement interrompre
l'exécution du programme et signaler une erreur au processus appelant. Par défaut,
elle va également remonter la pile des appels de fonctions et libérer la mémoire au fur à mesure.
Pour déclencher une panique du programme, il suffit d'appeler la macro avec un message en argument :
fn main() { panic!("!!! Oups something went wrong !!!") }
Si on exécute ce code, on obtient l'erreur suivante :
Compiling playground v0.0.1 (/playground)
Finished dev [unoptimized + debuginfo] target(s) in 0.41s
Running `target/debug/playground`
thread 'main' panicked at '!!! Oups something went wrong !!!', src/main.rs:2:5
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
On peut voir que la console nous indique que le fil d'exécution principal main
a paniqué, et il nous affiche le message
que nous avons passé en argument à notre macro panic!
.
Si nous reprenons notre exemple du minimum d'un tableau, nous pouvons écrire notre fonction de la manière suivante :
pub fn find_min_with_panic<T: Minimum>(tab: &[T]) -> T {
let mut minimum = None;
// Here is T is Copyable. Which means that t is not moved in the loop
for t in tab {
minimum = Minimum::min(minimum, Some(*t));
}
// We decide that we cannot compute the minimum of an empty array
match minimum {
Some(val) => val,
None => panic!("The array is empty"),
}
}
La première chose que nous pouvons noter avec notre fonction est le type de retour. En effet, nous ne retournons pas une option, mais bien un élèment de type T. En effet, si on est certain que notre fonction va retourner un élément, il n'y a pas de raison d'encapsuler ce retour dans une strucutre quelconque.
Si malgré notre certitude, la fonction ne parvenait pas à produire de résultat, nous serions alors dans un cas indertimné,
et par conséquent il faut mettre fin à l'exécution du programme. Pour cela, on peut voir dans la dernière expression que
si le minimum n'a pas été trouvé None => panic!("The array is empty"),
, alors le programme se termine sur une panic.
Le message d'erreur passé en argument est remonté plus haut. Il est en effet possible d'intercepter les panic
grâce à la fonction std::panic::catch_unwind
ou en rédéfissant manuellement le comportement de la macro panic!
à l'aide
de la fonction std::panic::set_hook
. Néanmoins, l'usage de ces fonctions devrait être limité à des cas très spécifiques
que nous ne couvrirons pas dans ce cours d'introduction au Rust.
Le développeur néophyte pourrait être tenté de simplifier (dans le sens du nombre de caractères) son code en utilisant
uniquement la macro panic!
. Cela va à l'encontre des bonnes pratiques du Rust et aura tendance à rendre
votre code peu compréhensible et défaillant. Je vous recommande cet excellent article de la documentation Rust qui explique quand utiliser la macro panic!
à bon escient.
En résumé, la macro panic
devrait être utilisé à des fins de debugging ou uniquement lorsque le programme rentre dans
un état indeterminé, c'est à dire un erreur irrattrapable.
Le type Result
Le Rust offre une solution plus élégante que d'interrompre l'exécution du programme pour gérer les erreurs. Il s'agit
comme pour Option<T>
d'un type énuméré. Voici sa définition :
#![allow(unused)] fn main() { enum Result<T, E> { Ok(T), Err(E), } }
Le type Result<T,E>
peut contenir soit une valeur attendue en cas de réussite sous la forme d'un Ok(ma_valeur)
, ou
valeur permettant de renseigner l'erreur sur forme de Err(mon_erreur)
. C'est un type plus complet qu'une simple
Option<T>
, car en cas d'absence de valeur, nous pouvons indiquer la cause de l'erreur.
Prenons par exemple la fonction suivante qui permet de télécharger une page WEB :
fn get_html(uri: String) -> String
Un problème pourrait se produire lors du téléchargement. On pourrait donc écrire :
fn get_html(uri: String) -> Option<String>
Si notre fonction nous retourne None
, on se rend compte très vite que l'on a aucune information sur la
raison de l'absence de données (pas de connexion, mauvaise url, accès interdit, etc...).
C'est là qu'intervient le type Result<T,E>
, en cas d'absence de résultat de la fonction, nous allons pouvoir
comprendre la source de l'erreur et réagir réagir en conséquence. Nous pourrions donc écrire :
enum DownloadError {
BadURL,
Forbidden,
...
}
fn get_html(uri: String) -> Result<String, DownloadError>
Reprenons maintenant notre exemple du minimum d'un tableau. La première chose que nous pouvons faire est de définir type représentant les erreurs que nous pourrions rencontrer en cherchant le minimum :
#[derive(PartialEq)]
pub enum FindMinError {
EmptyList,
UnsupportedError(String),
}
Ici nous envisageons deux scénarios pouvant provoquer une erreur :
- Le minimum d'une liste vide
- Une éventuelle erreur que nous n'aurions pas encore prévu. Cette erreur est accompagnée d'un message décrivant l'erreur.
Une fois nos erreurs définies, nous pouvons passer à l'implémentation de notre fonction de recherche du minimum :
pub fn find_min_with_result<T: Minimum>(tab: &[T]) -> Result<T, FindMinError> {
let mut minimum = None;
// Here is T is Copyable. Which means that t is not moved in the loop
for t in tab {
minimum = Minimum::min(minimum, Some(*t));
}
match minimum {
Some(val) => Ok(val),
None => Err(EmptyList),
}
}
Cette fonction n'est pas très différente des précédentes. On remarque à la fin que pour former un Result
,
si nous trouvons un minimum, nous l'encapsulons dans un Ok(...)
, sinon nous retournons une erreur avec Err(...)
.
Ici la seule erreur que nous retournons est la liste vide.
Pour traiter ce résultat, nous pouvons faire du pattern matching :
let min = find_min_with_result(&tab_empty);
match min {
Ok(val) => print!("The minimum value is {}", val),
Err(EmptyList) => eprintln!("The array is empty"),
Err(UnsupportedError(msg)) => panic!("Unsupported error : {}", msg),
}
Ici nous avons trois cas :
- le minimum a été trouvé, on l'affiche.
- la liste était vide, on affiche un message d'erreur.
- le minimum n'a pas pu être trouvé à cause d'une erreur non gérée, le programme va donc se terminer sur une panic contenant le message d'erreur retourné par la fonction.
Il est commun de rencontrer un usage pouvant mener facilement à des erreurs de Result
. Prenons par exemple
Result<T, String>
ou Result<T, i32>
. En règle général, ces types concrets permettent de retourner
un message ou un code en cas d'erreur. Bien que cela ne pose pas de problème du point de vue purement fonctionel,
il rend aveugle le compilateur. En effet, en définissant les erreurs attendues avec un type énuméré, le compilateur
peut s'assurer que toutes les erreurs ont été prises en compte lors d'une décomposition de l'erreur.
Prenons l'exemple suivant :
fn foo() -> Result<i32, String>{
// do stuff
match val {
1 => Ok(42),
2 => Err("Oupsi"),
3 => Err("G phai une faute"),
_ => Err("Aie aie aie")
}
}
Imaginons que j'aie fait une faute dans mon message d'erreur. Mon message d'erreur est le seul moyen de différencier les différentes erreurs possibles. En corrigeant ma faute, je dois maintenant mettre à jour tout mon code, ainsi que le code de tous ceux qui utilisent mon code. Je peux également me tromper sur la casse de mon message d'erreur la ponctuation etc... Le compilateur ne verra pas l'erreur et je peux passer des heures à chercher pourquoi mon code ne marche pas.
Il en va de même avec les codes d'erreurs numériques, il suffit que je veuille changer un seul code pour devoir à nouveau mettre tout à jour.
Il est donc fortement recommandé d'éviter cet usage du type Result
.
Néanmoins, l'usage de type énuméré pour la gestion des erreurs peut-être source de redondance. On se retrouve avec des types qui représentent les mêmes erreurs avec des noms différents dans chaque crate. Pour éviter cela, il est préférable de commencer par chercher si son erreur existe avant de créer une nouvelle erreur. Cela dépasse le cadre de ce cours, mais sachez qu'il existe deux crates populaires qui peuvent vous aider pour la gestion d'erreur :
L'opérateur ?
Le language Rust offre un sucre syntaxique, afin de simplifier la gestion des options et des erreurs imbriquées.
L'opérateur ?
permet de récupérer la valeur contenue ou faire remonter l'erreur ou l'absence de valeur. On s'en sert
principalement pour les Option
et les Result
. Pour plus de détails sur l'interface Try
qui permet d'utiliser
l'opérateur ?
sur un type quelconque, je vous recommande la documentation.
Prenons un exemple directement tiré de notre code :
pub fn find_min_amongst_arrays_by_hand<T: Minimum>(
lhs: &[T],
rhs: &[T],
) -> Result<T, FindMinError> {
let min_result = find_min_with_result(lhs);
let min_l = if let Ok(x) = min_result {
x
} else {
// Since tmp is not Ok, we return the error to the caller
return min_result;
};
let min_result = find_min_with_result(rhs);
let min_r = if let Ok(x) = min_result {
x
} else {
// Since tmp is not Ok, we return the error to the caller
return min_result;
};
Ok(min_l.min(min_r))
}
Cette fonction prends deux tableaux en argument et va chercher quelle est la valeur minimum globale.
Si on veut réutiliser le code que nous avons déjà écrit, nous avons une fonction qui retourne un
Result
et qui nous donne la valeur minimale d'un tableau. Il nous suffit donc de chercher la valeur
minimale dans le premier tableau, puis dans le deuxième, et enfin retourner la plus petite des deux
valeurs.
Seulement, ce n'est pas aussi simple puisque l'on obtient un Result
pour chaque fonction, il faut
systèmatiquement verifier qu'aucune erreur s'est produite. Ici, si une erreur est survenue, nous
n'avons rien d'autre à faire que de retourner l'erreur telle quelle.
C'est en réalité un cas de figure que l'on retrouve souvent. On peut voir dans notre code ci-dessus, le code est répetitif et rends le code la fonction moins lisible.
Avec l'opérateur ?
on peut simplement remplacer le test ainsi :
pub fn find_min_amongst_arrays_qm_op<T: Minimum>(lhs: &[T], rhs: &[T]) -> Result<T, FindMinError> {
// The question mark operator will unpack the value if the function returns [Result::Ok]
// or end the function and return the [Result:Err] to the caller.
let min_l = find_min_with_result(lhs)?;
let min_r = find_min_with_result(rhs)?;
Ok(min_l.min(min_r))
}
Ces deux fonctions font strictement la même chose. L'opérateur agit comme un sucre syntaxique qui permet
d'allèger l'écriture du code et ainsi augmenter sa lisibilité. En clair, si le résultat est Ok(val)
,
l'expression retourne val
, sinon la fonction se termine ici et retourne le résultat Err(error)
contenu
dans le résultat.
Rustlings
Les rustlings à faire dans ce chapitre sont les suivants:
La gestion des erreurs
$ rustlings run errors1
$ rustlings run errors2
$ rustlings run errors3
$ rustlings run errors4
$ rustlings run errors5
$ rustlings run errors6
Les options
$ rustlings run options1
$ rustlings run options2
$ rustlings run options3
Discussion du code closures
Concepts
Les concepts abordés dans cet exemple sont:
Documentation
Afin de compléter ce cours, je vous recommande la lecture des ressources suivantes :
- Les fonctions anonymes closures en Rust
- La transformation d'une option avec des closures
- Les fonctions d'ordre supérieur et les closures
Discussion
De plus en plus de langages proposent des fonctions anonymes (des fonctions sans identifiant). Ces fonctions sont un outil précieux de la programmation fonctionelle qui gagne elle aussi en popularité. Nous présenterons dans ce cours ce qu'elles sont et les avantages qu'elles peuvent apporter.
Fonctions anonymes
Une fonction anonyme est une fonction sans identifiant, qui permet de transformer un comportement en variable.
Prenons un exemple simple :
|x: i32| -> i32 { x + 1 }
Nous avons entre |
les arguments de notre fonction anonyme, ici x
qui est de type i32
. À la suite
du deuxième |
, nous avons le type de retour et finalement entre {}
, une expression qui est le corps
de la fonction. Il n'est pas obligatoire d'indiquer le type de l'argument, le type de retour ou de mettre
entre accolades le corps de la fonction, cela dépendra des déductions que le compilateur peut réaliser.
En une phrase, on peut lire l'expression ci-dessus comme étant : "Une fonction qui pour tout entier
signé de 32 bits x donne x + 1".
Cette expression en Rust est une fonction anonyme. Son équivalent en fonction standard serait :
fn add_one(x:i32) -> i32 {
x + 1
}
Il est possible de stocker nos fonctions anonymes dans une variable, par exemple :
let max_op: fn(i32, i32) -> i32 = |x, y| if x >= y { x } else { y };
Fonction d'ordre supérieur
Une fonction d'ordre supérieur est une fonction qui prends en argument une ou plusieurs fonctions et/ou retourne une ou plusieurs fonctions.
L'intérêt principal de nos fonctions anonyme est de pouvoir les passer et les retourner via des fonctions.
Prenons un petit exemple :
fn plus_two(x: i32, add_one: fn (i32) -> i32) -> i32 { add_one(add_one(x)) } fn main(){ let add_one = |x| x + 1; println!("{}", plus_two(5, add_one)); }
Ici nous avons une fonction qui additionne 2 à un entier x
, mais pour cela notre fonction doit tout d'abord
savoir additionner 1. Elle prend donc en argument l'entier x
auquel elle doit additionner 2, et une fonction
permettant d'additioner 1 à un nombre appelée add_one.
Si on se penche sur cet argument nous avons add_one: fn (i32) -> i32
, le type de cet argument est une fonction,
qui prend un i32
et retourne un i32
.
Puisque la fonction plus_two
prends en argument une fonction, il s'agit d'une fonction d'ordre supérieur.
Dans la fonction main
, on déclare une variable add_one
qui va contenir une fonction anonyme qui additione 1
à un entier. A titre d'illustration nous appliquons plus_two
à 5 et notre fonction anonyme stockée
dans une variable nommée add_one
.
Cet exemple est un peu trivial, mais il permet de saisir brièvement la syntaxe.
Nous pouvons modifier notre exemple du calcul du minimum d'une liste, pour y ajouter des fonctions d'ordre supérieur.
Dans un premier temps, nous pouvons généraliser le comportement de notre fonction de recherche du minimum. Pour cela,
On commence par créer un type BinaryOperator<T>
:
pub type BinaryOperator<T> = fn(T, T) -> T;
Si on lit sa définition, on s'apperçoit qu'il s'agit d'une fonction qui prends deux éléments de type T
et en retourne
un troisième du même. Cette définition s'applique parfaitement à la fonction minimum, je prends deux éléments du même type,
je détermine lequel est le plus petit et je le retourne.
Prenons maintenant le code de notre fonction du calcul du minimum généralisée :
pub fn find_with_hof<T: Copy>(tab: &[T], op: BinaryOperator<T>) -> Option<T> {
let mut res = None;
// Here is T is Copyable. Which means that t is not moved in the loop
for t in tab {
if let Some(val) = res {
res = Some(op(val, *t))
} else {
res = Some(*t)
}
}
res
}
Cette fonction est sensiblement la même que la fonction étudiée dans par la partie 07. Le premier changement qu'il faut remarquer, intervient au niveau des arguments.
On voit apparaître un nouvel argument op: BinaryOperator<T>
. Il s'agit simplement de l'opération que
nous allons utiliser itérativement sur notre tableau, afin d'obtenir un résultat. On pourrait prendre par
exemple la fonction minimum.
Contrairement au trait Minimum
que nous avions défini jusqu'à maintenant, nous allons gérer manuellement
le résultat qui est intialisé à None
. La deuxième modification que nous devons remarquer se trouve dans
le corps de la boucle. Pour gérer le résultat vide que l'on recontre durant la première itération,
on utilise un simple assignation conditionnelle comme nous l'avons vu dans les cours précédent.
Avec cette fonction d'ordre supérieur, nous pouvons désormais utiliser n'importe quelle opération qui réduit deux éléments en un seul.
En utilisant un autre type de fonction d'ordre supérieur, celles qui retournent un fonction, on peut générer plusieurs opérateurs de ce type.
pub fn minimum_operator<T: PartialOrd>() -> BinaryOperator<T> {
|x: T, y: T| if x <= y { x } else { y }
}
pub fn maximum_operator<T: PartialOrd>() -> BinaryOperator<T> {
|x: T, y: T| if x >= y { x } else { y }
}
pub fn sum_operator<T: std::ops::Add<Output = T>>() -> BinaryOperator<T> {
|x: T, y: T| x + y
}
pub fn mul_operator<T: std::ops::Mul<Output = T>>() -> BinaryOperator<T> {
|x: T, y: T| x * y
}
Pour l'utiliser ces fonctions rien de plus simple :
let min = find_with_hof(&tab, minimum_operator());
match min {
Some(val) => println!("The minimum value is {}", val),
None => eprintln!("There is no minimum"),
}
Il suffit d'appeler la fonction minimum_operator
qui va nous retourner notre fonction anonyme capable de
déterminer le minimum entre entre deux nombres.
On voit ici tout l'intérêt de nos fonctions d'ordre supérieur. Il me suffit d'écrire une seul fois le code qui réduit mon tableau et je peux choisir mon opération en lui passant simplement le comportement en argument, comme si c'était une variable quelconque.
Closures
En Rust, les fonctions anonymes se nomment closures. On recontre parfois la traduction fermetures. Les closures ne sont pas simplement des fonctions sans identifiant, en effet, une closure capture son environement. Une closure est composée d'une fonction anonyme et des variables capturée dans son environnement.
Voici un exemple de code qui illustre le concept de caputre :
fn main(){ let a : i32 = 50; let divide_by_a = |x:i32| x / a; println!("{}", divide_by_a(100)); }
Ici nous avons une variable a
qui contient un i32
et une variable divide_by_a
qui contient une closure qui
prend un i32
en argument et retourne un i32
. Ce qu'il faut remarquer, c'est que la variable a
est capturée
par la closure. Ce qui siginifie que si je passais la variable divide_by_a
à une fonction, la variable a
serait
elle aussi passée indirectement.
Jusqu'à maintenant, nous nous sommes contentés de passer en argument des closures qui ne capturaient aucune variable.
Pour passer par exemple notre closure divide_by_a
qui capture une variable, il nous faudra utiliser par exemple le trait
Fn
. Sans entrer dans les détails, c'est l'un des trois traits
qu'implémentent toutes les closures. Nous ne verrons pas les deux autres dans le cadre de ce cours, mais nous avons
FnOnce
et FnMut
Modifions donc notre code pour ajouter une fonction :
fn do_stuff<F: Fn(i32) -> i32>(op: F) -> i32 { op(100) } fn main(){ let a: i32 = 50; let divide_by_a = |x: i32| x / a; println!("{}", do_stuff(divide_by_a)); }
Dans le code ci-desssus, on peut voir que la fonction do_stuff
prends un argument appelé op
de type générique F
.
Notre type générique F
est un type implémentant Fn(i32) -> i32
, c'est à dire une fonction qui prend
en argument un i32
et retourne un i32
. Il ne faut surtout pas confondre fn
qui est un mot clé du langage et Fn
,
qui est un trait décrivant entre autre une closure qui capture des éléments de son environnement.
Exemples d'utilisation avec les options
Les options proposent également des méthodes qui prennent en argument d'autres fonctions. Nous pouvons en voir deux dans notre code.
- map
- filter
La fonction map permet de transformer le contenu d'une option si celle-ci n'est pas None, ou de ne rien faire dans le cas contraire. Prenons l'exemple suivant :
let two: f32 = 2.0f32;
let sum: Option<i32> = find_with_hof(&tab, sum_operator());
let half: Option<f32> = sum.map(|x: i32| (x as f32) / two);
match half {
Some(val) => println!("The sum of the elements divided by two is {}", val),
None => eprintln!("There is no sum"),
}
Dans le code ci-dessus, nous pouvons voir un exemple d'utilisation de la méthode map. Nous récupérons tout d'abord
la somme des éléments du tableau, sous forme d'option. Ensuite, pour transformer cette option, on utilise une closure,
qui permet de diviser un i32
par deux et qui retourne un f32
. On transforme donc une Option<i32>
en Option<f32>
.
Si la méthode find_with_hof
retourne une option vide, alors l'option retournée par map reste None
.
let max_val: Option<i32> = find_with_hof(&tab, max_op);
let odd_max: Option<i32> = max_val.filter(|x| x % 2 == 1);
match odd_max {
Some(_) => println!("The maximum value is an odd number"),
None => {
if max_val.is_some() {
println!("The maximum value is an even number")
} else {
eprintln!("There is no maximum")
}
}
}
Ici, nous pouvons voir un exemple d'utilisation de la méthode filter. Nous cherchons le plus grand élément du tableau.
Ensuite, nous essayons de déterminer sa parité à l'aide d'une closure qui retourne true
si le nombre est impaire.
Ici, nous avons 2 étapes :
- La fonction
find_with_hof
retourne une optionmax_val
- Sur
max_val
, nous appliquons un filtre, ce qui nous donneodd_max
. Nous avons donc 3 cas possibles- Si
odd_max
contient une valeur et que cette valeur est impaire, on affiche un message qui annonce que le maximum est impaire. - Sinon si l'
max_val
contient une valeur, on affiche un message qui annonce que le maximum est paire. - Sinon,
max_val
estNone
et donc il n'y a pas de maximum.
- Si
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 :
- Traitements des collections avec de itérateurs en Rust
- Le trait Iterator en Rust
- Les méthodes du trait Iterator
Discussion
Le trait itérateur
L'itérateur est un patron de conception extrêment répandu en prorgrammation orientée objet. On le retrouve dans plusieurs langages comme par exemple python.
Le trait Iterator
est défini ainsi en version simplifiée dans la documentation :
#![allow(unused)] fn main() { trait Iterator { type Item; fn next(&mut self) -> Option<Self::Item>; } }
Un itérateur possède donc un élément courant et une méthode next()
qui permet de passer à l'élément suivant,
puis de retourner l'élément courant. Un itérateur peut-être fini ou infini. Il peut permettre par exemple de
parcourir une collection ou de créer un compteur.
La documentation Rust offre un exemple d'implémentation d'itérateur assez accessible.
Les fonctions iter
et collect
Le Rust propose des méthodes permettant de passer d'une collection à un itérateur assez facilement.
fn main(){ let v : Vec<i32> = vec![1,2,3,4]; let mut v_iter = v.iter(); println!("{}", v_iter.next().unwrap()); println!("{}", v_iter.next().unwrap()); println!("{}", v_iter.next().unwrap()); println!("{}", v_iter.next().unwrap()); assert!(v_iter.next().is_none()); }
La méthode iter()
des vecteurs permet de créer un itérateur à partir d'un vecteur. Il faut noter que v_iter
est mutable, car son état interne est modifié par l'appel à la méthode next()
.
En effet, pour pouvoir avancer dans notre itérateur, nous utilisons la fonction next()
qui avance l'élément courant d'une position
et retourne une Option
sur l'élément courant. Pour rappel, la signature de la méthode de next()
est fn next(&mut self) -> Option<Self::Item>;
.
Il est aussi possible de transformer un itérateur en collection à l'aide de la méthode collect()
. La fonction from_fn()
permet
de créer un itérateur à l'aide d'une fonction :
fn main(){ let mut count = 0; let even_counter = std::iter::from_fn(|| { count += 2; Some(count) }); let v : Vec<i32> = even_counter.take(3).collect(); println!("Les 3 premiers nombres paires sont {}, {}, {}", v[0], v[1], v[2]) }
Le code ci-dessus commence par créer un itérateur infini de nombres paires. Nous verrons plus-tard que les éléments de l'itérateur infini n'est pas généré immédiatement
dans la section lazy-evaluation.
Nous utilisons une variable mutable count
, afin de générer les valeurs de notre itérateur.
Notre closure va capturer cette variable et l'incrémenter de 2 à chaque appel et retourner la valeur courante encapsulée dans une Option
.
Notre itérateur étant infini, nous devons le limiter à un nombre fini d'éléments avant de pouvoir récupérer une collection.
Pour cela nous utilisons la méthode take()
qui permet de limiter la taille de notre itérateur. Finalement, nous pouvons
récupérer un vecteur d'entier à l'aide de la méthode collect()
.
Quelques fonctions d'ordre supérieur sur les itérateurs
L'intérêt principal des itérateurs en Rust réside dans ses fonctions d'ordre supérieur. Elle permettent de traiter des collections de manière efficaces en apportant des garanties notament en terme de concurrence. On retrouve notament le crate Rayon-rs qui permet d'effectuer des traitement en parallèle sans apporter de modifications majeures au code.
Ici, nous nous concentrerons sur les méthodes suivantes qui sont très communes dans les langages fonctionnels (pas toujours avec les mêmes noms) :
map()
filter()
fold()
zip()
Reprenons notre code de recherche du minimum d'une liste :
pub fn find_minimum(v: &Vec<i32>) -> Option<i32> {
v.iter().fold(None, |acc, current| {
let next_acc = if let Some(val) = acc {
if val <= *current {
val
} else {
*current
}
} else {
*current
};
Some(next_acc)
})
}
Notre fonction prend en argument un vecteur v
que nous transformons en itérateur avec la méthode iter()
. Pour trouver le minimum
de notre itérateur, nous utilisons la méthode fold()
. Cette méthode permet de réduire un itérateur en appliquant itérativement,
une fonction qui prend deux arguments et qui retourne un seul élément. On se retrouve donc avec une
seule valeur à la fin de la réduction.
La méthode fold()
prend donc deux arguments. Le premier est la valeur d'initialisation de notre réduction. On parle parfois d'élément neutre,
mais ce terme est plus restrictif qu'une simple valeur initiale.
L'élément neutre d'une opération est l'élément N
qui si on lui applique l'opération avec n'importe quel autre élément x
retourne
cet élément x
. Si nous prenons l'exemple de l'addition, l'élément neutre est 0, car :
$$ 0 + x = x + 0 = x $$
et ce peu importe la valeur de x. Il est préférable d'utiliser un élément neutre, plutôt qu'une valeur quelconque pour l'initialisation
de notre réduction. Sans rentrer dans les détails, qui dépassent le cadre de ce cours, les algoritmes parallèles de réduction reposent
sur l'usage d'un élément neutre. Utiliser un élément neutre, vous permettra donc de parallèliser votre code bien plus simplement.
En l'occurence puisqu'on veut retourner une option, notre élément neutre est None
.
Le deuxième argument de notre fonction fold()
est l'opération de réduction que nous utiliserons pour trouver le minimum. Pour cela nous
utilisons une closure. Cette dernière prend deux argument, un accumulateur (la valeur courante de la réduction) de type Option<i32>
et un l'élément courant de l'itérateur qui est de type &i32
. Notre closure va simplement retourner une Option contenant l'élément le
plus petit entre la valeur actuelle de l'accumulateur et la valeur courante. Cette valeur devient la nouvelle valeur de l'accumulateur
pour l'appel suivant.
Le résultat de la méthode fold()
est la dernière valeur de l'accumulateur. Si l'itérateur est vide, la méthode fold()
retournera la valeur
d'initialisation. Dans notre cas, il s'agit d'une Option
vide.
Pour illustrer l'usage de la méthode filter()
, nous avons une fonction qui trouve le plus petit nombre pair dans un vecteur :
pub fn find_even_minimum(v: &Vec<i32>) -> Option<i32> {
v.iter().filter(|i| *i % 2 == 0).fold(None, |acc, current| {
let next_acc = if let Some(val) = acc {
if val <= *current {
val
} else {
*current
}
} else {
*current
};
Some(next_acc)
})
}
La méthode est similaire à la recherche du minimum, mais afin de garder uniquement les nombres pairs on ajoute la fonction filter()
. Quand on ajoute ainsi
une série de traitements à la suite, on parle de pipelines.
La fonction filter()
prend une fonction en paramètre appelée prédicat. Un prédicat et une fonction qui prend un élément et retourne un booléen.
Cette fonction va déterminer quels éléments conserver. Ici nous utilisons une closure qui retourne true
si le nombre est pair. La méthode filter()
va retourner un nouvel itérateur composé uniquement des éléments séléctionnés par le prédicat.
Pour finir, on recherche la valeur minimum de ce nouvel itérateur, de la même façon que dans l'exemple
précédent, à l'aide de la méthode fold()
.
Essayons maintenant de résoudre un problème un peu plus complexe en ajoutant les méthodes zip()
et map()
.
Nous aimerions trouver quel est l'élément le plus petit en valeur absolue d'un vecteur donné.
pub fn find_absolute_minimum(v: &Vec<i32>) -> Option<i32> {
let signs = v.iter().map(|i| i.signum());
let abs_values = v.iter().map(|i| i.abs());
signs
.zip(abs_values)
.fold(None, |acc, (c_sign, c_abs_v)| {
let next_acc = if let Some((sign, abs_v)) = acc {
if abs_v <= c_abs_v {
(sign, abs_v)
} else {
(c_sign, c_abs_v)
}
} else {
(c_sign, c_abs_v)
};
Some(next_acc)
})
.map(|(sign, abs_v)| sign * abs_v)
}
La première étape consiste à créer deux itérateurs. Le premier contient le signe de chaque élément et le deuxième, la valeur absolue de chaque élément.
let signs = v.iter().map(|i| i.signum());
let abs_values = v.iter().map(|i| i.abs());
Pour obtenir ces itérateurs, nous allons transformer nos itérateurs obtenus avec iter()
en utilsant
la fonction map()
. Cette méthode permet d'appliquer une même transformation sur tous les éléments
d'un itérateur. Pour l'itérateur signs
, on appelle la méthode signum()
des i32
, qui retourne 1 si
le nombre est positif, 0 si le nombre est 0 et -1 si le nombre est négatif. Pour abs_values
,
nous utilisons la méthode abs()
des i32
, qui retourne la valeur absolue d'un nombre.
signs
.zip(abs_values)
Maintenant que nous avons nos deux itérateurs, nous aimerions pouvoir itérer sur les deux simultanément.
Pour cela, nous pouvons utiliser la méthode zip()
. Elle permet de transformer deux itérateurs, en un
unique itérateur de tuple. Ici nous avons deux itérateurs de i32
, qui deviennent donc un seul itérateur
de type tuple (i32, i32)
.
.fold(None, |acc, (c_sign, c_abs_v)| {
let next_acc = if let Some((sign, abs_v)) = acc {
if abs_v <= c_abs_v {
(sign, abs_v)
} else {
(c_sign, c_abs_v)
}
} else {
(c_sign, c_abs_v)
};
Some(next_acc)
})
Ensuite avec fold()
, il nous suffit de comparer les valeurs absolues et de retourner une option contenant
le signe et la valeur absolue.
.map(|(sign, abs_v)| sign * abs_v)
Pour finir, on utilise la méthode map()
de notre Option<(i32,i32)>
pour multiplier
la valeur absolue par le signe. Ce qui nous donne au final une Option<i32>
contenant notre résultat.
Performances et lazy evaluation
Les transformations sur les itérateurs sont en général aussi perfomantes qu'une simple boucle for. On peut voir par exemple ce benchmark dans le livre Rust. Si on ajoute la possibilité de parallèliser facilement un code basé sur les itérateurs, leur intérêt paraît évident.
Un des éléments qui explique les perfomances des itérateurs réside dans la lazy evaluation.
Lorsqu'on appelle une opération de transformation sur un itérateur, la transformation n'est
pas réalisée directement. C'est uniquement lorsqu'on appelle une fonction dite terminale, comme
par exemple fold()
ou collect()
qui doivent produire un résulat. Les transformations intermédiaires
peuvent ainsi souvent être fusionnés.
Ce qui veut dire par exemple que dans le code suivant,
fn main() { let v = vec![0,1,2,3,4,5]; let it = v.iter().map(|x| x + 1).map(|x| x * 3).filter(|x| x % 2 == 1); let res : Vec<i32> = it.collect(); }
que tant que la fonction collect()
n'a pas été appelée, alors aucune transformation n'est effectuée.
Si vous ne nous croyez pas, un moyen simple de vous en convaincre, consiste à appliquer
une série de transformation sur un itérateur infini et de mesurer la performance.
Rustlings
Les rustlings à faire dans ce chapitre sont les suivants:
Les itérateurs
$ rustlings run iterators1
$ rustlings run iterators2
$ rustlings run iterators3
$ rustlings run iterators4
$ rustlings run iterators5
Les collections
Concepts
Les concepts abordés dans cet exemple sont:
Documentation
Afin de compléter ce cours, je vous recommande la lecture des ressources suivantes :
Discussion
En Rust, comme dans la plupart des langages modernes, il existe des structures de données qui permettent de se simplifier la vie lors de l'implémentation de divers algorithmes.
Dans ce chapitre, nous allons discuter des types Vec<T>
, String
, des slices.
Dans ce code nous modifions que très peu le code de la partie 6 afin
d'utiliser les types Vec<i32>
, String
et les slices.
Le type Vec<T>
Le type Vec<T>
est une collection qui permet de stocker des données d'un type unique générique, T
.
Ces données sont stockées dans un espace mémoire qui est garanti d'être contigu. Cet espace mémoire
peut changer dynamiquement à l'exécution contrairement aux tableaux statiques. D'un certain point
de vue, on peut le considérer comme un "pointeur intelligent": un Vec
est un pointeur sur le tas,
qui a une certaine capacité mémoire et sait combien la mémoire sur laquelle il pointe
est pleine.
Dans notre code, nous avons créé une fonction read_command_line()
/// Poorly emulates the parsing of a command line.
pub fn read_command_line(len: usize) -> Vec<i32> {
let mut rng = rand::thread_rng();
let mut v: Vec<i32> = Vec::new();
for _i in 0..len {
v.push(rng.gen());
}
v
}
Ici, un Vec<i32>
est instancié et on ajoute des éléments aléatoires dedans à l'aide de la
crate rand()
.
Pour créer un Vec
vide, on utilise la fonction associée, qui crée un vecteur vide
#![allow(unused)] fn main() { let mut v: Vec<i32> = Vec::new(); }
Ensuite, on remplit le vecteur avec des nombres aléatoires à l'aide
d'une boucle for
for _i in 0..len {
v.push(rng.gen());
}
qui itère sur les indices allant de 0
à len-1
.
Comme, nous n'utilisons pas l'indice, nous l'ignorons à l'aide de l'annotation _i
.
On ajoute des éléments dans le vecteur avec la fonction push()
et comme nous modifions
v
il est primordial de noter qu'il est mut
-able. Le générateur de nombre aléatoire
est stocké dans une variable rng
qui doit elle aussi être mutable.
En effet, les générateurs de nombres aléatoires stockent en général un état interne.
Le type String
Une String
ou un chaîne de caractère, est une séquence de caractères UTF-8
.
On pourrait penser naïvement que ce n'est rien d'autre qu'un Vec<char>
: en fait c'est plus compliqué que cela.
Bien que la chaîne de caractères, soit également rien d'autre qu'un pointeur vers des données sur le tas,
ainsi qu'une variable contenant la capacité de la mémoire sur laquelle pointe le pointeur et son remplissage.
Le problème principal vient de l'encodage UTF-8
: c'est un encodage de taille variable qui englobe les caractères
ASCII (qui sont stockés sur un octet) et une très grande quantité d'autres caractères (l'alphabet grec, des emojis, etc.)
qui sont stockés sur 1 à 4 octets. Ainsi chaque lettre de la chaîne de caractère correspond à un nombre variable d'octets
(ce qui n'est pas le cas pour un Vec
). Il est donc très vivement déconseillé de tenter d'indexer
une String
(faire s[i]
) comme on le ferait avec un Vec
. Il faut plutôt utiliser la méthode .get(i)
qui
interprète les caractères en fonction de la longueur de leurs encodages.
Une illustration de l'utilisation d'une chaîne de caractère se trouve à la fonction
/// Poorly emulates the parsing of a command line.
pub fn read_command_line_str() -> Result<Vec<i32>, String> {
let mut s = String::from("20 10 48 58 29 0 58 -10 39 5485 394");
s.push_str(" -100");
s.push(' ');
s.push('1');
s.push('2');
let s: Vec<&str> = s.split_ascii_whitespace().collect();
let mut v = Vec::new();
for i in 0..s.len() {
v.push(
s.get(i)
.ok_or(String::from("Unable to index"))?
.parse()
.map_err(|_| format!("Unable to parse {}", s[i]))?,
);
}
Ok(v)
}
Cette fonction construit une chaîne de caractères constituées de nombres et d'espaces,
puis la transforme en Vec<i32>
dont on calculera ensuite le minimum.
Une String
est souvent construite à partir d'une "chaîne littérale" à l'aide du trait de conversion From
(on les reconnaît parce qu'elles sont entourées de guillemets, ""
)
let mut s = String::from("20 10 48 58 29 0 58 -10 39 5485 394");
dont le type est str
formellement (c'est une chaîne de caractères qui vit durant toute la durée de vie du programme).
Néanmoins, le type str
n'est jamais utilisé en tant que tel en Rust, mais on utilise plutôt son "slice"
&str
(plus sur les "tranches" dans la section suivante).
Nous voyons que dans le code ci-dessus nous avons déclaré la variable s
comme étant mutable, car ensuite nous ajoutons
une slice de chaîne de caractère (de type &str
) à l'aide de la fonction push_str()
dans s
s.push_str(" -100");
On peut également ajouter des char
(ils sont entourés d'apostrophes ' '
) à l'aide de la fonction push()
s.push(' ');
s.push('1');
s.push('2');
Ensuite cette chaîne de caractères est convertie en Vec<&str>
où chaque élément du Vec
est un mot, qui est une sous chaîne de s
.
let s: Vec<&str> = s.split_ascii_whitespace().collect();
Finalement, dans
let mut v = Vec::new();
for i in 0..s.len() {
v.push(
s.get(i)
.ok_or(String::from("Unable to index"))?
.parse()
.map_err(|_| format!("Unable to parse {}", s[i]))?,
);
}
on crée un nouveau Vec<i32>
dans lequel on ajoute les mots convertis en entiers.
On commence par itérer sur le Vec<&str>
en utilisant les indices de 0
à s.len()-1
(la longueur du Vec
s
).
Puis nous passons à la conversion à proprement parler, bien qu'on fasse des choses un peu compliquées
s.get(i)
.ok_or(String::from("Unable to index"))?
.parse()
.map_err(|_| format!("Unable to parse {}", s[i]))?,
Ici, on commence par récupérer le i
-ème index de s
à l'aide de la méthode get(i)
qui retourne
une Option<&str>
(si i
est un indice valide nous avons Some(s[i])
, sinon None
). Puis, nous transformons
l'option avec ok_or()
(nous encapsulons s[i]
dans un Ok()
si nous avons un Some()
et transformons
None
en 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..y
où y
est non inclus (comme pour la boucle for
avec les indices).
Il existe également une syntaxe sans bornes à gauche, à droite, ou à gauche et à droite.
#![allow(unused)] fn main() { let a = [1, 2, 3, 4, 5, 6, 7, 8]; let b = &a[1..]; // on pointe vers [2, 3, .., 8] let b = &a[..5]; // on pointe vers [1, 2, .., 5] let b = &a[..]; // on pointe vers [1, 2, .., 8] }
Cette syntaxe s'applique également pour toute collection qu'on peut indexer
Le type d'un slice est noté par &[T]
, où T
est un type. On en voit un exemple
lorsqu'on veut afficher un tableau par exemple
pub fn print_tab(tab: &[i32]) {
for t in tab {
print!("{} ", t);
}
println!();
}
ou encore dans la fonction find_min()
pub fn find_min<T: Minimum>(tab: &[T]) -> SomethingOrNothing<T> {
let mut minimum = SomethingOrNothing::Nothing;
// Here is T is Copyable. Which means that t is not moved in the loop
for t in tab {
minimum = minimum.min(SomethingOrNothing::Something(*t));
}
minimum
}
Comme on le voit dans le main()
l'implémentation à l'aide d'un slice dans les fonction
permet une bien plus grande généricité que si on impose un type Vec
, un tableau statique,
ou un slice.
// Vec
let tab: Vec<i32> = io::read_command_line(10usize);
println!("Among the Somethings in the list:");
io::print_tab(&tab);
let min = find_min(&tab);
min.print();
// Slice
println!("Among the Somethings in the list:");
io::print_tab(&tab[1..9]);
let min = find_min(&tab[1..9]);
min.print();
// Array
let tab = [1, 2, 3, 4, 5, 6];
println!("Among the Somethings in the list:");
io::print_tab(&tab);
let min = find_min(&tab);
min.print();
La notation &str
représente ainsi une référence vers un str
qui est une chaîne de caractères
littérale, allouée pour la durée entière d'un programme dans une zone dédiée de la mémoire.
Le type str
étant "immovable" il n'est jamais utilisé tel quel, mais uniquement via des références.
Comme pour le slice utilisé pour généraliser le passage en argument des tableaux,
le slice de string &str
est également utilisé pour généraliser le passage de argument
de chaînes de caractères.
Rustlings
Les rustlings à faire dans ce chapitre sont les suivants:
Les Vec
$ rustlings run vecs1
$ rustlings run vecs2
Les String
$ rustlings run strings1
$ rustlings run strings2
$ rustlings run strings3
$ rustlings run strings4
Lifetimes
Concepts
Les concepts abordés dans cet exemple sont:
Pour plus d'informations sur le pattern NewType
, vous pouvez vous référer aux
chapitres 19.2 et 19.3 du livre.
Pour les lifetimes, il y a le chapitre du livre correspondant,
ainsi que celui du Rustonomicon.
Discussion
Dans ce chapitre, nous allons voir principalement deux concepts différents qui sont importants en Rust
et qu'on retrouve dans beaucoup de code. Les lifetimes, en particulier, sont un sujet complexe
et on verra deux applications différentes mais cela constitue la pointe de l'iceberg des applications
possibles. Le pattern NewType
est lui bien plus simple, et ne nécessite pas une très longue discussion.
Le pattern NewType
Le pattern NewType
très commun en Rust consiste à encapsuler un type externe à une crate, dans un type local
comme dans
#![allow(unused)] fn main() { #[derive(Debug)] pub struct SomethingOrNothing<T>(Option<T>); }
où on encapsule le type externe Option<T>
dans SomethingOrNothing<T>
.
Ce type a un paramètre générique T
et dérive le trait Debug
qui permet
de faire un affichage détaillé (mais pas très joli) du contenu du type.
Ce pattern est nécessaire pour implémenter un trait externe sur un type extern (ce qui est interdit en Rust). Ainsi, il ne nous aurait pas été possible d'implémenter
le trait Display
(qui permet d'afficher une instance du type), Default
(qui
permet de créer une instance par défaut), ou PartialEq
(qui permet de vérifier
l'égalité de deux instance du type) directement pour le type Option<T>
, car
Display
, Default
, et PartialEq
sont des traits externes tout comme le type
Option<T>
. Pour des types externes l'implémentation de traits externes
est interdite (c'est la orphan rule). Cela interdit de "casser" un code externe
en autorisant de multiples implémentations du même trait pour le même type.
Ainsi SomethingOrNothing<T>
nous permet d'implémenter ces trois traits
impl<T: Display> std::fmt::Display for SomethingOrNothing<T> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match &self {
SomethingOrNothing(None) => write!(f, "Nothing.")?,
SomethingOrNothing(Some(val)) => write!(f, "Something is: {}", val)?,
}
Ok(())
}
}
impl<T> Default for SomethingOrNothing<T> {
/// By Default a [SomethingOrNothing] is a nothing.
fn default() -> Self {
SomethingOrNothing(None)
}
}
impl<T: PartialEq> PartialEq for SomethingOrNothing<T> {
fn eq(&self, other: &Self) -> bool {
match (&self, &other) {
(SomethingOrNothing(None), SomethingOrNothing(None)) => true,
(SomethingOrNothing(Some(lhs)), SomethingOrNothing(Some(rhs))) => lhs == rhs,
_ => false,
}
}
}
Nous aimerions attirer votre attention sur une particularité du pattern matching
ici. On voit que nous pouvons faire un match sur des types imbriqués
comme pour SomethingOrNothing(Some(val))
, on va déstructurer les types énumérés
jusqu'à obtenir val
qui est la valeur qui nous intéresse.
Une deuxième utilité du pattern NewType est qu'elle permet de limiter
les fonctionnalités d'un type car cela nécessite de réimplémenter
les méthodes qui lui sont propres. Dans notre cas, seule la méthode unwrap()
de Option<T>
nous intéresse (cette fonction retourne la valeur encapsulée dans
la variante Some()
ou panique si on a un None
). Ainsi, on n'implémente
que celle-là
pub fn unwrap(self) -> T {
self.0.unwrap()
}
On peut noter qu'on a à faire à une struct
avec des membres anonymes.
Ainsi, les membres peuvent être accédés comme pour les tuples et comme il n'y
en a qu'un dans un SomethingOrNothing<T>
, on y accède avec le sélecteur self.0
.
Les lifetimes
Dans cette section nous discutons l'utilisation de l'annotation des lifetimes dans différents cas: les structures, les méthodes, les traits, et les fonctions.
Les structures
Dans notre programme, nous utiliserons le type CustomInt
qui est une représentation d'un entier
qui peut avoir une taille arbitraire (dans les limites de la mémoire de la machine).
#[derive(Debug)]
pub struct CustomInt<'a> {
/// The data contains the unsigned integers that are read from right to left
/// The number 1337 is stored as vec![7, 3, 3, 1]. Each number must be in the range [0,9]
/// and no trailing 0s are allowed.
data: &'a Vec<u8>,
/// Contains the sign of the number +/-1;
sign: i8,
}
Un tel entier est représenté par un signe i8
(qui peut valoir +1
ou -1
), et un
Vec<u8>
, un tableau dynamique de u8
(les valeurs admissibles vont de 0
à 9
),
qui contient les chiffres du nombre stockés de droite à gauche: [7,3,3,1]
est le nombre 1337
.
Ces nombres pouvant être gigantesques, nous voulons éviter de les dupliquer lorsque nous
les copions ou les manipulons. Une solution est de stocker uniquement
une référence vers les données, c'est-à-dire que data
est de type &Vec<u8>
.
On voit dans le code ci-dessus, qu'il est nécessaire d'annoter la durée de vie
de la référence avec 'a
. Si on omet l'annotation de durée de vie le
compilateur nous préviendra et nous oblige à en spécifier un
error[E0106]: missing lifetime specifier
--> src/custom_int.rs:13:11
|
13 | data: &Vec<u8>,
| ^ expected named lifetime parameter
|
help: consider introducing a named lifetime parameter
|
9 ~ pub struct CustomInt<'a> {
10 | /// The data contains the unsigned integers that are read from right to left
11 | /// The number 1337 is stored as vec![7, 3, 3, 1]. Each number must be in the range [0,9]
12 | /// and no trailing 0s are allowed.
13 ~ data: &'a Vec<u8>,
Il est en effet impossible pour le compilateur de savoir si la référence vivra
assez longtemps pour vivre plus longtemps qu'une instance de CustomInt
.
Les méthodes
L'implémentation des méthodes requiert une annotation sous peine d'erreurs
impl<'a> CustomInt<'a>
Il en va de même avec la fonction associée, try_new()
pub fn try_new(data: &'a Vec<u8>, sign: i8) -> Result<Self, String> {
if data.is_empty() {
Err(String::from("Data is empty."))
} else if sign == 1 || sign == -1 {
Ok(CustomInt { data, sign })
} else {
Err(String::from("Invalid sign."))
}
}
qui nécessite
une annotation dans la définition du type de data
. En effet, l'annotation
permet de dire au compilateur que data
, l'argument de try_new()
, vit suffisamment
longtemps pour permettre la création d'une instance de CustomInt
.
Les traits
Il y a plusieurs traits qui sont implémentés pour CustomInt<'a>
.: PartialEq
, Display
, et Minumum
. Pour PartialEq
et Display
, il suffit de renseigner
l'annotation 'a
à l'instruction impl
comme pour un paramètre générique, voir
impl<'a> PartialEq for CustomInt<'a>
impl<'a> std::fmt::Display for CustomInt<'a>
Il est possible d'écrire la même chose, en omettant l'annotation 'a
et en la remplaçant par '_
pour simplifier la notation
impl PartialEq for CustomInt<'_>
Comme l'annotation n'est utilisée nulle par, Rust offre ce sucre syntaxique pour éviter d'écrire trop d'annotations.
Pour le trait Minimum
les choses se compliquent un peu.
Pour éviter le copies/clones, on a fait le choix de
n'utiliser que des références dans les arguments, comme dans le type de retour
du calcul du minimum de deux valeurs
pub trait Minimum<'a> {
fn min(&'a self, rhs: &'a Self) -> &'a Self;
}
Ainsi, comme il y deux références en argument et une référence en sortie,
il est nécessaire d'annoter les références, car sinon le compilateur ne sait pas
avec que durée de vie annoter la sortie (les 2 sont possibles). Ainsi nous annotons
le trait avec la durée de vie 'a
, puis cette durée de vie est utilisée pour toutes
les références dans la fonction min()
. Ainsi toutes les références ont la même
durée de vie que celle annotée dans le trait.
Il y a trois implémentation de ce trait: la première est pour les i32
, la seconde pour SomthingOrNothing<T>
, et finalement pour CustomInt
.
- Pour l'implémentation
pour les
i32
impl<'a> Minimum<'a> for i32 {
fn min(&'a self, rhs: &'a Self) -> &'a Self {
if self < rhs {
self
} else {
rhs
}
}
}
l'implémentation est triviale, il y a uniquement besoin de reprendre
l'annotation pour l'implémentation dans les références en argument
de la fonction et dans le retour de la fonction. En effet, comme les
deux arguments peuvent être retournés, il est nécessaire de préciser
au compilateur que la durée de vie sera la même, sinon il met automatiquement
une durée de vie à chaque argument et reprend celle à &self
comme la durée
de vie de la sortie. En d'autres termes, sans annotations, on aurait
fn min(&self, rhs: &Self) -> &Self
qui serait converti automatiquement par le compilateur en
fn min(&'a self, rhs: &'b Self) -> &'a Self {
if self < rhs {
self
} else {
rhs
}
}
et la durée de vie 'a
du retour est pas compatible avec la durée de vie
qui serait retournée au moment de retourner rhs
(qui est 'b
). Ce qui entraînerait une erreur de compilation (on aime pas ça les erreurs nous).
2. Pour l'implémentation pour SomethingOrNothing<T>
, nous avons un paramètre générique.
impl<'a, T: Minimum<'a> + PartialEq> Minimum<'a> for SomethingOrNothing<T>
{
fn min(&'a self, rhs: &'a Self) -> &'a Self {
match (self, rhs) {
(SomethingOrNothing(None), SomethingOrNothing(None)) => self,
(SomethingOrNothing(Some(l)), SomethingOrNothing(Some(r))) => {
if *l == *l.min(r) {
self
} else {
rhs
}
}
(SomethingOrNothing(None), SomethingOrNothing(Some(_))) => rhs,
(SomethingOrNothing(Some(_)), SomethingOrNothing(None)) => self,
}
}
}
Ainsi nous constatons, que la ligne correspondant à la déclaration
de l'implémentation du trait Minimum
nécessite la déclaration de la durée de vie 'a
, ainsi que du type générique T
. On voit dans l'implémentation
de la fonction min()
, que nous faisons appel à min()
sur le type T
,
et que donc celui-ci doit implémenter le trait Minimum
(tout comme le trait PartialEq
).
On doit ainsi répercuter la durée de vie sur tous les Minimum
présents sur la ligne impl
impl<'a, T: Minimum<'a> + PartialEq> Minimum<'a> for SomethingOrNothing<T>
Nous ne discutons pas l'implémentation à proprement parler qui est assez raisonnable
pour trouver le minimum de deux valeur encapsulées dans un NewType
.
3. Finalement, on a l'implémentation pour CustomInt
qui n'a rien de vraiment nouveau
par rapport aux implémentation précédentes (on réutilise l'annotation 'a
dans min()
directement), à part la complexité monumentale de la fonction (elle fait plein de lignes)
fn min(&'a self, rhs: &'a Self) -> &'a Self {
match self.sign.cmp(&rhs.sign) {
Ordering::Less => return self,
Ordering::Greater => return rhs,
Ordering::Equal => match self.data.len().cmp(&rhs.data.len()) {
Ordering::Less => {
if self.sign == 1 {
return self;
} else {
return rhs;
}
}
Ordering::Greater => {
if self.sign == 1 {
return rhs;
} else {
return self;
}
}
Ordering::Equal => {
for (l, r) in self.data.iter().rev().zip(rhs.data.iter().rev()) {
let ls = (*l as i8) * self.sign;
let rs = (*r as i8) * self.sign;
match ls.cmp(&rs) {
Ordering::Less => return self,
Ordering::Greater => return rhs,
Ordering::Equal => {}
}
}
}
},
}
self
}
En effet, on doit faire attention au signe, à la longueur de notre CustomInt
et
à plusieurs autres joyeusetés. Ici, on peut utiliser le trait Ord
(la fonction cmp()
) pour faire les comparaisons entre le signe et les digits de
nos nombres. Le trait Ord
représente les opérateurs <, >, =, <=, >=
, via la fonction cmp()
qui retourne trois types correspondants
Ordering::Less
Ordering::Greater
Ordering::Equal
L'utilisation d'un type énuméré pour gérer chacun des cas peut sembler verbeux et complexe. Cependant, il permet de garantir à la compilation qu'on a pas oublié de traiter un cas par accident. Et ça, ça n'a pas de prix.
Les fonctions
Finalement, on utilise les lifetimes dans une fonction qui permet
le calcul du minimum dans un tableau et retourne un SomethingOrNothing<&T>
contenant une référence vers l'élément le plus petit.
Cette fonction est générique avec le paramètre T
et prend en argument une référence qui doivent être annotée.
pub fn find_min<'a, T: Minimum<'a>>(tab: &'a [T]) -> SomethingOrNothing<&'a T> {
// A very elegant fold applied on an iterator
tab.iter().fold(SomethingOrNothing::default(), |res, x| {
let r = match res {
SomethingOrNothing(None) => x,
SomethingOrNothing(Some(r)) => r.min(x),
};
SomethingOrNothing::new(r)
})
}
La fonction find_min()
prend en argument un slice de type générique T
qui doit implémenter Minimum
. Comme le type de retour est SomethingOrNothing<&T>
, donc on
encapsule une référence vers la valeur minimale, il est nécessaire d'annoter
les durées de vies car sinon elles auraient deux valeur différentes ce qui poserait problème au compilateur (car elles doivent être les mêmes).
L'implémentation de cette fonction, n'est pas très complexe, mais est très intéressante. En premier lieu, pour des question de généricité de l'implémentation
nous passons en argument un slice de T
: cette façon de procéder permet
d'avoir un argument qui serait une référence vers un tableau statique ou vers un Vec<T>
sans changer l'implémentation. De plus, nous utilisons ici un itérateur
sur le tableau est faisons un fold()
sur cet itérateur. Le fold()
prend en argument
un élément neutre (quel est la valeur initiale stockée dans le fold()
). Ici c'est
SomethingOrNothing::default()
puis une fonction anonyme prenant deux arguments
|res, x| {}
où la valeur retournée par cette fonction écrase la valeur de res
à chaque next()
de l'itérateur et qui doit avoir le même type que l'élément neutre, et où x
est la valeur courante de l'itérateur. Ici, le type de res
est SomethingOrNothing<&T>
et
le type de x
est &T
. La fonction anonyme
let r = match res {
SomethingOrNothing(None) => x,
SomethingOrNothing(Some(r)) => r.min(x),
};
SomethingOrNothing::new(r)
calcule le minimum entre la valeur actuelle stockée dans res
et x
en utilisant
la fonction min()
ce qui implique que T
doit implémenter Minimum
.
En pratique
Dans la fonction main()
de notre programme
pub fn find_min<'a, T: Minimum<'a>>(tab: &'a [T]) -> SomethingOrNothing<&'a T> {
// A very elegant fold applied on an iterator
tab.iter().fold(SomethingOrNothing::default(), |res, x| {
let r = match res {
SomethingOrNothing(None) => x,
SomethingOrNothing(Some(r)) => r.min(x),
};
SomethingOrNothing::new(r)
})
}
on crée un tableau de CustomInt
qui sont créés à partir de références
sur les tableau v1
, v2
, etc. qui vivrons ainsi jusqu'à la fin de notre
programme et qui seront promenées sans qu'on ait besoin de les copier à aucun moment.
Les liens entre les durées de vie des références que nous nous sommes efforcés d'annoter dan tout au long ce code sont vérifiées par le compilateur qui
vérifie qu'elles sont toutes valides à la compilation.
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:
- Tutoriel pour le pattern
builder
- Tutoriel pour le pattern
derive
- Chapitre du livre sur les entrées sorties
- Command Line Applications in Rust
L'interface à la ligne de commande et l'utilisation de librairies externes
Dans cette section nous allons voir une façon différente de lire la ligne de commande (par rapport à ce que nous avons fait dans la partie 07).
Cette façon de faire est trop complexe pour construire une vraie application et rajouterait beaucoup d'efforts à chaque fois qu'on veut en reconstruire une: elle demanderait un parsing long et fastidieux de la ligne de commande manuel. La librairie clap, la librairie de CLI la plus populaire pour Rust, nous permet de de construire une interface pour un programme avec arguments nommés, optionnel, et un menu d'aide de façon élégante et une bonne gestion des erreurs.
Afin d'utiliser une librairie externe, il faut l'ajouter comme dépendance dans le fichier Cargo.toml
de notre projet. Pour ce faire, il y a deux méthodes, et nous allons voir comment cela fonctionne pour clap
:
- Ajouter la ligne
clap = { version = "4.4.0", features = ["derive"] }
sous l'entrée [dependecies]
.
- Utiliser l'outil
cargo
qui le fait pour nous
cargo add clap --features derive
Il y a énormément de fonctionnalités dans l'outil cargo.
Ces deux façons sont équivalentes. Lors de l'ajout manuel, on doit choisir la version manuellement qu'on veut mettre dans
le fichier Cargo.toml
(cela permet de figer une version) ou on peut remplacer 4.4.0
par *
pour avoir toujours
la dernière version de la crate
à utiliser. Cependant cette façon de faire n'est pas recommandée, car
cela peut "casser" la compilation lors d'une mise à jour majeure (ou avoir des effets de sécurité
indésirables).
On note également, qu'on a un champs features
qui est optionnel, mais qui ici est mis à derive
.
Le langage Rust permet d'omettre une partie des fonctionnalités d'une librairies qui sont ajoutées
à l'aide d'annotations lorsque la feature
est activée. Nous n'entrerons pas dans les détails de ces annotations,
mais avons besoin de la feature derive
pour compiler notre code.
Nous pouvons à présent commencer à écrire nos fonctions pour lire la ligne de commande
à l'aide de la librairie clap
. Nous allons voir deux façons différentes
de créer une telle interface avec la librairie: les pattern builder
et derive
(c'est pour
ce dernier que nous avons besoin de la feature derive
).
Le but de cette interface à la ligne de commande est pour l'utilisateur·trice de pouvoir choisir les options suivantes pour notre petit programme de calcul de minimum dans une liste.
- Entrer à la main une liste de nombres.
- Créer une liste de
count
nombres aléatoires qui seront lus depuis/dev/urandom
. - Écrire la liste de nombres et le minimum de la liste dans un fichier de sortie (en en fournissant le nom) ou sur l'écran.
Il faut noter que l'option 1 et 2 son mutuellement exclusives. L'option 3 écrira dans un fichier uniquement si un nom de fichier est fourni par l'utilisateur·trice.
Il est fondamental que si les entrées sont mal formatées (on ne donne pas des nombres p.ex.) ou si on essaie d'utiliser les options 1 et 2 en même temps, on ait un bon traitement de l'erreur et un message d'erreur lisible.
Des tentatives d'exécution typiques seraient
$ cargo run -- --numbers 1 2 3 4 5 6
où on donne une liste de nombres après l'option --numbers
ou encore
$ cargo run -- --output fichier.txt --numbers 1 2 3 4 5 6
où --output
permet de spécifier un nom de fichier. En revanche, on doit avoir une erreur
si on essaie de faire
$ cargo run -- --count 10 --numbers 1 2 3 4 5 6
car on ne veut pas pouvoir générer deux listes de nombres, mais une seule.
Ainsi on a trois arguments possibles et tous sont optionnels, mais deux sont exclusifs.
Le builder
pattern
Nous allons voir à présent comment construire une interface en ligne de commande à proprement parler avec clap
. Pour ce faire et comprendre le fonctionnement interne de la librairie
nous allons d'abord étudier le builder
pattern, qui consiste à construire l'interface
à l'aide de fonctions qui vont construire petit à petit notre application.
La fonction qui va faire tourner notre application se trouve dans src/io.rs
et a la signature suivante
pub fn read_command_line_builder() -> Result<(), String>
On remarque qu'elle ne prend aucun argument en paramètre et qu'elle retourne un Result<(), String>
. En d'autres termes, si tout s'est bien passé, nous ne retournons "rien". Dans l'éventualité
où quelque chose ne s'est pas passé comme prévu, nous retournons une chaîne de caractères
qui contiendra un message d'erreur.
Le création de la "commande" se trouve dans le code
let matches =
Command::new(COMMAND)
.author(AUTHOR)
.version(VERSION)
.arg(
Arg::new("numbers") // id
.short('n') // version courte -n
.long("numbers") // ou longue --numbers
.help("A list of i32 numbers") // l'aide
.num_args(1..) // combien il y a d'entrées
.allow_negative_numbers(true) // on peut avoir des négatifs
.value_parser(value_parser!(i32)) // on veut s'assurer que ça soit des nombres
.required(false), // optionnel
)
.arg(
Arg::new("count")
.short('c')
.long("count")
.help("How many random numbers we want?")
.value_parser(value_parser!(usize))
.conflicts_with("numbers") // impossible d'avoir -c et -n
.required(false),
)
.arg(
Arg::new("output")
.short('o')
.long("output")
.help("Should we write output in a file?")
.required(false),
)
.get_matches();
Ici nous effectuons diverses opérations. Nous commençons par créer une nouvelle commande
dont le nom est cli
1
const COMMAND: &str = "cli";
const AUTHOR: &str = "Orestis Malaspinas";
const VERSION: &str = "0.1.0";
Command::new(COMMAND)
.author(AUTHOR)
.version(VERSION)
avec différents composants optionnels, comme le nom de l'auteur de l'application, sa version, etc. Cela permet maintenant d'ajouter les arguments sur cette application.
Comme discuté plus haut nous voulons trois arguments (numbers
, output
, et count
) qui sont ajouté dans un ordre
qui n'a aucune importance.
Command::new(COMMAND)
.author(AUTHOR)
.version(VERSION)
L'appel à la méthode .arg()
nous permet d'ajouter un nouvel argument, créé avec l'appel à
Arg::new(id)
où id
est une chaîne de caractères qui permet d'identifier de façon unique l'argument.
Puis viennent toutes les propriétés de notre argument:
short('n')
: l'option peut être nommée-n
,long("numbers")
: l'option peut être nommée--numbers
, permettant d'appeler le programme avec
$ cargo run -- --numbers 1 2 3 4 5 6
$ cargo run -- -n 1 2 3 4 5 6
help("A list of i32 numbers")
: le message d'aide si nous appelons
⋊> ~/g/p/r/c/r/cli on 25-cli-i-o ⨯ cargo run -- --help
Finished dev [unoptimized + debuginfo] target(s) in 0.01s
Running `target/debug/cli --help`
Usage: cli [OPTIONS]
Options:
-n, --numbers <numbers>... A list of i32 numbers
-c, --count <count> How many random numbers we want?
-o, --output <output> Should we write output in a file?
-h, --help Print help
-V, --version Print version
num_args(1..)
: qui permet d'avoir plusieurs valeurs dans l'argument et savons qu'il doit y en avoir plus d'un. Sans cet argument, l'appel
$ cargo run -- --numbers 1 2 3 4 5 6
considérerait le 2
comme la valeur d'un autre argument et ne ferait pas partie de numbers
.
Puis viennent encore
allow_negative_numbers(true)
: pour autoriser les nombres négatifs (sinon-
est parsé comme nouvel argument)value_parser(value_parser!(i32))
: on ne veut que desi32
(les nombres à virgules, les lettres, etc sont automatiquement rejetées et un message d'erreur est affiché)required(false)
: est-ce que l'argument est obligatoire (ici ce n'est pas le cas)? En d'autres termes est-ce l'exécution suivante est valide?
$ cargo run -- # sans option numbers
Si l'argument est required(true)
alors il est nécessaire de spécifier l'option sinon on aura un message d'erreur.
Dans la suite du code on crée encore deux arguments count
et output
. Nous avons déjà couvert
les différentes fonctions appelée, à l'exception d'une:
conflicts_with("numbers")
: ici nous spécifions que l'argumentcount
ne peut pas être présent en même temps que l'argumentnumbers
(peu importe l'ordre d'appel). Ainsi si nous essayons d'exécuter
$ cargo run -- --numbers 1 2 3 4 5 6 -c 6
nous aurons le message d'erreur
error: the argument '--numbers <numbers>...' cannot be used with '--count <count>'
Usage: cli --numbers <numbers>...
For more information, try '--help'
Après avoir construit les arguments, nous devons appeler la fonction get_matches()
qui
termine la construction de la commande et vérifie s'il n'y a pas d'arguments qui sont contradictoires (un message d'erreur sera produit à l'exécution si cela est le cas).
Lorsque ce code est exécuté, notre programme peut maintenant parser la ligne de commande lorsqu'il est exécuté. A nous maintenant d'utiliser correctement les différents arguments. Ici, nous devons traiter deux "groupes" d'arguments:
numbers
etcount
qui sont deux options exclusives,output
qui est optionnel également.
Le traitement de
let numbers = if let Some(count) =
matches.get_one::<usize>("count")
{
read_from_urandom(*count)?
} else if let Some(numbers) =
matches.get_many::<i32>("numbers")
{
numbers.copied().collect()
} else {
Vec::new()
};
crée la liste de nombre que nous voulons avoir pour calculer le minimum.
Comme les deux arguments sont optionnels, nous voyons que pour les déstructurer,
il faut passer par une construction if let Some() = ...
.
Dans le cas de l'argument count
nous savons que nous voulons un usize
dont l'identifiant
est "count"
. Si l'argument est présent,
matches.get_one::<usize>("count")
retourne Some(&usize)
(nous obtenons une référence vers l'argument)
et nous appellerons la fonction read_from_urandom()
(que nous discuterons plus bas). Sinon, nous devons vérifier si l'argument numbers
est présents
et quelles valeurs lui sont assignées. Ainsi, si l'argument est présent
matches.get_many::<i32>("numbers")
retournera un nombre arbitraire de références d'entiers, qui seront ensuite
transformés en Vec<i32>
à l'aide de la ligne
numbers.copied().collect()
qui commence par faire une copie des valeurs de la liste de références pour pouvoir
en devenir les propriétaires, puis les met dans un Vec<i32>
(il faut noter que le type
de numbers
est inféré grâce au reste du code). A la fin de ce if let Some() = ...
nous retournons un Vec<i32>
(qui peut être vide)
qui sera utilisé dans la suite de la fonction.
Il nous reste à décider si nous allons écrire les sorties de notre programme (la liste de nombre et son minimum) dans un fichier ou dans la sortie standard à l'aide du code
if let Some(output) =
matches.get_one::<String>("output")
{
write_to_file(output, &numbers)?;
} else {
println!("Among the Somethings in the list:");
print_tab(&numbers);
println!("{}", find_min(&numbers).to_string());
}
Rien de très nouveau ici, si "output"
est présent
matches.get_one::<String>("output")
nous pouvons déstructurer le retour de la fonction et obtenir le nom du fichier
dans lequel nous allons écrire dans la fonction write_to_file()
ou le cas échant
écrire dans la sortie standard .
Gestion d'erreur un peu simplifiée
Aussi bien les appels à read_from_urandom()
que write_to_file()
sont suivis d'un ?
.
Ces fonctions doivent manipuler des fichiers et peuvent donc échouer à n'importe quel moment
(si le fichier n'existe pas, s'il ne peut être créé, etc). Elles retournent
donc des Result
. L'opérateur ?
en Rust est très pratique. Il est utilisé pour répercuter
les erreurs de façon courte dans les fonctions. En gros il répond à la question:
"Est-ce que la fonction a retourné Ok()
ou Err()
?" Si la réponse est Ok()
il retourne ce
qui est contenu dans le Ok()
qui peut être assigné à une variable (ou retourné de la fonction).
En revanche si la réponse est Err()
, on retourne l'erreur de la fonction courante.
Cet opérateur permet d'éviter d'alourdi le code avec du pattern matching à chaque appel
qui peut échouer et est très utilisé dans le code Rust.
Le derive
pattern
Nous avons vu en grand détail comment construire une commande avec un builder
design. Nous allons voir à présent de façon très succincte comment faire la même
chose avec le pattern derive
. Ici, tout le code écrit plus haut sera généré
pour nous à l'aide de macro
Rust, et nous avons uniquement besoin de spécifier
ce qui doit être généré. Afin de créer une interface en ligne de commande nous devons uniquement
créer une struct
annotée
#[derive(Parser)]
#[command(author, version, about, long_about = None)]
struct CliMin {
#[arg(short, long, help = "A list of i32 numbers", num_args=1.., allow_negative_numbers=true, value_parser = clap::value_parser!(i32))]
numbers: Option<Vec<i32>>,
#[arg(short, long, help = "How many random numbers we want?", value_parser = clap::value_parser!(usize), conflicts_with = "numbers")]
count: Option<usize>,
#[arg(short, long, help = "Filename for writing the numbers.")]
output: Option<String>,
}
qui contient différent champs qui sont annotés (ou pas).
La ligne
#[derive(Parser)]
va dire au compilateur de Rust de générer automatiquement tout le parsing d'arguments
en fonction de ce qu'il va trouver dans la structure en dessous, ici CliMin
.
Puis vient la commande préprocesseur
#[command(author, version, about, long_about = None)]
qui va créer la nouvelle commande à proprement parler. Elle correspond à
Command::new(COMMAND)
.author(AUTHOR)
.version(VERSION)
A la différence de cette ligne où on spécifie explicitement l'auteur, etc., ici,
le contenu du champs author, version, etc. est directement récupéré du Cargo.toml
.
Ensuite la structure CliMin
possède trois membres: numbers
, count
, et output
qui sont annotés avec un #[arg()]
.
#[arg(short, long, help = "A list of i32 numbers", num_args=1.., allow_negative_numbers=true, value_parser = clap::value_parser!(i32))]
numbers: Option<Vec<i32>>,
Cette annotation va dire au compilateur de générer
automatiquement le code pour toutes les paires clés valeurs se trouvant entre les parenthèses.
Si une valeur est absente alors un comportement par défaut est appliqué.
Ainsi, long
va générer automatiquement que l'option en version longue pour la variable numbers
est --numbers
. De façon similaire, par défaut la version short
de
numbers sera -n
(la première lettre de numbers
). Cette façon de faire par défaut permet de réduire la quantité de code. Par contre, elle est également dangereuse, car si deux
champs commencent par le même nom, seul le premier aura le short
qui lui correspondra.
Le reste des arguments correspondent à toutes les méthodes de la version builder
vues dans le chapitre précédent. Il y a deux grandes différences:
- On ne trouve pas
d'équivalent à
required(false)
. En fait, l'obligation ou non de spécifier un argument est directement inféré par le type de l'argument: si c'est uneOption
alors l'argument est... optionnel. numbers
est directement parsé enVec<i32>
, pas besoin de faire des conversions.
Le reste du code est relativement trivial. Pour utiliser notre interface en ligne de commande
pub fn read_command_line_derive() -> Result<(), String> {
let cli = CliMin::parse();
let numbers = if let Some(count) = cli.count {
read_from_urandom(count)?
} else if let Some(numbers) = cli.numbers {
numbers
} else {
Vec::new()
};
if let Some(output) = cli.output {
write_to_file(&output, &numbers)?;
} else {
println!("Among the Somethings in the list:");
print_tab(&numbers);
println!("{}", find_min(&numbers).to_string());
}
Ok(())
}
Il faut appeler la fonction parse()
let cli = CliMin::parse();
et les champs cli.cout
, cli.numbers
, et cli.output
seront automatiquement assignés
aux valeurs dans la cli
si elles sont compatibles avec les formats spécifiés dans les
arguments correspondants. Sinon des erreurs seront affichées.
Les entrées / sorties
Il y a deux fonctions qui gèrent la lecture / écriture de fichiers dans notre programme.
- La fonction
read_from_urandom()
permet de lire le fichier/dev/urandom
qui contient dans les systèmes unix des "vrais" nombres aléatoires générés par le bruit du système. - La fonction
write_to_file()
qui permet d'écrire la liste de nombre, ainsi que le minimum dans un fichier. Nous allons brièvement discuter ces fonctions, afin de comprendre un peu mieux comment faire des entrées / sorties depuis le disque en Rust.
Lecture de fichier
Pour la lecture de fichier, nous nous concentrons sur la fonction
fn read_from_urandom(count: usize) -> Result<Vec<i32>, String> {
let file = File::open("/dev/urandom").map_err(|_| "Could not open /dev/urandom")?;
let mut buf_reader = BufReader::new(file);
let mut numbers = vec![0; count * 4];
buf_reader
.read_exact(&mut numbers)
.map_err(|_| "Could not read numbers")?;
Ok(numbers
.chunks(4)
.map(|i| i32::from_be_bytes(i.try_into().unwrap()))
.collect::<Vec<_>>())
}
L'ouverture d'un fichier en lecture se fait avec la fonction File::open()
qui peut échouer si le fichier n'existe pas par exemple. Dans le cas d'une
erreur, nous nous empressons de convertir l'erreur dans un message d'erreur
avec la fonction map_err()
qui prend en argument une fonction anonyme qui a pour argument
ce qui est encapsulé dans le type Err()
et qui retourne une nouvelle valeur qui sera
encapsulée dans une nouvelle Err()
. Cette façon de faire n'est pas très idiomatique
pour Rust, mais elle nous satisfait pour le moment, afin d'avoir des types de retour homogènes
et de pouvoir utiliser l'opérateur ?
(voir plus haut).
Comme nous lisons dans le fichier /dev/urandom
qui est un flux continu d'octets, nous définissons
une mémoire tampon sur le fichier et allons lire exactement 4 * count
fois octets, soit
exactement l'équivalent de count
entiers 32-bits soit i32
let mut buf_reader = BufReader::new(file);
let mut numbers = vec![0; count * 4];
buf_reader
.read_exact(&mut numbers)
.map_err(|_| "Could not read numbers")?;
Finalement, notre mémoire tableau numbers
(qui est rien d'autre qu'une suite d'octets)
est convertie en Vec<i32>
grâce à la puissance des itérateurs.
Ok(numbers
.chunks(4)
.map(|i| i32::from_be_bytes(i.try_into().unwrap()))
.collect::<Vec<_>>())
Dans un premier temps
le tableau est découpé en tranches de 4 éléments grâce à la méthode .chunks(4)
(l'itérateur est maintenant une suite
d'itérateurs de 4 éléments). Puis chacun des éléments de l'itérateur (soit 4 octets)
est transformé en i32
, grâce à la méthode map(|i| i32::from_be_bytes(i.try_into().unwrap()))
.
Il faut noter qu'ici nous utilisons la fonction try_into()
qui peut échouer si nous n'avons pas
4 octets à disposition quand nous faisons la conversion. Ici, par construction cela ne peut pas se produire et pouvons unwrap()
le résultat. Finalement, à l'aide de collect()
,
nous créons un Vec<i32>
à partir de l'itérateur obtenu et l'encapsulons dans un Ok()
,
car le résultat de notre fonction est un succès, si tout s'est bien passé.
Écriture dans un fichier
Pour l'écriture dans un fichier, nous nous concentrons sur la fonction
fn write_to_file(output: &str, numbers: &[i32]) -> Result<(), String> {
let mut file = File::create(output).map_err(|_| format!("Failed to create {output}"))?;
writeln!(file, "Among the Somethings in the list:")
.map_err(|_| "Failed to write header into file.")?;
for n in numbers {
write!(file, "{n} ").map_err(|_| format!("Failed to write {n} into file."))?;
}
writeln!(file,).map_err(|_| "Failed to write carriage return into file.")?;
writeln!(file, "{}", find_min(numbers).to_string())
.map_err(|_| "Failed to write minimum value into file.")?;
Ok(())
}
Nous commençons par créer un fichier à l'aide de la fonction File::create()
qui prend en argument le chemin où créer le fichier.
let mut file = File::create(output).map_err(|_| format!("Failed to create {output}"))?;
Si un fichier existe déjà
il est écrasé par défaut. Si la création est impossible, une erreur est retournée.
De plus le fichier est ouvert en mode écriture. Il faut noter que nous allons modifier le fichier, et il est donc mutable.
Ainsi, nous pouvons écrire dans le fichier à l'aide des macros write!
et writeln!
qui s'utilisent comme print!
et println!
à l'exception qu'elles prennent
des fichier en argument et retournent une erreur en cas d'échec.
writeln!(file, "Among the Somethings in the list:")
.map_err(|_| "Failed to write header into file.")?;
for n in numbers {
write!(file, "{n} ").map_err(|_| format!("Failed to write {n} into file."))?;
}
writeln!(file,).map_err(|_| "Failed to write carriage return into file.")?;
writeln!(file, "{}", find_min(numbers).to_string())
.map_err(|_| "Failed to write minimum value into file.")?;
A nouveau, toutes les erreurs sont transformées en messages (des chaînes de caractères) pour simplifier les concepts abordés dans ce code.
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:
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:
- À la section 19.1 du livre.
- Au Rustonomicon.
Pour illustrer les concepts unsafe
, nous allons discuter de l'une des structures de données
les plus "simples" de l'informatique, mais qui est très difficile à implémenter en Rust: la liste simplement chaînée.
Une discussion très détaillée de l'implémentation de la liste chaînée se trouve sur
l'excellent Learn Rust With Entirely Too Many Linked Lists.
Rappelez vous également que la liste simplement chaînée est une structure de donnée permettant de représenter
une pile (structure de donnée abstraite bien connue).
Pour commencer, nous allons voir son implémentation sûre, et les efforts qu'il faut consentir pour faire l'implémentation,
puis une implémentation unsafe
ou pas sûre.
La liste chaînée
Pour simplifier, nous allons nous limiter à l'implémentations de 6 fonctionnalité dans notre liste chaînée:
- La fonction
new()
qui crée une nouvelle liste. - La fonction
is_empty()
qui nous dit si la liste est vide. - La fonction
push()
qui ajoute un élément en tête de liste. - La fonction
pop()
qui retire l'élément de tête de la liste et retourne la valeur stockée. - La fonction
print()
qui affiche tous les éléments de la liste.
Avant de voir l'implémentation de ces fonctions, nous devons définir la structure de données utilisée pour la liste chaînée. Pour rappel, une liste chaînée est une suite de nœuds ou éléments qui sont reliés entre eux par des pointeurs:
val1 --> val2 --> val3 --> fin
Ainsi chaque élément contient les données et un pointeur vers l'élément suivant. En Rust, on serait donc tenté de faire
#![allow(unused)] fn main() { struct Element { data: i32, next: Element, } }
Comme pour le C
cette construction ne peut pas fonctionner, car nous avons à faire à un type récursif et dont on ne peut connaître la taille à la compilation.
error[E0072]: recursive type `Element` has infinite size
--> src/main.rs:3:1
|
3 | struct Element {
| ^^^^^^^^^^^^^^
4 | data: i32,
5 | next: Element,
| ------- recursive without indirection
|
help: insert some indirection (e.g., a `Box`, `Rc`, or `&`) to break the cycle
|
5 | next: Box<Element>,
| ++++ +
For more information about this error, try `rustc --explain E0072`.
error: could not compile `playground` (bin "playground") due to previous error
Ainsi, comme nous le recommande le compilateur, il faut utiliser un Box
(voir le chapitre 10) pour faire une allocation sur le tas de next
.
#![allow(unused)] fn main() { struct Element { data: i32, next: Box<Element>, } }
Nous ne sommes pas encore sortis d'affaire, car notre type élément ne permet pas de représenter la fin de la chaîne. Il faudrait que
next
soit un élément suivant soit pas d'élément suivant (indiquant ainsi la fin de la chaîne). Mais on connaît un tel type non?
Le type Option<T>
(voir le chapitre 7) fait exactement ce que nous voulons: la variante Some(element)
indique la présence
d'un élément suivant, alors que la variante None
indique son absence.
#![allow(unused)] fn main() { struct Element { data: i32, next: Option<Box<Element>>, } }
Il ne nous reste plus qu'à créer la structure de liste chaînée, qui va juste avoir la forme suivante:
pub struct LinkedList {
head: Option<Box<Element>>,
}
Et juste posséder la tête de la liste chaînée.
La philosophie générale de l'implémentation purement safe
de la liste chaînée est qu'on ne va jamais emprunter les instances de la liste chaînée, mais toujours
en prendre la propriété. Cela aura des effets non négligeables sur l'ergonomie du code (qui n'est pas forcément nécessaire), mais que l'on accepte à des fins éducatives.
La fonction associée new()
La fonction new()
est triviale à écrire
pub fn new() -> Self {
Self { head: None }
}
Il faut juste noter que new()
retourne une nouvelle instance de LinkedList
dont la tête est None
(il n'y a aucun élément dans la liste).
La méthode is_empty()
La méthode is_empty()
est un poil moins triviale à écrire. Pour déterminer si la liste est vide, il faut juste vérifier que que l'instance
de la liste chaînée à head
soit égale à None
. On peut faire cela avec du pattern matching
match self.head {
None => true,
_ => false,
}
Mais cela ne suffit pas complètement, car il faut également retourner self
(l'instance de la liste chaînée sinon elle serait détruite à la fin de is_empty()
).
Ainsi on retourne non seulement le booléen qui nous dit si la liste est vide ou non, mais également la list (self
).
En fait, on peut faire beaucoup plus court et pratique en utilisant la
méthode is_none()
de la librairie standard de Rust. Mais cela reviendrait à
emprunter self
ce qui contreviendrait au principe qu'on s'est fixé pour cette implémentation
un peu étrange.
pub fn is_empty(self) -> (bool, Self) {
match self.head {
None => (true, self),
_ => (false, self),
}
}
La méthode push()
La fonction push(value)
doit créer un élément à placer en tête de liste qui aura comme next
l'ancienne tête de liste.
Il y a différentes façons de l'implémenter. Ici, nous allons prendre possession de l'instance de la liste, et retourner
une nouvelle liste avec le nouvel élément ajouté
pub fn push(self, data: i32) -> Self {
let elem = Box::new(Element::new(data, self.head));
Self { head: Some(elem) }
}
Dans cette implémentation on voit que self
est "move" dans la fonction push
(on prend en argument self
et non &self
ou &mut self
). Puis ont crée un nouvel élément
contenant data
et la tête actuelle de la liste. Le nouvel élément elem
a donc
la propriété de la mémoire de self.head
.
La fonction pop()
La fonction pop()
pub fn pop(self) -> (Option<i32>, Self) {
if let Some(elem) = self.head {
(Some(elem.data), Self { head: elem.next })
} else {
(None, Self { head: None })
}
}
retourne une Option<i32>
avec éventuellement une valeur si la liste n'est pas vide, et retourne une nouvelle liste où la tête
est soit l'élément suivant soit une liste vide (si on est à la fin de la liste).
Il faut noter la syntaxe if let Some(elem) = self.head ... else
qui permet de se passer
du pattern matching et raccourcir sensiblement le code.
La fonction print()
Finalement, la fonction print()
est probablement la plus étrange de toutes.
pub fn print(self) -> Self {
let mut new_list = Self::new();
let mut current = self.head;
while let Some(tmp) = current {
print!("{} --> ", tmp.data);
new_list = new_list.push(tmp.data);
current = tmp.next;
}
println!("∅");
new_list
}
En effet, on aimerait ne pas avoir à détruire notre liste en entier lorsque nous la parcourons.
Ainsi, la fonction print()
prend en argument self
et retourne Self
.
Cette fonction va créer une nouvelle instance de LinkedList
mutable,
puis parcourir tous les éléments de la liste self
à l'aide de la boucle
let mut current = self.head;
while let Some(tmp) = current {
print!("{} --> ", tmp.data);
new_list = new_list.push(tmp.data);
current = tmp.next;
}
println!("∅");
où on va parcourir la liste en "consommant" les éléments: lors de l'assignation
de current
à tmp.next
, l'ancienne valeur de current
sort de la portée et est détruite automatiquement. Ainsi, il est nécessaire de push()
la valeur tmp.data
sur la liste nouvellement créée précédemment new_list
. Dans la boucle, nous affichons
également chaque valeur avec un formatage astucieux. Finalement,
nous retournons la nouvelle liste.
Les défauts de cette implémentation
On constate tout d'abord que cette implémentation n'est pas très ergonomique.
En effet, à chaque utilisation de la liste, il faut toujours faire un assignation
à une nouvelle liste ce qui est encore acceptable pour push()
. En revanche pour
pop()
ou print()
on voit clairement que c'est pas pratique du tout.
Un autre problème plus complexe apparaît et concerne la libération de la mémoire
de notre liste chaînée. Il s'avère que la libération automatique est impossible
à faire en garantissant qu'on ne va pas faire exploser la pile. Comme expliqué
sur ce lien
la libération de la mémoire se fait de façon récursive. Ainsi, on n'a aucune garantie
que la pile d'appel de la fonction de libération de la mémoire (drop()
) par le compilateur
ne va pas dépasser sa capacité.
fn main() {
// Exemple de stack overflow
let mut immutable_list = ImmutableList::new();
for i in 0..1_000_000 {
immutable_list = immutable_list.push(i);
}
}
Si vous compilez et exécutez ce programme, vous constaterez probablement une erreur de type stack overflow.
Vous me direz qu'il suffit d'implémenter le trait Drop
comme dans "Too many linked
lists..." mais... cela ne fonctionne pas non plus pour des raisons trop complexes à
expliquer ici. La seule solution est d'implémenter une fonction vidant la liste et de l'appeler à la main et perd un peu la raison d'être du Rust.
pub fn clear(self) {
let mut current = self.head;
while let Some(tmp) = current {
current = tmp.next;
}
}
fn main() {
// Exemple de stack overflow
let mut immutable_list = ImmutableList::new();
for i in 0..1_000_000 {
immutable_list = immutable_list.push(i);
}
immutable_list.clear();
}
Le Rust unsafe
Tous les efforts du chapitre précédent peuvent être largement évités
avec un tout petit peu de Rust unsafe
. Le Rust un peu moins sûr
nous permet de relaxer certaines contraintes du compilateur et d'écrire un code
plus joli tout en annotant les régions problématiques. Nous créons ici
deux codes différents:
- Un code
safe
qui est inspiré de Learn Rust With Entirely Too Many Linked Lists mais qui on le verra n'est pas complètementsafe
quand on gratte un peu. - Un code
unsafe
qui ressemble à la liste simplement chaînée que nous écririons enC
.
Les deux contraintes qui nous intéressent ici, et qui sont relaxées sont:
- Le déréférencement d'un "pointeur cru" (raw pointer).
- L'appel à une fonction
unsafe
.
Ainsi tout code qui fait l'une ou l'autre de ces opérations, doit être annoté unsafe
suivit par un bloc:
unsafe {
// les opérations pas sûres ici
}
Comme tout bloc, il peut ainsi retourner une valeur.
Il a d'autres contraintes qui sont relaxées, mais nous n'en profiterons pas ici, donc nous ne les mentionnons pas. En revanche toutes les règles concernant la propriété et les prêts restent valides. On peut pas faire n'importe quoi tout de même!
La version safe
La structure de données de la liste chaînée reste identique à celle que nous avons vue plus haut, ainsi que la création d'une nouvelle liste.
struct Element {
data: i32,
next: Option<Box<Element>>,
}
pub struct LinkedList {
head: Option<Box<Element>>,
}
pub fn new() -> Self {
Self { head: None }
}
Ici, nous ne nous interdisons pas d'utiliser des références et d'emprunter les instances
de notre liste. Ainsi, la fonction is_empty()
est simplement
pub fn is_empty(&self) -> bool {
self.head.is_none()
}
où on a bien une référence vers self
en argument de is_empty
.
La fonction push()
La fonction dont nous discuterons le plus en détails est la fonction push()
.
Au fur et à mesure que nous la modifierons, nous étudierons ce qu'elle fait.
Naïvement, nous voudrions que push()
fasse
fn push(&mut self, data: i32) {
let new_element = Box::new(Element::new(data, self.head));
self.head = Some(new_element);
}
Évidemment, cela ne peut pas être aussi simple, car on "move" self.head
dans
new_element
ce qui est interdit, car self
est derrière une référence mutable...
On doit donc ruser beaucoup plus pour faire croire à l'infâme compilateur que ce qu'on fait est sûr.
La solution simple et élégante est d'utiliser la méthode take()
implémentée pour
les Option<T>
qui retourne la valeur de l'option et la remplace par None
.
Ainsi, le compilateur est content: on a pas move self.head
, mais on l'a juste muté.
pub fn push(&mut self, data: i32) {
// let new_element = Box::new(Element::new(data, self.head));
// Cela ne peut pas fonctionner, pace qu'on est derrière une référence partagée
// et donc on peut pas "move" self.head
let new_head = Box::new(Element::new(data, self.head.take()));
// take retourne la valeur qui se trouve dans Some et laisse un None
// à la place de l'option.
// C'est strictement équivalent au replace (ci-dessous)
self.head = Some(new_head);
}
On a donc un push()
fonctionnel. Mais on ne sait pas vraiment ce qui se passe
dans le take()
et ça semble un peu trop "magique" pour être safe
.
En étudiant l'implémentation de take()
on se rencontre que `
self.head.take()
est équivalent à
std::mem::replace(&mut self.head, None)
qui lui-même est équivalent à
unsafe {
let result = std::ptr::read(&self.head);
std::ptr::write(&mut self.head, None);
result
}
En fait, tout au fond des choses, take()
effectue des appels aux fonctions unsafe
read()
et write()
. En fait read()
crée un copie "bit à bit" de self.head
et
comme self.head
n'est pas Copy
on a deux moyens d'accéder à la mémoire: soit par
self.head
soit par result
(c'est de l'aliasing, ce qui est pas super sûr...). En particulier, si on essaie
d'assigner self.head
(self.head = ...
) le compilateur va libérer la mémoire qui était
liée à self.head
et ainsi rendre les données de result
invalides! Pour empêcher cela
on doit utiliser la fonction write()
qui va écrire sans libérer la mémoire
les données liées à self.head
(ce qui non plus n'est pas très sûr, parce qu'on
gère pas la libération de la mémoire de self.head
).
Combinées ces deux opérations read()/write()
sont sûres, mais le compilateur n'a aucun moyen de le déduire et nous avons dû recourir à des opérations unsafe
bien qu'emballées dans des fonctions parfaitement safe
! Nous n'avons donc plus de garantie de la part du compilateur,
et par conséquent, la responsabilité de la gestion de la mémoire nous revient (cf. les travaux du Pr. Ben).
pub fn push_replace(&mut self, data: i32) {
let old_head = std::mem::replace(&mut self.head, None);
let new_head = Box::new(Element::new(data, old_head));
// replace retourne self.head et remplace l'ancienne valeur par None (comme ça le compilateur est content)
self.head = Some(new_head);
}
pub fn push_unsafe(&mut self, data: i32) {
let old_head = unsafe {
// De la documentation:
// `read` crée une copie bit à bit de `T`, que `T` soit [`Copy`] ou non.
// Si `T` n'est pas [`Copy`], utiliser à la fois la valeur renvoyée et la valeur de
// `*src` peut violer la sécurité de la mémoire. Notez que l'assignation à `*src` compte comme une
// utilisation parce qu'elle tentera de `drop` la valeur à `*src`.
let result = std::ptr::read(&self.head);
std::ptr::write(&mut self.head, None);
// Ce `write` est en fait un "truc" pour enlever l'aliasing entre
// self.head et result. Il écrase la valeur à self.head avec None
// sans `drop` self.head et donc result.
result
};
let new_head = Box::new(Element::new(data, old_head));
self.head = Some(new_head);
}
Dans ce code, nous voyons dans le commentaire l'utilisation d'un mot peut-être inconnu: aliasing
.
L'aliasing décrit une situation dans laquelle un emplacement de données en mémoire peut être accessible
par différents noms dans le programme: dans notre cas result
et self.head
.
La morale de cette histoire est qu'il et très important d'essayer de faire son maximum pour faire du
code safe. Comme on peut le voir dans le code ci-dessus, certaines fois cela est impossible (ou impose une complexité beaucoup trop grande
à la personne qui développe),
car les règles du compilateur sont telles qu'il ne peut
garantir que ce qu'on fait est sûr. Dans ces rares cas,
après avoir bien réfléchi et qu'on s'est assuré que le code est sûr quoi qu'il
arrive, on peut écrire des parties de code unsafe
. Une bonne pratique reste d'emballer ce
code dans une fonction safe
afin de limiter les cas d'utilisation.
La fonction pop()
La fonction pop()
prend en argument &mut self
et modifie donc l'instance de la liste chaînée sur laquelle elle s'applique. Elle a aussi recours à la fonction take()
comme push()
.
pub fn pop(&mut self) -> Option<i32> {
// map prend la valeur dans Some, lui applique la fonction anonyme
// et remballe la valeur obtenue dans un Some. Si l'Option
// originale est None, il se passe rien.
self.head.take().map(|element| {
self.head = element.next;
element.data
})
}
Si nous nous limitions à self.head.take()
, nous retournerions la tête de la liste,
après l'avoir remplacée par None
. Cela casserait évidemment la liste chaînée de
plus d'un élément... Ainsi, nous utilisons la fonction map()
qui va appliquer
la fonction anonyme qui lui est donnée en argument à la variante Some(elem)
et emballer le retour dans une variante Some()
(elle ne fera rien si
l'option est None
). Ici, nous retournons les données stockées dans l'élément de la tête, puis assignons l'élément suivant à la tête.
La fonction print()
La fonction print()
est également bien plus élégante que celle vue précédemment.
pub fn print(&self) {
let mut current = &self.head;
while let Some(tmp) = ¤t {
print!("{} --> ", tmp.data);
current = &tmp.next;
}
println!("∅");
}
En effet, elle ne prend qu'une référence vers la liste et va se contenter de parcourir tous les éléments sans libérer la mémoire.
La version unsafe
Nous avons utilisé l'appel de fonctions unsafe
à l'intérieur de fonction safe
pour
faire le code précédent. Dans cette partie, nous allons manipuler des "raw" pointers,
qui ressemblent beaucoup aux pointeurs du C.
Les pointeurs sont très semblables aux références, sauf qu'ils n'obéissent pas aux mêmes
règles de sûreté ou de durée de vie. C'est pour cela que ce genre de pointeurs sont utilisés
bien souvent pour faire du Rust unsafe
. Ces pointeurs se créent communément à partir de références
#![allow(unused)] fn main() { let value = 2; let p_to_value: *const i32 = &value; }
Ici, nous partons d'une variable immutable value
dont nous prenons la référence (immutable)
et nous l'assignons à un pointeur constant de i32
. Ce type de pointeur peut uniquement être lu et il est impossible de modifier la valeur pointée (le code suivant ne compile pas).
#![allow(unused)] fn main() { let value = 2; let p_to_value: *const i32 = &value; *p_to_value = 3; // argl }
Pour créer un pointeur pouvant modifier les données sur lesquelles il pointe, il est nécessaire que value
et la référence créée
sur value
soient mutables. Mais il faut également se rappeler que pour modifier une valeur, il faut déréférencer le pointeur
ce qui est une opération très dangereuse. Ainsi, il faut annoter tout code contenant le déréférencement d'un pointeur cru
avec unsafe
#![allow(unused)] fn main() { let mut value = 2; let p_to_value: *mut i32 = &mut value; unsafe { *p_to_value = 10; } }
Sans l'annotation unsafe on a l'erreur suivante
error[E0133]: dereference of raw pointer is unsafe and requires unsafe function or block
--> /tmp/mdbook-1nmQ54/unsafe.md:450:5
|
5 | *p_to_value = 10;
| ^^^^^^^^^^^^^^^^ dereference of raw pointer
|
= note: raw pointers may be null, dangling or unaligned; they can violate aliasing rules and cause data races: all of these are undefined behavior
error: aborting due to previous error
For more information about this error, try `rustc --explain E0133`
La structure de données
La structure de données d'un élément ressemble beaucoup à ce qu'on écrirait en C
#![allow(unused)] fn main() { struct Element { data: i32, next: *mut Element, } }
Un Element
contient, data
, les données qui sont stockées dans chaque élément (un i32
ici)
et un raw pointer mutable sur l'élément suivant: next
. Il est très important que ce pointeur
soit mutable pour qu'on puisse le modifier (pour ajouter ou supprimer les éléments).
Notez qu'en comparaison avec les Element
des sections précédentes, nous n'utilisons pas de
type Option
pour signaler la présence ou absence d'un élément suivant. Cela sera représenté,
comme en C, par un pointeur null
.
Un nouvel Element
est créé à l'aide de la fonction new()
fn new(data: i32, next: *mut Element) -> *mut Element {
let layout = Layout::new::<Element>();
let e = unsafe { alloc(layout) as *mut Element };
if e.is_null() {
handle_alloc_error(layout);
}
unsafe {
(*e).data = data;
(*e).next = next;
}
e
}
qui prend en argument les données à stocker dans l'élément et le pointeur suivant.
On constate que pour allouer un élément, on doit préciser son "layout" en mémoire,
puis utiliser la fonction alloc()
qui est une fonction unsafe
qui
alloue un espace mémoire correspondant au layout spécifié et retourne un pointeur
d'octets (u8
). Ce pointeur est immédiatement converti en pointeur mutable
d'Element
. Lors d'une tentative d'allocation manuelle, si l'allocation échoue le
programme panique par défaut. Ici, on doit gérer l'échec de l'allocation manuellement
en vérifiant si le pointeur est nul e.is_null()
. Ensuite, il est nécessaire de déréférencer le nouvel élément e
et d'assigner chacun de ses champs à data
et next
respectivement. Cette opération nécessitant un déréférencement est
unsafe
et est annotée de façon adéquate.
L'allocation manuelle nécessite également une libération de la mémoire manuelle à
l'aide de la fonction dealloc()
. Cette opération est faite dans le trait Drop
qui
implémente la fonction drop()
. Pour des raw pointers, la fonction drop()
n'est pas appelée automatiquement et il faut l'appeler explicitement lors de la sortie de la portée du pointeur (voir la section sur pop()
). De plus, pour la plupart des types, la fonction drop()
est implémentée automatiquement, mais ici
il est nécessaire d'appeler dealloc()
manuellement et donc il est nécessaire
de faire l'implémentation explicitement.
impl Drop for Element {
fn drop(&mut self) {
let elem = self as *mut Element;
if !elem.is_null() {
let layout = Layout::new::<Element>();
unsafe {
dealloc(elem as *mut u8, layout);
}
}
}
}
On voit que pour libérer la mémoire, on doit vérifier que le pointeur
qu'on essaie de libérer n'est pas null
afin d'éviter de tenter de désallouer
une zone mémoire interdite, puis on appelle la fonction dealloc()
qui est de façon inhérente
unsafe
(on a aucune garantie que la mémoire qu'on libère est toujours valide) il
est nécessaire d'annoter le code unsafe
également (comme pour alloc()
on a besoin de connaître le layout mémoire de ce qu'on va libérer).
Les fonctions new()
et drop()
sont deux fonctions qui appellent du code unsafe
, mais n'ont pas besoin d'être annotées unsafe
: c'est des abstractions permettant
de cacher la dangerosité, tout en permettant à l'utilisateur·trice de chercher
les bugs mémoire plus facilement, car ils se trouvent toujours liés à ces parties unsafe
.
Maintenant que nous pouvons créer des nouveaux éléments (et les détruire), nous pouvons passer à la liste chaînée qui n'est rien d'autre qu'un pointeur d'Element
mutable nommé astucieusement head
.
pub struct LinkedList {
head: *mut Element,
}
Pour créer une nouvelle liste chaînée, nous avons uniquement besoin de signaler
que la liste est vide en assignant à head
un pointeur nul mutable (ptr::nul_mut()
).
pub fn new() -> LinkedList {
LinkedList {
head: ptr::null_mut(),
}
}
La fonction is_empty()
Naturellement, la fonction is_empty()
va uniquement vérifier que la tête de la liste
est nulle et est trivialement implémentée
fn is_empty(&self) -> bool {
self.head.is_null()
}
La fonction push()
La fonction push()
est très simple à écrire et équivalente à ce qu'on ferait en C
pub fn push(&mut self, data: i32) {
let new_head = Element::new(data, self.head);
self.head = new_head;
}
La relaxation des règles très strictes sur les références permet de déplacer le pointeur de tête dans le nouvel élément qui devient ainsi la nouvelle tête de la liste.
La fonction pop()
La fonction pop()
est un peu plus complexe, mais également très similaire à ce qu'on
ferait en C.
pub fn pop(&mut self) -> Option<i32> {
if self.is_empty() {
None
} else {
let old_head = self.head;
unsafe {
self.head = (*self.head).next;
}
let val = unsafe { (*old_head).data };
unsafe {
old_head.drop_in_place();
}
Some(val)
}
}
Si la liste est vide, on retourne un None
(aucune valeur) car il n'y a rien à retourner.
En revanche, si un élément est présent en tête de liste on garde un pointeur sur la copie de l'élément de tête, old_head
, puis on déplace le pointeur de tête sur l'élément suivant
(cette dernière opération nécessite un déréférencement et est donc unsafe
).
Puis ont récupère la data
stockée dans old_head
(opération unsafe
car on fait un déréférencement) et finalement on appelle explicitement drop_in_place()
(qui appelle drop()
). Suite à la libération de la mémoire (qui empêche les fuites mémoire)
on termine par retourner la valeur qui était stockée au sommet de la liste chaînée.
La fonction print()
La fonction print()
est relativement simple et très similaire à ce qu'on fait en C
pub fn print(&self) {
let mut current_head = self.head;
while !current_head.is_null() {
unsafe {
print!("{} --> ", (*current_head).data);
current_head = (*current_head).next;
}
}
println!("∅");
}
On crée un pointeur mutable qui va parcourir tous les éléments de la liste chaînée et en afficher le contenu, jusqu'à atteindre la fin de la liste (le pointeur devient null
). Les raw pointers n'appelant jamais drop()
quand ils sortent de la portée
ne libèrent jamais la mémoire sur laquelle ils pointent donc la liste chaînée reste intacte.
La fonction drop()
Comme on l'a dit tout à l'heure pour les Element
, il est nécessaire d'implémenter
le trait Drop
impl Drop for LinkedList {
fn drop(&mut self) {
while !self.is_empty() {
let _ = self.pop();
}
}
}
Ici toutes les désallocations sont cachées dans la fonction pop()
, et on parcourt
toute la liste comme on l'a fait pour la fonction print()
.
Aller plus loin
Il y a plusieurs exercices que vous pouvez effectuer à partir de ces codes. Dans un ordre aléatoire:
- Rendez-la liste générique (transformez
i32
enT
), - Ajoutez la fonction
peek()
qui permet de jeter un œil à la tête de la liste, - Ajoutez une fonction
remove(i)
permettant d'enlever lei
-ème élément de la liste, - Ajoutez une fonction
insert(i, data)
permettant d'ajouterdata
à lai
-ème place dans la liste, - Ajouter les fonctions permettant d'itérer sur la liste en implémentant les trait
Iter
etIntoIter
.
Rustlings
Les rustlings à faire dans ce chapitre sont les suivants:
Les Box
$ rustlings run box1