Le langage de programmation Rust
par Steve Klabnik et Carol Nichols, avec la participation de la Communauté Rust
Cette version du document suppose que vous utilisez Rust 1.58 (publié le 13/01/2022) ou ultérieur. Voir la section “Installation” du chapitre 1 pour installer ou mettre à jour Rust.
Le format HTML de la version anglaise est disponible en ligne à l'adresse
https://doc.rust-lang.org/stable/book/
et en hors-ligne avec l'installation de Rust qui a été effectuée avec rustup
;
vous pouvez lancer rustup docs --book
pour l'ouvrir.
Vous avez aussi à votre disposition quelques traductions entretenues par la communauté.
La version anglaise de ce livre est disponible au format papier et e-book chez No Starch Press.
Avant-propos
Cela n'a pas toujours été aussi évident, mais le langage de programmation Rust apporte avant tout plus de puissance : peu importe le type de code que vous écrivez en ce moment, Rust vous permet d'aller plus loin et de programmer en toute confiance dans une plus grande diversité de domaines qu'auparavant.
Prenez par exemple la gestion des éléments au “niveau système” qui traite de détails bas niveau de gestion de mémoire, de modèles de données et de concurrence. Traditionnellement, ce domaine de la programmation est jugé ésotérique, compréhensible uniquement par une poignée de personnes qui ont consacré des années d'apprentissage à en déjouer les pièges infâmes. Et même ceux qui travaillent dans ce domaine le font avec beaucoup de prudence, de crainte que leur code ne puisse conduire à des problèmes de sécurité, des plantages ou des corruptions de mémoire.
Rust fait tomber ces obstacles en éliminant les vieux pièges et en apportant un ensemble d'outils soignés et conviviaux pour vous aider sur votre chemin. Les développeurs qui ont besoin de "se plonger" dans le contrôle de plus bas niveau peuvent ainsi le faire avec Rust, sans prendre le risque habituel de plantages ou de failles de sécurité, et sans avoir à apprendre les subtilités d'un enchevêtrement d'outils capricieux. Encore mieux, le langage est conçu pour vous guider naturellement vers un code fiable et efficace en termes de rapidité d'exécution et d'utilisation de la mémoire.
Les développeurs qui travaillent déjà avec du code bas niveau peuvent utiliser Rust pour accroître leurs ambitions. Par exemple, introduire du parallélisme en Rust est une opération à faible risque : le compilateur va détecter les erreurs classiques pour vous. Et vous pourrez vous lancer dans des améliorations plus agressives de votre code avec la certitude que vous n'introduirez pas accidentellement des causes de plantage ou des vulnérabilités.
Mais Rust n'est pas cantonné à la programmation de bas niveau. C'est un langage suffisamment expressif et ergonomique pour rendre les applications en ligne de commande, les serveurs web et bien d'autres types de code agréables à écrire — vous trouverez plus tard des exemples simples de ces types de programmes dans ce livre. Travailler avec Rust vous permet d'acquérir des compétences qui sont transposables d'un domaine à un autre ; vous pouvez apprendre Rust en écrivant une application web, puis appliquer les mêmes notions pour les utiliser avec votre Raspberry Pi.
Ce livre exploite pleinement le potentiel de Rust pour permettre à ses utilisateurs de se perfectionner. C'est une documentation conviviale et accessible destinée à améliorer vos connaissances en Rust, mais aussi à améliorer vos capacités et votre assurance en tant que développeur en général. Alors foncez, préparez-vous à apprendre, et bienvenue dans la communauté Rust !
— Nicholas Matsakis et Aaron Turon
Introduction
Note : la version anglaise de ce livre est disponible au format papier et ebook chez No Starch Press à cette adresse : The Rust Programming Language
Bienvenue sur Le langage de programmation Rust, un livre d'initiation à Rust. Le langage de programmation Rust vous aide à écrire plus rapidement des logiciels plus fiables. L'ergonomie de haut-niveau et la maîtrise de bas-niveau sont souvent en opposition dans la conception des langages de programmation ; Rust remet en cause ce conflit. Grâce à l'équilibre entre ses puissantes capacités techniques et une bonne ergonomie de développement, Rust vous donne la possibilité de contrôler les détails de bas-niveau (comme l'utilisation de la mémoire) sans tous les soucis traditionnellement associés à ce genre de pratique.
À qui s'adresse Rust
Rust est idéal pour de nombreuses personnes pour diverses raisons. Analysons quelques-uns des groupes les plus importants.
Équipes de développeurs
Rust se révèle être un outil productif pour la collaboration entre de grandes équipes de développeurs ayant différents niveaux de connaissances en programmation système. Le code de bas-niveau est sujet à une multitude de bogues subtils, qui, dans la plupart des autres langages, ne peuvent être prévenus qu'au moyen de campagnes de test étendues et de minutieuses revues de code menées par des développeurs chevronnés. Avec Rust, le compilateur joue le rôle de gardien en refusant de compiler du code qui comprend ces bogues discrets et vicieux, y compris les bogues de concurrence. En travaillant avec le compilateur, l'équipe peut se concentrer sur la logique du programme plutôt que de traquer les bogues.
Rust offre aussi des outils de développement modernes au monde de la programmation système :
- Cargo, l'outil intégré de gestion de dépendances et de compilation, qui uniformise et facilite l'ajout, la compilation, et la gestion des dépendances dans l'écosystème Rust.
- Rustfmt, qui assure une cohérence de style de codage pour tous les développeurs.
- Le Rust Langage Server alimente les environnements de développement intégrés (IDE) pour la complétion du code et l'affichage direct des messages d'erreur.
En utilisant ces outils ainsi que d'autres dans l'écosystème Rust, les développeurs peuvent être plus productifs quand ils écrivent du code système.
Étudiants
Rust est conçu pour les étudiants et ceux qui s'intéressent à l'apprentissage des concepts système. En utilisant Rust, de nombreuses personnes ont appris des domaines comme le développement de systèmes d'exploitation. La communauté est très accueillante et répond volontiers aux questions des étudiants. Grâce à des initiatives comme ce livre, les équipes de Rust veulent rendre les notions système accessibles au plus grand nombre, particulièrement à ceux qui débutent dans la programmation.
Entreprises
Des centaines d'entreprises, petites et grosses, utilisent Rust en production pour différentes missions. Ils l'utilisent pour des outils en ligne de commande, des services web, des outils DevOps, des systèmes embarqués, de l'analyse et de la conversion audio et vidéo, des cryptomonnaies, de la bio-informatique, des moteurs de recherche, de l'internet des objets (IoT), de l'apprentissage automatique (marchine learning), et même des parties importantes du navigateur internet Firefox.
Développeurs de logiciel libre
Rust est ouvert aux personnes qui veulent développer le langage de programmation Rust, la communauté, les outils de développement et les bibliothèques. Nous serions ravis que vous contribuiez au langage Rust.
Les personnes qui recherchent la rapidité et la stabilité
Rust est une solution pour les personnes qui chérissent la rapidité et la stabilité dans un langage. Par rapidité, nous entendons la vitesse des programmes que vous pouvez créer avec Rust et la rapidité avec laquelle Rust vous permet de les écrire. Les vérifications du compilateur de Rust assurent la stabilité durant l'ajout de fonctionnalités ou le remaniement du code. Cela le démarque des langages qui ne font pas ces contrôles sur du code instable que le programme a hérité avec le temps, et que bien souvent les développeurs ont peur de modifier. En s'efforçant de mettre en place des abstractions sans coût, des fonctionnalités de haut-niveau qui compilent vers du code bas-niveau aussi rapide que s'il avait été écrit à la main, Rust fait en sorte que le code sûr soit aussi du code rapide.
Le langage Rust espère aider beaucoup d'autres utilisateurs ; ceux cités ici ne font partie que d'un univers bien plus grand. Globalement, la plus grande ambition de Rust est d'éradiquer les compromis auxquels les développeurs se soumettaient depuis des décennies en leur apportant sécurité et productivité, rapidité et ergonomie. Essayez Rust et vérifiez si ses décisions vous conviennent.
À qui est destiné ce livre
Ce livre suppose que vous avez écrit du code dans un autre langage de programmation mais ne suppose pas lequel. Nous avons essayé de rendre son contenu le plus accessible au plus grand nombre d'expériences de programmation possible. Nous ne nous évertuons pas à nous questionner sur ce qu'est la programmation ou comment l'envisager. Si vous êtes débutant en programmation, vous seriez mieux avisé en lisant un livre qui vous initie à la programmation.
Comment utiliser ce livre
Globalement, ce livre est prévu pour être lu dans l'ordre. Les chapitres suivants s'appuient sur les notions abordées dans les chapitres précédents, et lorsque les chapitres précédents ne peuvent pas approfondir un sujet, ce sera généralement fait dans un chapitre suivant.
Vous allez rencontrer deux différents types de chapitres dans ce livre : les chapitres théoriques et les chapitres de projet. Dans les chapitres théoriques, vous allez apprendre un sujet à propos de Rust. Dans un chapitre de projet, nous allons construire ensemble des petits programmes, pour appliquer ce que vous avez appris précédemment. Les chapitres 2, 12 et 20 sont des chapitres de projet ; les autres sont des chapitres théoriques.
Le chapitre 1 explique comment installer Rust, comment écrire un programme "Hello, world!" et comment utiliser Cargo, le gestionnaire de paquets et outil de compilation. Le chapitre 2 est une initiation pratique au langage Rust. Nous y aborderons des concepts de haut-niveau, et les chapitres suivants apporteront plus de détails. Si vous voulez vous salir les mains tout de suite, le chapitre 2 est l'endroit pour cela. Au début, vous pouvez même sauter le chapitre 3, qui aborde les fonctionnalités de Rust semblables aux autres langages de programmation, et passer directement au chapitre 4 pour en savoir plus sur le système de possession (ownership) de Rust. Toutefois, si vous êtes un apprenti particulièrement minutieux qui préfère apprendre chaque particularité avant de passer à la suivante, vous pouvez sauter le chapitre 2 et passer directement au chapitre 3, puis revenir au chapitre 2 lorsque vous souhaitez travailler sur un projet en appliquant les notions que vous avez apprises.
Le chapitre 5 traite des structures et des méthodes, et le chapitre 6 couvre les
énumérations, les expressions match
, et la structure de contrôle if let
.
Vous emploierez les structures et les énumérations pour créer des types
personnalisés avec Rust.
Au chapitre 7, vous apprendrez le système de modules de Rust et les règles de visibilité, afin d'organiser votre code et son interface de programmation applicative (API) publique. Le chapitre 8 traitera des structures de collections de données usuelles fournies par la bibliothèque standard, comme les vecteurs, les chaînes de caractères et les tables de hachage (hash maps). Le chapitre 9 explorera la philosophie et les techniques de gestion d'erreurs de Rust.
Le chapitre 10 nous plongera dans la généricité, les traits et
les durées de vie, qui vous donneront la capacité de créer du code qui s'adapte
à différents types. Le chapitre 11 traitera des techniques de test, qui restent
nécessaires malgré les garanties de sécurité de Rust, pour s'assurer que
la logique de votre programme est valide. Au chapitre 12, nous écrirons
notre propre implémentation d'un sous-ensemble des fonctionnalités du programme
en ligne de commande grep
, qui recherche du texte dans des fichiers.
Pour ce faire, nous utiliserons de nombreuses notions abordées dans les
chapitres précédents.
Le chapitre 13 explorera les fermetures (closures) et itérateurs : ce sont les fonctionnalités de Rust inspirées des langages de programmation fonctionnels. Au chapitre 14, nous explorerons plus en profondeur Cargo et les bonnes pratiques pour partager vos propres bibliothèques avec les autres. Le chapitre 15 parlera de pointeurs intelligents qu'apporte la bibliothèque standard et des traits qui activent leurs fonctionnalités.
Au chapitre 16, nous passerons en revue les différents modes de programmation concurrente et comment Rust nous aide à développer dans des tâches parallèles sans crainte. Le chapitre 17 comparera les fonctionnalités de Rust aux principes de programmation orientée objet, que vous connaissez peut-être.
Le chapitre 18 est une référence sur les motifs et le filtrage de motif (pattern matching), qui sont des moyens puissants permettant de communiquer des idées dans les programmes Rust. Le chapitre 19 contient une foultitude de sujets avancés intéressants, comme le code Rust non sécurisé (unsafe), les macros et plus de détails sur les durées de vie, les traits, les types, les fonctions et les fermetures (closures).
Au chapitre 20, nous terminerons un projet dans lequel nous allons implémenter en bas-niveau un serveur web multitâches !
Et finalement, quelques annexes qui contiennent des informations utiles sur le langage sous forme de référentiels qui renvoient à d'autres documents. L'annexe A liste les mots-clés de Rust, l'annexe B couvre les opérateurs et symboles de Rust, l'annexe C parle des traits dérivables qu'apporte la bibliothèque standard, l'annexe D référence certains outils de développement utiles, et l'annexe E explique les différentes éditions de Rust.
Il n'y a pas de mauvaise manière de lire ce livre : si vous voulez sauter des étapes, allez-y ! Vous devrez alors peut-être revenir sur les chapitres précédents si vous éprouvez des difficultés. Mais faites comme bon vous semble.
Une composante importante du processus d'apprentissage de Rust est de comprendre comment lire les messages d'erreur qu'affiche le compilateur : ils vous guideront vers du code correct. Ainsi, nous citerons de nombreux exemples qui ne compilent pas, avec le message d'erreur que le compilateur devrait vous afficher dans chaque cas. C'est donc normal que dans certains cas, si vous copiez et exécutez un exemple au hasard, il ne compile pas ! Assurez-vous d'avoir lu le texte autour pour savoir si l'exemple que vous tentez de compiler doit échouer. Ferris va aussi vous aider à identifier du code qui ne devrait pas fonctionner :
Ferris | Signification |
---|---|
Ce code ne compile pas ! | |
Ce code panique ! | |
Ce code ne se comporte pas comme voulu. |
Dans la plupart des cas, nous vous guiderons vers la version du code qui devrait fonctionner.
Code source
Les fichiers du code source qui a généré ce livre en anglais sont disponibles sur GitHub.
La version française est aussi disponible sur GitHub.
Traduction des termes
Voici les principaux termes techniques qui ont été traduits de l'anglais vers le français.
Anglais | Français | Remarques |
---|---|---|
adaptor | adaptateur | - |
ahead-of-time compilation | compilation anticipée | sigle : AOT |
alias | alias | - |
allocated | alloué | - |
angle bracket | chevrons | - |
annotate | indiquer | - |
anti-pattern | anti-patron | - |
Appendix | annexe | tout en minuscule (sauf en début de phrase) |
append | ajouter | - |
Application Programming Interface (API) | interface de programmation applicative (API) | - |
assertion | vérification | - |
assign | assigner | - |
argument | argument / paramètre | - |
arm | branche | dans une expression match |
array | tableau | - |
artifact | artéfact | - |
associated function | fonction associée | - |
attribute | attribut | - |
backend | application dorsale | - |
backtrace | retraçage | - |
benchmark | benchmark | - |
binary crate | crate binaire | s'utilise au féminin |
buffer overread | lecture hors limites | - |
n-bit number | nombre encodé sur n bits | - |
blanket implementation | implémentation générale | - |
blob | blob | - |
boilerplate code | code standard | - |
boolean | booléen | - |
borrow | emprunt(er) | - |
borrow checker | vérificateur d'emprunt | - |
box | boite | - |
buffer overread | sur-lecture de tampon | - |
bug | bogue | - |
build | compilation | - |
build system | système de compilation | - |
byte | octet | - |
Cargo | Cargo | - |
catchall value | valeur passe-partout | - |
channel | canal | - |
Chapter | chapitre | tout en minuscule (sauf en début de phrase) |
CI system | système d'Intégration Continue | - |
clause | clause | - |
cleanup | nettoyage | - |
closure | fermeture | - |
code review | revue de code | - |
coercion | extrapolation | - |
collection | collection | - |
command | commande | dans un terminal |
commit | commit | - |
compound | composé | - |
concept chapter | chapitre théorique | - |
concurrency | concurrence | - |
concurrent | concurrent | - |
concurrent programming | programmation concurrente | - |
conditional | structure conditionnelle | - |
cons list | liste de construction | - |
constant | constant / constante | - |
construct | instruction | - |
constructor | constructeur | - |
consuming adaptor | adaptateur de consommation | - |
control flow construct | structure de contrôle | - |
core of the error | message d'erreur | - |
corruption | corruption / être corrompu | - |
CPU | processeur | - |
crash | plantage | - |
crate | crate | nom féminin (une crate) |
curly bracket | accolade | - |
dangling | pendouillant | - |
data race | accès concurrent | - |
data representation | modèle de données | - |
deadlock | interblocage | - |
deallocate | désalloué | - |
debug | déboguer | - |
debugging | débogage | - |
deep copy | copie en profondeur | - |
dependency | dépendance | - |
deref coercion | extrapolation de déréferencement | - |
dereference operator | opérateur de déréférencement | - |
dereferencing | déréférencement | - |
design pattern | patron de conception | - |
destructor | destructeur | - |
destructure | déstructurer | - |
DevOps | DevOps | - |
directory | dossier | - |
dot notation | la notation avec un point | - |
double free | double libération | - |
drop | libérér | - |
elision | élision | - |
enum | énumération | - |
enumeration | énumération | - |
enum’s variant | variante d'énumération | - |
exploit | faille | - |
expression | expression | - |
field | champ | d'une structure |
Figure | Illustration | - |
flag | drapeau | pour les programmes en ligne de commande |
float | nombre à virgule flottante | - |
floating-point number | nombre à virgule flottante | - |
framework | environnement de développement | - |
frontend | interface frontale | - |
fully qualified syntax | syntaxe totalement définie | - |
function | fonction | - |
functional programming | programmation fonctionnelle | - |
garbage collector | ramasse-miettes | - |
generics | génériques / généricité | - |
generic type parameter | paramètre de type générique | - |
getter | accesseur | - |
glob | global | opérateur |
global scope | portée globale | - |
grapheme cluster | groupe de graphèmes | - |
green thread | tâche virtuelle | - |
guessing game | jeu de devinettes | - |
handle | référence abstraite | - |
hash | hash / relatif au hachage | - |
hash map | table de hachage | - |
heap | tas | - |
Hello, world! | Hello, world! | - |
high-level | haut niveau | - |
identifier | identificateur | - |
idiomatic | idéal | - |
immutability | immuabilité | - |
immutable | immuable | - |
index | indice | - |
indexing | indexation | - |
input/output | entrée/sortie | sigle : IO |
instance | instance | - |
instantiate | instancier | créer une instance |
integer literal | littéral d'entiers | - |
integer overflow | dépassement d'entier | - |
Integrated Development Environment (IDE) | environnement de développement intégré (IDE) | - |
interior mutability | mutabilité interne | - |
interrupt signal | signal d'arrêt | - |
invalidate | neutraliser | - |
IOT | internet des objets (IOT) | - |
iterator | itérateur | - |
iterator adaptor | adaptateur d'itération | - |
job | mission | - |
just-in-time compilation | compilation à la volée | sigle : JIT |
keyword | mot-clé | - |
lazy | évaluation paresseuse | comportement d'un itérateur |
legacy code | code instable que le programme a hérité avec le temps | - |
library | bibliothèque | - |
library crate | crate de bibliothèque | s'utilise au féminin |
lifetime | durée de vie | - |
linker | linker | - |
linter | analyse statique | - |
literal value | valeur littérale | - |
Listing | encart | tout en minuscule (sauf en début de phrase) |
loop | boucle | - |
low-level | bas niveau | - |
machine learning | apprentissage automatique | - |
macro | macro | - |
main | main | - |
map | tableau associatif | - |
match guard | contrôle de correspondance | - |
memory leak | fuite de mémoire | - |
memory management | gestion de mémoire | - |
message-passing | passage de messages | - |
method | méthode | - |
mock object | mock object | - |
modern | récent | - |
module | module | - |
module system | système de modules | - |
monomorphization | monomorphisation | - |
move | déplacement | - |
mutability | mutabilité | - |
mutable | mutable | modifiable |
mutate | muter | - |
namespace | espace de nom | - |
namespacing | l'espace de nom | - |
nested (path) | (chemin) imbriqué | - |
newtype pattern | motif newtype | - |
nightly Rust | version expérimentale de Rust | - |
Note | remarque | tout en minuscule (sauf en début de phrase) |
numerical characters | chiffres | - |
object-oriented language | langage orienté objet | - |
operating system | système d'exploitation | - |
output | sortie | - |
overload | surcharge | - |
owner | propriétaire | - |
ownership | possession | - |
package manager | système de gestion de paquets | - |
panic | panique(r) | - |
parallel programming | parallélisme | - |
parallelism | parallélisme | - |
parameter | paramètre | - |
parse | interpréter | - |
PATH | PATH | - |
pattern | motif | - |
pattern-matching | filtrage par motif | - |
placeholder | espace réservé | {} pour fmt |
pointer | pointeur | - |
popping off the stack | dépiler | - |
prelude | étape préliminaire | - |
primitive obsession | obsession primitive | - |
privacy | visibilité | en parlant des éléments d'un module |
procedural macro | macro procédurale | - |
process | processus | - |
project chapter | chapitre de projet | - |
propagate | propager | - |
pushing onto the stack | empiler | - |
race condition | situation de concurrence | - |
raw identifier | identificateur brut | - |
README | README | - |
recursive type | type récursif | - |
refactoring | remaniement | - |
reference | référence | - |
reference counting | compteur de références | - |
reference cycle | boucle de références | - |
release | publication | - |
registry | registre | - |
regression | régression | - |
release | publication | - |
remainder | modulo | opération % |
reproducible build | compilation reproductible | - |
Resource Acquisition Is Initialization (RAII) | l'acquisition d'une ressource est une initialisation (RAII) | - |
return | retourner | - |
run | exécuter | pour les programmes |
Rustacean | Rustacé | - |
section header | entête de section | - |
semantic version | version sémantique | - |
scalar | scalaire | - |
scope | portée | - |
script | script | - |
secret | secret | - |
section header | en-tête de section | - |
semantic version | version sémantique | - |
semantic versioning | versionnage sémantique | abréviation : SemVer |
shadow | masquer | remplacer une variable par une autre de même nom |
shadowing | masquage | - |
shallow copy | copie superficielle | - |
shell | terminal / invite de commande | - |
shorthand | abréviation | - |
sidebar | volet latéral | - |
signature | signature | d'une fonction |
signed | signé | - |
slash | barre oblique | - |
slice | slice | - |
smart pointer | pointeur intelligent | - |
snake case | snake case | - |
snip | partie masquée ici | dans un encart |
space | espace | ce mot est féminin quand on parle du caractère typographique |
square brackets | crochets | - |
stack | pile | - |
stack overflow | débordement de pile | - |
standard | standard (adj. inv.) / norme (n.f.) | - |
standard error | erreur standard | - |
standard input | entrée standard | - |
standard library | bibliothèque standard | - |
standard output | sortie standard | - |
statement | instruction | - |
statically typed | statiquement typé | - |
string | chaîne de caractères | - |
string literal | un littéral de chaîne de caractères | - |
String | String | nom féminin (une String ) |
struct | structure | - |
submodule | sous-module | - |
supertrait | supertrait | - |
syntax sugar | sucre syntaxique | - |
systems concept | notion système | - |
systems-level | niveau système | - |
systems-level code | code système | - |
terminal | terminal | - |
test double | double de test | - |
thread | tâche | - |
thread pool | groupe de tâches | - |
token | jeton | - |
trait | trait | - |
trait bound | trait lié | - |
trait object | objet trait | - |
tree | arborescence | - |
troubleshooting | dépannage | - |
tuple | tuple | - |
tuple struct | structure tuple | - |
tuple enum | énumération tuple | - |
type | type | - |
type annotation | annotation de type | - |
type inference | inférence de types | - |
two’s complement | complément à deux | - |
two’s complement wrapping | rebouclage du complément à deux | - |
underlying operating system | système d'exploitation sous-jacent | - |
underscore | tiret bas | le caractère _ |
unit-like struct | structure unité | - |
unit type | type unité | le () |
unit value | valeur unité | - |
unrolling | déroulage | pour une boucle à taille connue à la compilation |
unsafe | non sécurisé | - |
unsigned | sans signe (toujours positif) | - |
unsigned | non signé | - |
unwind | dérouler | (la pile) |
user input | saisie utilisateur | - |
variable | variable | - |
variant | variante | d'une énumération |
vector | vecteur | - |
version control system (VCS) | système de gestion de versions (VCS) | - |
vertical pipe | barre verticale | la barre ` |
warning | avertissement | - |
weak reference | référence faible | - |
wildcard | joker | - |
worker | opérateur | - |
workspace | espace de travail | - |
yank | déprécier | - |
zero-cost abstraction | abstraction sans coût | - |
Prise en main
Démarrons notre périple avec Rust ! Il y a beaucoup à apprendre, mais chaque aventure doit commencer quelque part. Dans ce chapitre, nous allons aborder :
- L'installation de Rust sur Linux, macOS et Windows
- L'écriture d'un programme qui affiche
Hello, world!
- L'utilisation de
cargo
, le gestionnaire de paquets et système de compilation de Rust
Installation
La première étape consiste à installer Rust. Nous allons télécharger Rust via
rustup
, un outil en ligne de commande conçu pour gérer les versions de Rust et
les outils qui leur sont associés. Vous allez avoir besoin d'une connexion
Internet pour le téléchargement.
Note : si vous préférez ne pas utiliser
rustup
pour une raison ou une autre, vous pouvez vous référer à la page des autres moyens d'installation de Rust pour d'autres méthodes d'installation.
L'étape suivante est d'installer la dernière version stable du compilateur Rust. La garantie de stabilité de Rust assurera que tous les exemples dans le livre qui se compilent bien vont continuer à se compiler avec les nouvelles versions de Rust. La sortie peut varier légèrement d'une version à une autre, car Rust améliore souvent les messages d'erreur et les avertissements. En résumé, toute nouvelle version stable de Rust que vous installez de cette manière devrait fonctionner en cohérence avec le contenu de ce livre.
La notation en ligne de commande
Dans ce chapitre et les suivants dans le livre, nous allons montrer quelques commandes tapées dans le terminal. Les lignes que vous devrez écrire dans le terminal commencent toutes par
$
. Vous n'avez pas besoin d'écrire le caractère$
; il marque le début de chaque commande. Les lignes qui ne commencent pas par$
montrent généralement le résultat de la commande précédente. De plus, les exemples propres à PowerShell utiliseront>
plutôt que$
.
Installer rustup
sur Linux ou macOS
Si vous utilisez Linux ou macOS, ouvrez un terminal et écrivez la commande suivante :
$ curl --proto '=https' --tlsv1.2 https://sh.rustup.rs -sSf | sh
Cette commande télécharge un script et lance l'installation de l'outil rustup
,
qui va installer la dernière version stable de Rust. Il est possible que l'on
vous demande votre mot de passe. Si l'installation se déroule bien, vous
devriez voir la ligne suivante s'afficher :
Rust is installed now. Great!
Vous aurez aussi besoin d'un linker, qui est un programme que Rust utilise pour regrouper ses multiples résultats de compilation dans un unique fichier. Il est probable que vous en ayez déjà un d'installé, mais si vous avez des erreurs à propos du linker, cela veut dire vous devrez installer un compilateur de langage C, qui inclura généralement un linker. Un compilateur est parfois utile car certains paquets Rust communs nécessitent du code C et auront besoin d'un compilateur C.
Sur macOS, vous pouvez obtenir un compilateur C en lançant la commande :
$ xcode-select --install
Les utilisateurs de Linux doivent généralement install GCC ou Clang, en fonction
de la documentation de leur distribution. Par exemple, si vous utilisez Ubuntu,
vous pouvez installer le paquet build-essential
.
Installer rustup
sous Windows
Sous Windows, il faut aller sur https://www.rust-lang.org/tools/install et suivre les instructions pour installer Rust. À un moment donné durant l'installation, vous aurez un message vous expliquant qu'il va vous falloir l'outil de compilation C++ pour Visual Studio 2013 ou plus récent. La méthode la plus facile pour obtenir les outils de compilation est d'installer Build Tools pour Visual Studio 2019. Lorsque vous aurez à sélectionner les composants à installer, assurez-vous que les “Outils de compilation C++” sont bien sélectionnés, et que le SDK Windows 10 et les paquets de langage Anglais sont bien inclus.
La suite de ce livre utilisera des commandes qui fonctionnent à la fois dans cmd.exe et PowerShell. S'il y a des différences particulières, nous vous expliquerons lesquelles utiliser.
Mettre à jour et désinstaller
Après avoir installé Rust avec rustup
, la mise à jour vers la dernière version
est facile. Dans votre terminal, lancez le script de mise à jour suivant :
$ rustup update
Pour désinstaller Rust et rustup
, exécutez le script de désinstallation
suivant dans votre terminal :
$ rustup self uninstall
Dépannage
Pour vérifier si Rust est correctement installé, ouvrez un terminal et entrez cette ligne :
$ rustc --version
Vous devriez voir le numéro de version, le hash de commit, et la date de commit de la dernière version stable qui a été publiée, au format suivant :
rustc x.y.z (abcabcabc yyyy-mm-dd)
Si vous voyez cette information, c'est que vous avez installé Rust avec succès !
Si vous ne voyez pas cette information et que vous êtes sous Windows, vérifiez
que Rust est présent dans votre variable d'environnement système %PATH%
. Si
tout est correct et que Rust ne fonctionne toujours pas, il y a quelques
endroits où vous pourrez trouver de l'aide. Le plus accessible est le
canal #beginners sur le Discord officiel de Rust. Là-bas, vous pouvez
dialoguer en ligne avec d'autres Rustacés (un surnom ridicule que nous nous
donnons entre nous) qui pourront vous aider. D'autres bonnes sources de données
sont le forum d'utilisateurs et Stack Overflow.
Documentation en local
L'installation de Rust embarque aussi une copie de la documentation en local
pour que vous puissiez la lire hors ligne. Lancez rustup doc
afin d'ouvrir la
documentation locale dans votre navigateur.
À chaque fois que vous n'êtes pas sûr de ce que fait un type ou une fonction fournie par la bibliothèque standard ou que vous ne savez pas comment l'utiliser, utilisez cette documentation de l'interface de programmation applicative (API) pour le savoir !
Hello, World!
Maintenant que vous avez installé Rust, écrivons notre premier programme Rust. Lorsqu'on apprend un nouveau langage, il est de tradition d'écrire un petit programme qui écrit le texte "Hello, world!" à l'écran, donc c'est ce que nous allons faire !
Note : ce livre part du principe que vous êtes familier avec la ligne de commande. Rust n'impose pas d'exigences sur votre éditeur, vos outils ou l'endroit où vous mettez votre code, donc si vous préférez utiliser un environnement de développement intégré (IDE) au lieu de la ligne de commande, vous êtes libre d'utiliser votre IDE favori. De nombreux IDE prennent en charge Rust à des degrés divers ; consultez la documentation de l'IDE pour plus d'informations. Récemment, l'équipe Rust s'est attelée à améliorer l'intégration dans les IDE et des progrès ont rapidement été faits dans ce domaine !
Créer un dossier projet
Nous allons commencer par créer un dossier pour y ranger le code Rust. Là où vous mettez votre code n'est pas important pour Rust, mais pour les exercices et projets de ce livre, nous vous suggérons de créer un dossier projects dans votre dossier utilisateur et de ranger tous vos projets là-dedans.
Ouvrez un terminal et écrivez les commandes suivantes pour créer un dossier projects et un dossier pour le projet “Hello, world!” à l'intérieur de ce dossier projects.
Sous Linux, macOS et PowerShell sous Windows, écrivez ceci :
$ mkdir ~/projects
$ cd ~/projects
$ mkdir hello_world
$ cd hello_world
Avec CMD sous Windows, écrivez ceci :
> mkdir "%USERPROFILE%\projects"
> cd /d "%USERPROFILE%\projects"
> mkdir hello_world
> cd hello_world
Écrire et exécuter un programme Rust
Ensuite, créez un nouveau fichier source et appelez-le main.rs. Les fichiers
Rust se terminent toujours par l'extension .rs. Si vous utilisez plusieurs
mots dans votre nom de fichier, utilisez un tiret bas (_
) pour
les séparer. Par exemple, vous devriez utiliser hello_world.rs au lieu de
helloworld.rs.
Maintenant, ouvrez le fichier main.rs que vous venez de créer et entrez le code de l'encart 1-1.
Fichier : main.rs
fn main() { println!("Hello, world!"); }
Encart 1-1 : Un programme qui affiche Hello, world!
Enregistrez le fichier et retournez dans votre terminal. Sur Linux ou macOS, écrivez les commandes suivantes pour compiler et exécuter le fichier :
$ rustc main.rs
$ ./main
Hello, world!
Sur Windows, écrivez la commande .\main.exe
à la place de .\main
:
> rustc main.rs
> .\main.exe
Hello, world!
Peu importe votre système d'exploitation, la chaîne de caractères
Hello, world!
devrait s'écrire dans votre terminal. Si cela ne s'affiche pas,
référez-vous à la partie "Dépannage" du
chapitre d'installation pour vous aider.
Si Hello, world!
s'affiche, félicitations ! Vous avez officiellement écrit un
programme Rust. Cela fait de vous un développeur Rust — bienvenue !
Structure d'un programme Rust
Regardons en détail ce qui s'est passé dans votre programme “Hello, world!”. Voici le premier morceau du puzzle :
fn main() { }
Ces lignes définissent une fonction dans Rust. La fonction main
est spéciale :
c'est toujours le premier code qui est exécuté dans tous les programmes
en Rust. La première ligne déclare une fonction qui s'appelle main
, qui n'a
pas de paramètre et qui ne retourne aucune valeur. S'il y avait des paramètres,
ils seraient placés entre les parenthèses ()
.
À noter en outre que le corps de la fonction est placé entre des accolades
{}
. Rust en a besoin autour du corps de chaque fonction. C'est une
bonne pratique d'insérer l'accolade ouvrante sur la même ligne que la
déclaration de la fonction, en ajoutant une espace entre les deux.
Si vous souhaitez formater le code de vos projets Rust de manière standardisé,
vous pouvez utiliser un outil de formatage automatique tel que rustfmt
.
L'équipe de Rust a intégré cet outil dans la distribution standard de Rust,
comme pour rustc
par exemple, donc il est probablement déjà installé sur votre
ordinateur ! Consultez la documentation en ligne pour en savoir plus.
À l'intérieur de la fonction main
, nous avons le code suivant :
#![allow(unused)] fn main() { println!("Hello, world!"); }
Cette ligne fait tout le travail dans ce petit programme : il écrit le texte à l'écran. Il y a quatre détails importants à noter ici.
Premièrement, le style de Rust est d'indenter avec quatre espaces, et non pas avec une tabulation.
Deuxièmement, println!
fait appel à une macro Rust. S'il appelait une
fonction à la place, cela serait écrit println
(sans le !
). Nous aborderons
les macros Rust plus en détail dans le chapitre 19. Pour l'instant, vous avez
juste à savoir qu'utiliser un !
signifie que vous utilisez une macro plutôt
qu'une fonction classique. Les macros ne suivent pas toujours les mêmes règles
que les fonctions.
Troisièmement, vous voyez la chaîne de caractères "Hello, world!"
. Nous
envoyons cette chaîne en argument à println!
et cette chaîne est affichée
à l'écran.
Quatrièmement, nous terminons la ligne avec un point-virgule (;
), qui indique
que cette expression est terminée et que la suivante est prête à commencer. La
plupart des lignes de Rust se terminent avec un point-virgule.
La compilation et l'exécution sont des étapes séparées
Vous venez de lancer un nouveau programme fraîchement créé, donc penchons-nous sur chaque étape du processus.
Avant de lancer un programme Rust, vous devez le compiler en utilisant le
compilateur Rust en entrant la commande rustc
et en lui passant le nom de
votre fichier source, comme ceci :
$ rustc main.rs
Si vous avez de l'expérience en C ou en C++, vous observerez des similarités
avec gcc
ou clang
.
Après avoir compilé avec succès, Rust produit un binaire exécutable.
Avec Linux, macOS et PowerShell sous Windows, vous pouvez voir l'exécutable en
utilisant la commande ls
dans votre terminal. Avec Linux et macOS,
vous devriez voir deux fichiers. Avec PowerShell sous Windows, vous devriez voir
les trois mêmes fichiers que vous verriez en utilisant CMD.
$ ls
main main.rs
Avec CMD sous Windows, vous devez saisir la commande suivante :
> dir /B %= l'option /B demande à n'afficher que les noms de fichiers =%
main.exe
main.pdb
main.rs
Ceci affiche le fichier de code source avec l'extension .rs, le fichier exécutable (main.exe sous Windows, mais main sur toutes les autres plateformes) et, quand on utilise Windows, un fichier qui contient des informations de débogage avec l'extension .pdb. Dans ce dossier, vous pouvez exécuter le fichier main ou main.exe comme ceci :
$ ./main # ou .\main.exe sous Windows
Si main.rs était votre programme “Hello, world!”, cette ligne devrait afficher
Hello, world!
dans votre terminal.
Si vous connaissez un langage dynamique, comme Ruby, Python, ou JavaScript, vous n'avez peut-être pas l'habitude de compiler puis lancer votre programme dans des étapes séparées. Rust est un langage à compilation anticipée, ce qui veut dire que vous pouvez compiler le programme et le donner à quelqu'un d'autre, et il peut l'exécuter sans avoir Rust d'installé. Si vous donnez à quelqu'un un fichier .rb, .py ou .js, il a besoin d'avoir respectivement un interpréteur Ruby, Python, ou Javascript d'installé. Cependant, avec ces langages, vous n'avez besoin que d'une seule commande pour compiler et exécuter votre programme. Dans la conception d'un langage, tout est une question de compromis.
Compiler avec rustc
peut suffire pour de petits programmes, mais au fur et à
mesure que votre programme grandit, vous allez avoir besoin de régler plus
d'options et faciliter le partage de votre code. À la page suivante, nous allons
découvrir l'outil Cargo, qui va vous aider à écrire des programmes Rust à
l'épreuve de la réalité.
Hello, Cargo!
Cargo est le système de compilation et de gestion de paquets de Rust. La plupart des Rustacés utilisent cet outil pour gérer les projets Rust, car Cargo s'occupe de nombreuses tâches pour vous, comme compiler votre code, télécharger les bibliothèques dont votre code dépend, et compiler ces bibliothèques. (On appelle dépendance une bibliothèque nécessaire pour votre code.)
Des programmes Rust très simples, comme le petit que nous avons écrit précédemment, n'ont pas de dépendance. Donc si nous avions compilé le projet “Hello, world!” avec Cargo, cela n'aurait fait appel qu'à la fonctionnalité de Cargo qui s'occupe de la compilation de votre code. Quand vous écrirez des programmes Rust plus complexes, vous ajouterez des dépendances, et si vous créez un projet en utilisant Cargo, l'ajout des dépendances sera plus facile à faire.
Comme la large majorité des projets Rust utilisent Cargo, la suite de ce livre va supposer que vous utilisez aussi Cargo. Cargo s'installe avec Rust si vous avez utilisé l'installateur officiel présenté dans la section “Installation”. Si vous avez installé Rust autrement, vérifiez que Cargo est installé en utilisant la commande suivante dans votre terminal :
$ cargo --version
Si vous voyez un numéro de version, c'est qu'il est installé ! Si vous voyez une
erreur comme Commande non trouvée
(ou command not found
), alors consultez la
documentation de votre méthode d'installation pour savoir comment installer
séparément Cargo.
Créer un projet avec Cargo
Créons un nouveau projet en utilisant Cargo et analysons les différences avec notre projet initial “Hello, world!”. Retournez dans votre dossier projects (ou là où vous avez décidé d'enregistrer votre code). Ensuite, sur n'importe quel système d'exploitation, lancez les commandes suivantes :
$ cargo new hello_cargo
$ cd hello_cargo
La première commande a crée un nouveau dossier appelé hello_cargo. Nous avons appelé notre projet hello_cargo, et Cargo crée ses fichiers dans un dossier avec le même nom.
Rendez-vous dans le dossier hello_cargo et afficher la liste des fichiers. Vous constaterez que Cargo a généré deux fichiers et un dossier pour nous : un fichier Cargo.toml et un dossier src avec un fichier main.rs à l'intérieur.
Il a aussi créé un nouveau dépôt Git ainsi qu'un fichier .gitignore. Les
fichiers de Git ne seront pas générés si vous lancez cargo new
au sein d'un
dépôt Git ; vous pouvez désactiver ce comportement temporairement en utilisant
cargo new --vcs=git
.
Note : Git est un système de gestion de versions très répandu. Vous pouvez changer
cargo new
pour utiliser un autre système de gestion de versions ou ne pas en utiliser du tout en écrivant le drapeau--vcs
. Lancezcargo new --help
pour en savoir plus sur les options disponibles.
Ouvrez Cargo.toml dans votre éditeur de texte favori. Son contenu devrait être similaire au code dans l'encart 1-2.
Fichier : Cargo.toml
[package]
name = "hello_cargo"
version = "0.1.0"
edition = "2021"
[dependencies]
Encart 1-2 : Contenu de Cargo.toml généré par cargo new
Ce fichier est au format TOML (Tom’s Obvious, Minimal Language), qui est le format de configuration de Cargo.
La première ligne, [package]
, est un en-tête de section qui indique que les
instructions suivantes configurent un paquet. Au fur et à mesure que nous
ajouterons plus de détails à ce fichier, nous ajouterons des sections
supplémentaires.
Les trois lignes suivantes définissent les informations de configuration dont
Cargo a besoin pour compiler votre programme : le nom, la version, et l'édition
de Rust à utiliser. Nous aborderons la clé edition
dans
l'Annexe E.
La dernière ligne, [dependencies]
, est le début d'une section qui vous permet
de lister les dépendances de votre projet. Dans Rust, les paquets de code sont
désignés sous le nom de crates. Nous n'allons pas utiliser de crate pour ce
projet, mais nous le ferons pour le premier projet au chapitre 2 ; nous
utiliserons alors cette section à ce moment-là.
Maintenant, ouvrez src/main.rs et jetez-y un coup d'œil :
Fichier : src/main.rs
fn main() { println!("Hello, world!"); }
Cargo a généré un programme “Hello, world!” pour vous, exactement comme celui que nous avons écrit dans l'encart 1-1 ! Pour le moment, les seules différences entre notre projet précédent et le projet que Cargo a généré sont que Cargo a placé le code dans le dossier src, et que nous avons un fichier de configuration Cargo.toml à la racine du dossier projet.
Cargo prévoit de stocker vos fichiers sources dans le dossier src. Le dossier parent est là uniquement pour les fichiers README, pour les informations à propos de la licence, pour les fichiers de configuration et tout ce qui n'est pas directement relié à votre code. Utiliser Cargo vous aide à structurer vos projets. Il y a un endroit pour tout, et tout est à sa place.
Si vous commencez un projet sans utiliser Cargo, comme nous l'avons fait avec le projet “Hello, world!”, vous pouvez le transformer en projet qui utilise Cargo. Déplacez le code de votre projet dans un dossier src et créez un fichier Cargo.toml adéquat.
Compiler et exécuter un projet Cargo
Maintenant, regardons ce qu'il y a de différent quand nous compilons et exécutons le programme “Hello, world!” avec Cargo ! À l'intérieur de votre dossier hello_cargo, compilez votre projet en utilisant la commande suivante :
$ cargo build
Compiling hello_cargo v0.1.0 (file:///projects/hello_cargo)
Finished dev [unoptimized + debuginfo] target(s) in 2.85 secs
Cette commande crée un fichier exécutable dans target/debug/hello_cargo (ou target\debug\hello_cargo.exe sous Windows) plutôt que de le déposer dans votre dossier courant. Vous pouvez lancer l'exécutable avec cette commande :
$ ./target/debug/hello_cargo # ou .\target\debug\hello_cargo.exe sous Windows
Hello, world!
Si tout s'est bien passé, Hello, world!
devrait s'afficher dans le terminal.
Lancer cargo build
pour la première fois devrait aussi mener Cargo à créer
un nouveau fichier à la racine du dossier projet : Cargo.lock. Ce fichier
garde une trace des versions exactes des dépendances de votre
projet. Ce projet n'a pas de dépendance, donc le fichier est un peu vide. Vous
n'aurez jamais besoin de changer ce fichier manuellement ; Cargo va gérer son
contenu pour vous.
Nous venons de compiler un projet avec cargo build
avant de l'exécuter avec
./target/debug/hello_cargo
, mais nous pouvons aussi utiliser cargo run
pour
compiler le code et ensuite lancer l'exécutable dans une seule et même
commande :
$ cargo run
Finished dev [unoptimized + debuginfo] target(s) in 0.0 secs
Running `target/debug/hello_cargo`
Hello, world!
Notez que cette fois-ci, nous ne voyons pas de messages indiquant que Cargo a
compilé hello_cargo
. Cargo a détecté que les fichiers n'avaient pas changé,
donc il a juste exécuté le binaire. Si vous aviez modifié votre code source,
Cargo aurait recompilé le projet avant de le lancer, et vous auriez eu les
messages suivants :
$ cargo run
Compiling hello_cargo v0.1.0 (file:///projects/hello_cargo)
Finished dev [unoptimized + debuginfo] target(s) in 0.33 secs
Running `target/debug/hello_cargo`
Hello, world!
Cargo fournit aussi une commande appelée cargo check
. Elle vérifie rapidement
votre code pour s'assurer qu'il est compilable, mais ne produit pas
d'exécutable :
$ cargo check
Checking hello_cargo v0.1.0 (file:///projects/hello_cargo)
Finished dev [unoptimized + debuginfo] target(s) in 0.32 secs
Dans quel cas n'aurions-nous pas besoin d'un exécutable ? Parfois, cargo check
est bien plus rapide que cargo build
, car il saute l'étape de création de
l'exécutable. Si vous vérifiez votre travail continuellement pendant que vous
écrivez votre code, utiliser cargo check
accélèrera le processus ! C'est
pourquoi de nombreux Rustacés utilisent périodiquement cargo check
quand ils
écrivent leur programme afin de s'assurer qu'il compile. Ensuite, ils lancent
cargo build
quand ils sont prêts à utiliser l'exécutable.
Récapitulons ce que nous avons appris sur Cargo :
- Nous pouvons créer un projet en utilisant
cargo new
. - Nous pouvons compiler un projet en utilisant
cargo build
. - Nous pouvons compiler puis exécuter un projet en une seule fois en utilisant
cargo run
. - Nous pouvons compiler un projet sans produire de binaire afin de vérifier
l'existance d'erreurs en utilisant
cargo check
. - Au lieu d'enregistrer le résultat de la compilation dans le même dossier que votre code, Cargo l'enregistre dans le dossier target/debug.
Un autre avantage d'utiliser Cargo est que les commandes sont les mêmes peu importe le système d'exploitation que vous utilisez. Donc à partir de maintenant, nous n'allons plus faire d'opérations spécifiques à Linux et macOS par rapport à Windows.
Compiler pour diffuser
Quand votre projet est finalement prêt à être diffusé, vous pouvez utiliser
cargo build --release
pour le compiler en l'optimisant. Cette commande va
créer un exécutable dans target/release au lieu de target/debug. Ces
optimisations rendent votre code Rust plus rapide à exécuter, mais l'utiliser
rallonge le temps de compilation de votre programme. C'est pourquoi il y a deux
différents profils : un pour le développement, quand vous voulez recompiler
rapidement et souvent, et un autre pour compiler le programme final qui sera
livré à un utilisateur, qui n'aura pas besoin d'être recompilé à plusieurs
reprises et qui s'exécutera aussi vite que possible. Si vous évaluez le temps
d'exécution de votre code, assurez-vous de lancer cargo build --release
et
d'utiliser l'exécutable dans target/release pour vos bancs de test.
Cargo comme convention
Pour des projets simples, Cargo n'apporte pas grand-chose par rapport à rustc
,
mais il vous montrera son intérêt au fur et à mesure
que vos programmes deviendront plus complexes. Avec des projets complexes
composés de plusieurs crates, il est plus facile de laisser Cargo prendre en
charge la coordination de la compilation.
Même si le projet hello_cargo
est simple, il utilise maintenant une grande
partie de l'outillage que vous rencontrerez dans votre carrière avec Rust. En
effet, pour travailler sur n'importe quel projet Rust existant, vous n'avez
qu'à saisir les commandes suivantes pour télécharger le code avec Git, vous
déplacer dans le dossier projet et compiler :
$ git clone example.org/projet_quelconque
$ cd projet_quelconque
$ cargo build
Pour plus d'informations à propos de Cargo, vous pouvez consulter sa documentation.
Résumé
Vous êtes déjà bien lancé dans votre périple avec Rust ! Dans ce chapitre, vous avez appris comment :
- Installer la dernière version stable de Rust en utilisant
rustup
- Mettre à jour Rust vers une nouvelle version
- Ouvrir la documentation installée en local
- Écrire et exécuter un programme “Hello, world!” en utilisant directement
rustc
- Créer et exécuter un nouveau projet en utilisant les conventions de Cargo
C'est le moment idéal pour construire un programme plus ambitieux pour s'habituer à lire et écrire du code Rust. Donc, au chapitre 2, nous allons écrire un programme de jeu de devinettes. Si vous préférez commencer par apprendre comment les principes de programmation de base fonctionnent avec Rust, rendez-vous au chapitre 3, puis revenez au chapitre 2.
Programmer le jeu du plus ou du moins
Entrons dans le vif du sujet en travaillant ensemble sur un projet concret !
Ce chapitre présente quelques concepts couramment utilisés en Rust en vous
montrant comment les utiliser dans un véritable programme. Nous aborderons
notamment les instructions let
et match
, les méthodes et fonctions
associées, l'utilisation des crates, et bien plus encore ! Dans les chapitres
suivants, nous approfondirons ces notions. Dans ce chapitre, vous n'allez
exercer que les principes de base.
Nous allons coder un programme fréquemment réalisé par les débutants en programmation : le jeu du plus ou du moins. Le principe de ce jeu est le suivant : le programme va tirer au sort un nombre entre 1 et 100. Il invitera ensuite le joueur à saisir un nombre qu'il pense deviner. Après la saisie, le programme indiquera si le nombre saisi par le joueur est trop grand ou trop petit. Si le nombre saisi est le bon, le jeu affichera un message de félicitations et se fermera.
Mise en place d'un nouveau projet
Pour créer un nouveau projet, rendez-vous dans le dossier projects que vous avez créé au chapitre 1 et utilisez Cargo pour créer votre projet, comme ceci :
$ cargo new jeu_du_plus_ou_du_moins
$ cd jeu_du_plus_ou_du_moins
La première commande, cargo new
, prend comme premier argument le nom de notre
projet (jeu_du_plus_ou_du_moins
). La seconde commande nous déplace dans le
dossier de notre nouveau projet créé par Cargo.
Regardons le fichier Cargo.toml qui a été généré :
Fichier : Cargo.toml
[package]
name = "jeu_du_plus_ou_du_moins"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
Comme vous l'avez expérimenté dans le chapitre 1, cargo new
génère un
programme “Hello, world!” pour vous. Ouvrez le fichier src/main.rs :
Fichier : src/main.rs
fn main() { println!("Hello, world!"); }
Maintenant, lançons la compilation de ce programme “Hello, world!” et
son exécution en une seule commande avec cargo run
:
$ cargo run
Compiling jeu_du_plus_ou_du_moins v0.1.0 (file:///projects/jeu_du_plus_ou_du_moins)
Finished dev [unoptimized + debuginfo] target(s) in 1.50s
Running `target/debug/jeu_du_plus_ou_du_moins`
Hello, world!
Cette commande run
est très pratique lorsqu'on souhaite itérer rapidement
sur un projet, comme c'est le cas ici, pour tester rapidement chaque
modification avant de passer à la suivante.
Ouvrez à nouveau le fichier src/main.rs. C'est dans ce fichier que nous écrirons la totalité de notre code.
Traitement d'un nombre saisi
La première partie du programme consiste à demander au joueur de saisir du texte, à traiter cette saisie, et à vérifier que la saisie correspond au format attendu. Commençons par permettre au joueur de saisir son nombre. Entrez le code de l'encart 2-1 dans le fichier src/main.rs.
Fichier : src/main.rs
use std::io;
fn main() {
println!("Devinez le nombre !");
println!("Veuillez entrer un nombre.");
let mut supposition = String::new();
io::stdin()
.read_line(&mut supposition)
.expect("Échec de la lecture de l'entrée utilisateur");
println!("Votre nombre : {}", supposition);
}
Encart 2-1 : Code permettant de récupérer une saisie utilisateur et de l'afficher
Ce code contient beaucoup d'informations, nous allons donc l'analyser petit
à petit. Pour obtenir la saisie utilisateur et ensuite l'afficher, nous avons
besoin d'importer la bibliothèque d'entrée/sortie io
(initiales
de input/output) afin de pouvoir l'utiliser. La bibliothèque io
provient de
la bibliothèque standard, connue sous le nom de std
:
use std::io;
fn main() {
println!("Devinez le nombre !");
println!("Veuillez entrer un nombre.");
let mut supposition = String::new();
io::stdin()
.read_line(&mut supposition)
.expect("Échec de la lecture de l'entrée utilisateur");
println!("Votre nombre : {}", supposition);
}
Par défaut, Rust importe dans la portée de tous les programmes quelques fonctionnalités définies dans la bibliothèque standard. Cela s'appelle l'étape préliminaire (the prelude), et vous pouvez en savoir plus dans sa documentation de la bibliothèque standard.
Si vous
voulez utiliser un type qui ne s'y trouve pas, vous devrez l'importer
explicitement avec l'instruction use
. L'utilisation de la bibliothèque
std::io
vous apporte de nombreuses fonctionnalités utiles, comme ici la
possibilité de récupérer une saisie utilisateur.
Comme vous l'avez vu au chapitre 1, la fonction main
est le point d'entrée
du programme :
use std::io;
fn main() {
println!("Devinez le nombre !");
println!("Veuillez entrer un nombre.");
let mut supposition = String::new();
io::stdin()
.read_line(&mut supposition)
.expect("Échec de la lecture de l'entrée utilisateur");
println!("Votre nombre : {}", supposition);
}
Le mot clé fn
déclare une nouvelle fonction, les parenthèses ()
indiquent
que cette fonction n'accepte aucun paramètre, et l'accolade ouvrante {
marque
le début du corps de la fonction.
Comme vous l'avez également appris au chapitre 1, println!
est une macro qui
affiche une chaîne de caractères à l'écran :
use std::io;
fn main() {
println!("Devinez le nombre !");
println!("Veuillez entrer un nombre.");
let mut supposition = String::new();
io::stdin()
.read_line(&mut supposition)
.expect("Échec de la lecture de l'entrée utilisateur");
println!("Votre nombre : {}", supposition);
}
Ce code affiche du texte qui indique le titre de notre jeu, et un autre qui demande au joueur d'entrer un nombre.
Enregistrer des données dans des variables
Ensuite, on crée une variable pour stocker la saisie de l'utilisateur, comme ceci :
use std::io;
fn main() {
println!("Devinez le nombre !");
println!("Veuillez entrer un nombre.");
let mut supposition = String::new();
io::stdin()
.read_line(&mut supposition)
.expect("Échec de la lecture de l'entrée utilisateur");
println!("Votre nombre : {}", supposition);
}
Le programme commence à devenir intéressant ! Il se passe beaucoup de choses
dans cette petite ligne. Nous utilisons l'instruction let
pour créer la
variable. Voici un autre exemple :
let pommes = 5;
Cette ligne permet de créer une nouvelle variable nommée pommmes
et à lui
assigner la valeur 5. Par défaut en Rust, les variables sont immuables.
Nous aborderons plus en détail cette notion dans la section “Variables et
Mutabilité” au chapitre 3. Pour
rendre une variable mutable (c'est-à-dire modifiable), nous ajoutons mut
devant le nom de la variable :
#![allow(unused)] fn main() { let pommes = 5; // immuable let mut bananes = 5; // mutable, modifiable }
Remarque : La syntaxe
//
permet de commencer un commentaire qui s'étend jusqu'à la fin de la ligne. Rust ignore tout ce qu'il y a dans un commentaire. Nous verrons plus en détail les commentaires dans le chapitre 3.
Lorsque vous revenez sur le jeu du plus ou du moins, vous comprenez donc
maintenant que la ligne let mut supposition
permet de créer une variable
mutable nommée supposition
. Le signe égal (=
) indique à Rust que nous
voulons désormais lier quelquechose à la variable. A la droite du signe égal,
nous avons la valeur liée à supposition
, qui est ici le résultat de
l'utilisation de String::new
, qui est une fonction qui retourne une nouvelle
instance de String
.
String
est un type de chaîne de caractères fourni
par la bibliothèque standard, qui est une portion de texte encodée en UTF-8 et
dont la longueur peut augmenter.
La syntaxe ::
dans String::new()
indique que new
est une fonction
associée au type String
. Une fonction associée est une fonction qui est
implémentée sur un type, ici String
. Cette fonction new
crée une nouvelle
chaîne de caractères vide, une nouvelle String
. Vous trouverez fréquemment
une fonction new
sur d'autres types, car c'est un nom souvent donné à une
fonction qui crée une nouvelle valeur ou instance d'un type.
En définitif, la ligne let mut supposition = String::new();
crée une nouvelle
variable mutable qui contient une nouvelle chaîne de caractères vide, une
instance de String
. Ouf !
Recueillir la saisie utilisateur
Rappelez-vous que nous avons importé les fonctionnalités d'entrée/sortie de la
bibliothèque standard avec use std::io;
à la première ligne de notre
programme. Nous allons maintenant appeler la fonction stdin
du module io
,
qui va nous permettre de traiter la saisie utilisateur :
use std::io;
fn main() {
println!("Devinez le nombre !");
println!("Veuillez entrer un nombre.");
let mut supposition = String::new();
io::stdin()
.read_line(&mut supposition)
.expect("Échec de la lecture de l'entrée utilisateur");
println!("Votre nombre : {}", supposition);
}
Si nous n'avions pas importé la bibliothèque io
avec use std::io
au début
du programme, on aurait toujours pu utiliser la fonction en écrivant l'appel à
la fonction de cette manière : std::io::stdin
. La fonction stdin
retourne
une instance de std::io::Stdin
, qui est un type qui
représente une référence abstraite (handle) vers l'entrée standard du
terminal dans lequel vous avez lancé le programme.
Ensuite, la ligne .read_line(&mut supposition)
appelle la méthode
read_line
sur l'entrée standard afin d'obtenir
la saisie utilisateur.
Nous passons aussi &mut supposition
en argument de read_line
pour lui
indiquer dans quelle chaîne de caractère il faut stocker la saisie utilisateur.
Le but final de read_line
est de récupérer tout ce que l'utilisateur écrit
dans l'entrée standard et de l'ajouter à la fin d'une chaîne de caractères
(sans écraser son contenu) ; c'est pourquoi nous passons cette chaîne de
caractères en argument. Cet argument doit être mutable pour que read_line
puisse en modifier le contenu.
Le &
indique que cet argument est une référence, ce qui permet de laisser
plusieurs morceaux de votre code accéder à une même donnée sans avoir besoin
de copier ces données dans la mémoire plusieurs fois. Les références sont une
fonctionnalité complexe, et un des avantages majeurs de Rust est qu'il rend sûr
et simple l'utilisation des références. Il n'est pas nécessaire de trop
s'apesantir sur les références pour terminer ce programme.
Pour l'instant, tout ce que vous devez savoir est que comme les variables, les
références sont immuables par défaut.
D'où la nécessité d'écrire &mut supposition
au lieu de &supposition
pour la
rendre mutable. (Le chapitre 4 expliquera plus en détail les références.)
Gérer les erreurs potentielles avec le type Result
Nous avons encore du travail sur cette ligne de code. Même si nous allons rajouter une troisième ligne de code, elle ne fait partie que d'une seule ligne de code. Cette nouvelle partie rajoute cette méthode :
use std::io;
fn main() {
println!("Devinez le nombre !");
println!("Veuillez entrer un nombre.");
let mut supposition = String::new();
io::stdin()
.read_line(&mut supposition)
.expect("Échec de la lecture de l'entrée utilisateur");
println!("Votre nombre : {}", supposition);
}
Nous aurions pu écrire ce code de cette manière :
io::stdin().read_line(&mut supposition).expect("Échec de la lecture de l'entrée utilisateur");
Cependant, une longue ligne de code n'est pas toujours facile à lire, c'est donc
une bonne pratique de la diviser. Il est parfois utile d'ajouter une nouvelle
ligne et des espaces afin de désagréger les longues lignes lorsque vous
appelerez une méthode, comme ici avec la syntaxe .nom_de_la_methode()
.
Maintenant, voyons à quoi sert cette ligne.
Comme expliqué précédemment, read_line
stocke dans la variable qu'on lui
passe en argument tout ce que l'utilisateur a saisi, mais cette fonction
retourne aussi une valeur − dans notre cas, de type
io::Result
. Il existe plusieurs types nommés
Result
dans la bibliothèque standard de Rust : un type générique
Result
ainsi que des déclinaisons spécifiques à
des sous-modules, comme io::Result
. Les types Result
sont des
énumérations, aussi appelées enums, qui peuvent
avoir un certain nombre de valeurs prédéfinies que l'on appelle variantes.
Les énumérations sont souvent utilisées avec match
, une structure
conditionelle qui facilite l'exécution d'un code différent en fonction de la
variante dans l'énumération au moment de son évaluation.
Le chapitre 6 explorera les énumérations plus en détail. La raison d'être du
type Result
est de coder des informations pour la gestion des erreurs.
Les variantes de Result
sont Ok
et Err
. La variante Ok
signifie que
l'opération a fonctionné, et à l'intérieur de Ok
se trouve la valeur générée
avec succès. La variante Err
signifie que l'opération a échoué, et Err
contient les informations décrivant comment ou pourquoi l'opération a échoué.
Les valeurs du type Result
, comme pour tous les types, ont des méthodes
qui leur sont associées. Par exemple, une instance de io::Result
a une
méthode expect
que vous pouvez utiliser. Si cette
instance de io::Result
a pour valeur la variante Err
, l'appel à expect
fera planter le programme et affichera le message que vous avez passé en
argument de expect
. Si l'appel à read_line
retourne une variante Err
, ce
sera probablement dû à une erreur du système d'exploitation. Si en revanche
read_line
a pour valeur la variante Ok
, expect
récupèrera le
contenu du Ok
, qui est le résultat de l'opération, et vous le retournera afin
que vous puissiez l'utiliser. Dans notre exemple, ce résultat est le nombre
d'octets de la saisie utilisateur.
Si on n'appelle pas expect
, le programme compilera, mais avec un
avertissement :
$ cargo build
Compiling jeu_du_plus_ou_du_moins v0.1.0 (file:///projects/jeu_du_plus_ou_du_moins)
warning: unused `Result` that must be used
--> src/main.rs:10:5
|
10 | io::stdin().read_line(&mut supposition);
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
= note: `#[warn(unused_must_use)]` on by default
= note: this `Result` may be an `Err` variant, which should be handled
warning: `jeu_du_plus_ou_du_moins` (bin "jeu_du_plus_ou_du_moins") generated 1 warning
Finished dev [unoptimized + debuginfo] target(s) in 0.59s
Rust nous prévient que l'on ne fait rien du Result
que nous fournit
read_line
, et que par conséquent notre programme ne gère pas une erreur
potentielle.
La meilleure façon de masquer cet avertissement est de réellement écrire le
code permettant de gérer l'erreur, mais dans notre cas on a seulement besoin de
faire planter le programme si un problème survient, on utilise donc expect
.
Nous verrons dans le chapitre 9 comment gérer
correctement les erreurs.
Afficher des valeurs grâce aux espaces réservés de println!
Mis à part l'accolade fermante, il ne nous reste plus qu'une seule ligne à étudier dans le code que nous avons pour l'instant :
use std::io;
fn main() {
println!("Devinez le nombre !");
println!("Veuillez entrer un nombre.");
let mut supposition = String::new();
io::stdin()
.read_line(&mut supposition)
.expect("Échec de la lecture de l'entrée utilisateur");
println!("Votre nombre : {}", supposition);
}
Cette ligne affiche la chaîne de caractères qui contient maintenant ce que
l'utilisateur a saisi. La paire d'accolades {}
représente un espace réservé :
imaginez qu'il s'agit de pinces de crabes qui gardent la place d'une valeur.
Vous pouvez afficher plusieurs valeurs en utilisant des accolades : la première
paire d'accolades affichera la première valeur listée après la chaîne de
formatage, la deuxième paire d'accolades affichera la deuxième valeur, et ainsi
de suite. Pour afficher plusieurs valeurs en appelant println!
une seule
fois, on ferait comme ceci :
#![allow(unused)] fn main() { let x = 5; let y = 10; println!("x = {} et y = {}", x, y); }
Ce code afficherait x = 5 et y = 10
.
Test de la première partie
Pour tester notre début de programme, lançons-le à l'aide de la commande
cargo run
:
$ cargo run
Compiling jeu_du_plus_ou_du_moins v0.1.0 (file:///projects/jeu_du_plus_ou_du_moins)
Finished dev [unoptimized + debuginfo] target(s) in 6.44s
Running `target/debug/jeu_du_plus_ou_du_moins`
Devinez le nombre !
Veuillez entrer un nombre.
6
Votre nombre : 6
À ce stade, la première partie de notre programme est terminée : nous avons récupéré la saisie du clavier et nous l'affichons à l'écran.
Générer le nombre secret
Maintenant, il nous faut générer un nombre secret que notre joueur va devoir
deviner. Ce nombre devra être différent à chaque fois pour qu'on puisse
s'amuser à y jouer plusieurs fois. Nous allons tirer au sort un nombre compris
entre 1 et 100 pour que le jeu ne soit pas trop difficile. Rust n'embarque pas
pour l'instant de fonctionnalité de génération de nombres aléatoires dans sa
bibliothèque standard. Cependant, l'équipe de Rust propose une
crate rand
qui offre la possibilité de le faire.
Étendre les fonctionnalités de Rust avec une crate
Souvenez-vous, une crate est un ensemble de fichiers de code source Rust. Le
projet sur lequel nous travaillons est une crate binaire, qui est un programme
exécutable. La crate rand
est une crate de bibliothèque, qui contient du
code qui peut être utilisé dans d'autres programmes, et qui ne peut pas être
exécuté tout seul.
La coordination des crates externes est un domaine dans lequel Cargo excelle.
Avant d'écrire le code qui utilisera rand
, il nous faut éditer le fichier
Cargo.toml pour y spécifier rand
en tant que dépendance. Ouvrez donc
maintenant ce fichier et ajoutez la ligne suivante à la fin, en dessous de
l'en-tête de section [dependencies]
que Cargo a créé pour vous. Assurez-vous
de spécifier rand
exactement comme dans le bout de code suivant, avec ce
numéro de version, ou sinon les exemples de code de ce tutoriel pourraient ne
pas fonctionner.
Fichier : Cargo.toml
rand = "0.8.3"
Dans le fichier Cargo.toml, tout ce qui suit une en-tête fait partie de cette
section, et ce jusqu'à ce qu'une autre section débute. Dans [dependencies]
,
vous indiquez à Cargo de quelles crates externes votre
projet dépend, et de quelle version de ces crates vous avez besoin.
Dans notre cas, on ajoute comme dépendance la crate rand
avec la version
sémantique 0.8.3
. Cargo arrive à interpréter le
versionnage sémantique (aussi appelé SemVer), qui
est une convention d'écriture de numéros de version. En réalité, 0.8.3
est
une abréviation pour ^0.8.3
, ce qui signifie “toute version ultérieure ou
égale à 0.8.3
mais strictement antérieure à 0.9.0
”. Cargo considère que ces
versions ont des API publiques compatibles avec la version 0.8.3
, et cette
indication garantit que vous obtiendrez la dernière version de correction qui
compilera encore avec le code de ce chapitre. Il n'est pas garanti que les
versions 0.9.0
et ultérieures aient la même API que celle utilisée dans les
exemples suivants.
Maintenant, sans apporter le moindre changement au code, lançons une compilation du projet, comme dans l'encart 2-2 :
$ cargo build
Updating crates.io index
Downloaded rand v0.8.3
Downloaded libc v0.2.86
Downloaded getrandom v0.2.2
Downloaded cfg-if v1.0.0
Downloaded ppv-lite86 v0.2.10
Downloaded rand_chacha v0.3.0
Downloaded rand_core v0.6.2
Compiling rand_core v0.6.2
Compiling libc v0.2.86
Compiling getrandom v0.2.2
Compiling cfg-if v1.0.0
Compiling ppv-lite86 v0.2.10
Compiling rand_chacha v0.3.0
Compiling rand v0.8.3
Compiling jeu_du_plus_ou_du_moins v0.1.0 (file:///projects/jeu_du_plus_ou_du_moins)
Finished dev [unoptimized + debuginfo] target(s) in 2.53s
Encart 2-2 : Résultat du lancement de cargo build
après
avoir ajouté la crate rand
comme dépendance
Il est possible que vous ne voyiez pas exactement les mêmes numéros de version, (mais ils seront compatibles avec votre code, grâce au versionnage sémantique !), différentes lignes (en fonction de votre système d'exploitation), et les lignes ne seront pas forcément affichées dans le même ordre.
Lorsque nous ajoutons une dépendance externe, Cargo récupère la dernière version de tout dont la dépendance nécessite depuis le registre, qui est une copie des données de Crates.io. Crates.io est là où les développeurs de l'écosystème Rust publient leurs projets open source afin de les rendre disponibles aux autres.
Une fois le registre mis à jour, Cargo lit la section [dependencies]
et se
charge de télécharger les crates qui y sont listés que vous n'avez pas encore
téléchargé. Dans notre cas, bien que nous n'ayons spécifié qu'une seule
dépendance, rand
, Cargo a aussi téléchargé d'autres crates dont dépend
rand
pour fonctionner. Une fois le téléchargement terminé des crates, Rust
les compile, puis compile notre projet avec les dépendances disponibles.
Si vous relancez tout de suite cargo build
sans changer quoi que ce soit, vous
n'obtiendrez rien d'autre que la ligne Finished
. Cargo sait qu'il a déjà
téléchargé et compilé les dépendances, et que vous n'avez rien changé dans votre
fichier Cargo.toml. Cargo sait aussi que vous n'avez rien changé dans votre
code, donc il ne le recompile pas non plus. Étant donné qu'il n'a rien à faire,
Cargo se termine tout simplement.
Si vous ouvrez le fichier src/main.rs, faites un changement très simple, enregistrez le fichier, et relancez la compilation, vous verrez s'afficher uniquement deux lignes :
$ cargo build
Compiling jeu_du_plus_ou_du_moins v0.1.0 (file:///projects/jeu_du_plus_ou_du_moins)
Finished dev [unoptimized + debuginfo] target(s) in 2.53 secs
Ces lignes nous informent que Cargo a recompilé uniquement à cause de notre petit changement dans le fichier src/main.rs. Les dépendances n'ayant pas changé, Cargo sait qu'il peut simplement réutiliser ce qu'il a déjà téléchargé et compilé précédemment.
Assurer la reproductibilité des compilations avec le fichier Cargo.lock
Cargo embarque une fonctionnalité qui garantie que vous pouvez recompiler le
même artéfact à chaque fois que vous ou quelqu'un d'autre compile votre code :
Cargo va utiliser uniquement les versions de dépendances que vous avez
utilisées jusqu'à ce que vous indiquiez le contraire.
Par exemple, immaginons que la semaine prochaine, la version 0.8.4 de la
crate rand
est publiée, et qu'elle apporte une correction importante, mais
aussi qu'elle produit une régression qui va casser votre code. Pour éviter cela,
Rust crée le fichier Cargo.lock la première fois que vous utilisez
cargo build
, donc nous l'avons désormais dans le dossier
jeu_du_plus_ou_du_moins.
Quand vous compilez un projet pour la
première fois, Cargo détermine toutes les versions de dépendances qui
correspondent à vos critères et les écrit dans le fichier Cargo.lock. Quand
vous recompilerez votre projet plus tard, Cargo verra que le fichier
Cargo.lock existe et utilisera les versions précisées à l'intérieur au lieu
de recommencer à déterminer toutes les versions demandées.
Ceci vous permet d'avoir automatiquement des compilations reproductibles.
En d'autres termes, votre projet va rester sur la version 0.8.3
jusqu'à ce
que vous le mettiez à jour explicitement, grâce au fichier Cargo.lock.
Mettre à jour une crate vers sa nouvelle version
Lorsque vous souhaitez réellement mettre à jour une crate, Cargo vous fournit
la commande update
, qui va ignorer le fichier Cargo.lock et va rechercher
toutes les versions qui correspondent à vos critères dans Cargo.toml. Cargo
va ensuite écrire ces versions dans le fichier Cargo.lock. Sinon par défaut,
Cargo va rechercher uniquement les versions plus grandes que 0.8.3
et
inférieures à 0.9.0
. Si la crate rand
a été publiée en deux nouvelles
versions 0.8.4
et 0.9.0
, alors vous verrez ceci si vous lancez
cargo update
:
$ cargo update
Updating crates.io index
Updating rand v0.8.3 -> v0.8.4
Cargo ignore la version 0.9.0
. À partir de ce moment, vous pouvez aussi
constater un changement dans le fichier Cargo.lock indiquant que la version
de la crate rand
que vous utilisez maintenant est la 0.8.4
. Pour utiliser
rand
en version 0.9.0
ou toute autre version dans la série des 0.9.x
, il
vous faut mettre à jour le fichier Cargo.toml comme ceci :
[dependencies]
rand = "0.9.0"
La prochaine fois que vous lancerez cargo build
, Cargo mettra à jour son
registre de crates disponibles et réévaluera vos exigences vis-à-vis de rand
selon la nouvelle version que vous avez spécifiée.
Il y a encore plus à dire à propos de Cargo et de son écosystème que nous aborderons au chapitre 14, mais pour l'instant, c'est tout ce qu'il vous faut savoir. Cargo facilite la réutilisation des bibliothèques, pour que les Rustacés soient capables d'écrire des petits projets issus d'un assemblage d'un certain nombre de paquets.
Générer un nombre aléatoire
Commençons désormais à utiliser rand
pour générer un nombre à deviner. La
prochaine étape est de modifier src/main.rs comme dans l'encart 2-3.
Fichier : src/main.rs
use std::io;
use rand::Rng;
fn main() {
println!("Devinez le nombre !");
let nombre_secret = rand::thread_rng().gen_range(1..101);
println!("Le nombre secret est : {}", nombre_secret);
println!("Veuillez entrer un nombre.");
let mut supposition = String::new();
io::stdin()
.read_line(&mut supposition)
.expect("Échec de la lecture de l'entrée utilisateur");
println!("Votre nombre : {}", supposition);
}
Encart 2-3 : Ajout du code pour générer un nombre aléatoire
D'abord, nous avons ajouté la ligne use rand::Rng
. Le trait Rng
définit
les méthodes implémentées par les générateurs de nombres aléatoires, et ce
trait doit être accessible à notre code pour qu'on puisse utiliser ces
méthodes. Le chapitre 10 expliquera plus en détail les traits.
Ensuite, nous ajoutons deux lignes au milieu. A la première ligne, nous
appelons la fonction rand::thread_rng
qui nous fournit le générateur de
nombres aléatoires particulier que nous allons utiliser : il est propre au fil
d'exécution courant et généré par le système d'exploitation. Ensuite, nous
appelons la méthode gen_range
sur le générateur de nombres aléatoires. Cette
méthode est définie par le trait Rng
que nous avons importé avec
l'instruction use rand::Rng
. La méthode gen_range
prend une expression
d'intervalle en paramètre et génère un nombre aléatoire au sein de
l'intervalle. Le genre d'expression d'intervalle utilisé ici est de la forme
début..fin
et inclut la borne inférieure mais exclut la borne supérieure,
nous avons donc besoin de préciser 1..101
pour demander un nombre entre 1
et 100. De manière équivalente, nous pourrions également passer l'intervalle
fermé 1..=100
Remarque : vous ne pourrez pas deviner quels traits, méthodes et fonctions utiliser avec une crate, donc chaque crate a une documentation qui donne des indications sur son utilisation. Une autre fonctionnalité intéressante de Cargo est que vous pouvez utiliser la commande
cargo doc --open
, qui va construire localement la documentation intégrée par toutes vos dépendances et va l'ouvrir dans votre navigateur. Si vous vous intéressez à d'autres fonctionnalités de la craterand
, par exemple, vous pouvez lancercargo doc --open
et cliquer surrand
dans la barre latérale sur la gauche.
La seconde nouvelle ligne affiche le nombre secret. C'est pratique lors du développement pour pouvoir le tester, mais nous l'enlèverons dans la version finale. Ce n'est pas vraiment un jeu si le programme affiche la réponse dès qu'il démarre !
Essayez de lancer le programme plusieurs fois :
$ cargo run
Compiling jeu_du_plus_ou_du_moins v0.1.0 (file:///projects/jeu_du_plus_ou_du_moins)
Finished dev [unoptimized + debuginfo] target(s) in 2.53s
Running `target/debug/jeu_du_plus_ou_du_moins`
Devinez le nombre !
Le nombre secret est : 7
Veuillez entrer un nombre.
4
Votre nombre : 4
$ cargo run
Finished dev [unoptimized + debuginfo] target(s) in 0.02s
Running `target/debug/jeu_du_plus_ou_du_moins`
Devinez le nombre !
Le nombre secret est : 83
Veuillez entrer un nombre.
5
Votre nombre : 5
Vous devriez obtenir des nombres aléatoires différents, et ils devraient être tous compris entre 1 et 100. Beau travail !
Comparer le nombre saisi au nombre secret
Maintenant que nous avons une saisie utilisateur et un nombre aléatoire, nous pouvons les comparer. Cette étape est écrite dans l'encart 2-4. Sachez toutefois que le code ne se compile pas encore, nous allons l'expliquer par la suite.
Fichier : src/main.rs
use rand::Rng;
use std::cmp::Ordering;
use std::io;
fn main() {
// -- partie masquée ici --
println!("Devinez le nombre !");
let nombre_secret = rand::thread_rng().gen_range(1..101);
println!("Le nombre secret est : {}", nombre_secret);
println!("Veuillez entrer un nombre.");
let mut supposition = String::new();
io::stdin()
.read_line(&mut supposition)
.expect("Échec de la lecture de l'entrée utilisateur");
println!("Votre nombre : {}", supposition);
match supposition.cmp(&nombre_secret) {
Ordering::Less => println!("C'est plus !"),
Ordering::Greater => println!("C'est moins !"),
Ordering::Equal => println!("Vous avez gagné !"),
}
}
Encart 2-4 : Traitement des valeurs possibles saisies en comparant les deux nombres
Premièrement, nous ajoutons une autre instruction use
, qui importe
std::cmp::Ordering
à portée de notre code depuis la bibliothèque standard.
Le type Ordering
est une autre énumération et a les variantes Less
(inférieur), Greater
(supérieur) et Equal
(égal). Ce sont les trois
issues possibles lorsqu'on compare deux valeurs.
Ensuite, nous ajoutons cinq nouvelles lignes à la fin qui utilisent le type
Ordering
. La méthode cmp
compare deux valeurs et peut être appelée sur
tout ce qui peut être comparé. Elle prend en paramètre une référence de ce qu'on
veut comparer : ici, nous voulons comparer supposition
et nombre_secret
.
Ensuite, cela retourne une variante de l'énumération Ordering
que nous avons
importée avec l'instruction use
. Nous utilisons une expression
match
pour décider quoi faire ensuite en fonction de
quelle variante de Ordering
a été retournée à l'appel de cmp
avec
supposition
et nombre_secret
.
Une expression match
est composée de branches. Une branche est constituée
d'un motif (pattern) avec lequel elle doit correspondre et du code qui sera
exécuté si la valeur donnée au match
correspond bien au motif de cette
branche. Rust prend la valeur donnée à match
et la compare au motif de chaque
branche à tour de rôle. Les motifs et la structure de contrôle match
sont des
fonctionnalités puissantes de Rust qui vous permettent de décrire une multitude
de scénarios que votre code peut rencontrer et de s'assurer que vous les gérez
toutes. Ces fonctionnalités seront expliquées plus en détail respectivement
dans le chapitre 6 et le chapitre 18.
Voyons un exemple avec l'expression match
que nous avons utilisé ici. Disons
que l'utilisateur a saisi le nombre 50 et que le nombre secret généré
aléatoirement a cette fois-ci comme valeur 38. Quand le code compare 50 à 38,
la méthode cmp
va retourner Ordering::Greater
, car 50 est plus grand
que 38. L'expression match
obtient la valeur Ordering::Greater
et commence
à vérifier le motif de chaque branche. Elle consulte le motif de la première
branche, Ordering::Less
et remarque que la valeur Ordering::Greater
ne
correspond pas au motif Ordering::Less
; elle ignore donc le code de cette
branche et passe à la suivante. Le motif de la branche suivante est
Ordering::Greater
, qui correspond à Ordering::Greater
! Le code associé à
cette branche va être exécuté et va afficher à l'écran C'est moins !
.
L'expression match
se termine ensuite, car elle n'a pas besoin de consulter
les autres branches de ce scénario.
Cependant, notre code dans l'encart 2-4 ne compile pas encore. Essayons de le faire :
$ cargo build
Compiling libc v0.2.86
Compiling getrandom v0.2.2
Compiling cfg-if v1.0.0
Compiling ppv-lite86 v0.2.10
Compiling rand_core v0.6.2
Compiling rand_chacha v0.3.0
Compiling rand v0.8.3
Compiling jeu_du_plus_ou_du_moins v0.1.0 (file:///projects/jeu_du_plus_ou_du_moins)
error[E0308]: mismatched types
--> src/main.rs:22:21
|
22 | match supposition.cmp(&nombre_secret) {
| ^^^^^^^^^^^^^^ expected struct `String`, found integer
|
= note: expected reference `&String`
found reference `&{integer}`
For more information about this error, try `rustc --explain E0308`.
error: could not compile `jeu_du_plus_ou_du_moins` due to previous error
error[E0283]: type annotations needed for `{integer}`
--> src/main.rs:8:44
|
8 | let nombre_secret = rand::thread_rng().gen_range(1..101);
| ------------- ^^^^^^^^^ cannot infer type for type `{integer}`
| |
| consider giving `nombre_secret` a type
|
= note: multiple `impl`s satisfying `{integer}: SampleUniform` found in the `rand` crate:
- impl SampleUniform for i128;
- impl SampleUniform for i16;
- impl SampleUniform for i32;
- impl SampleUniform for i64;
and 8 more
note: required by a bound in `gen_range`
--> /Users/carolnichols/.cargo/registry/src/github.com-1ecc6299db9ec823/rand-0.8.3/src/rng.rs:129:12
|
129 | T: SampleUniform,
| ^^^^^^^^^^^^^ required by this bound in `gen_range`
help: consider specifying the type arguments in the function call
|
8 | let nombre_secret = rand::thread_rng().gen_range::<T, R>(1..101);
| ++++++++
Some errors have detailed explanations: E0283, E0308.
For more information about an error, try `rustc --explain E0283`.
error: could not compile `guessing_game` due to 2 previous errors
Le message d'erreur nous indique que nous sommes dans un cas de types non
compatibles (mismatched types). Rust a un système de types fort et statique.
Cependant, il a aussi une fonctionnalité d'inférence de type. Quand nous avons
écrit let mut supposition = String::new()
, Rust a pu en déduire que
supposition
devait être une String
et ne nous a pas demandé d'écrire le
type. D'autre part, nombre_secret
est d'un type de nombre. Quelques types de
nombres de Rust peuvent avoir une valeur entre 1 et 100 : i32
, un nombre
entier encodé sur 32 bits ; u32
, un nombre entier de 32 bits non signé
(positif ou nul) ; i64
, un nombre entier encodé sur 64 bits ; parmi tant
d'autres. Rust utilise par défaut un i32
, qui est le type de nombre_secret
,
à moins que vous précisiez quelque part une information de type qui amènerait
Rust à inférer un type de nombre différent. La raison de cette erreur est que
Rust ne peut pas comparer une chaîne de caractères à un nombre.
Au bout du compte, nous voulons convertir la String
que le programme récupère
de la saisie utilisateur en un nombre, pour qu'on puisse la comparer
numériquement au nombre secret. Nous allons faire ceci en ajoutant cette ligne
supplémentaire dans le corps de la fonction main
:
Fichier : src/main.rs
use rand::Rng;
use std::cmp::Ordering;
use std::io;
fn main() {
println!("Devinez le nombre !");
let nombre_secret = rand::thread_rng().gen_range(1..101);
println!("Le nombre secret est : {}", nombre_secret);
println!("Veuillez entrer un nombre.");
// -- partie masquée ici --
let mut supposition = String::new();
io::stdin()
.read_line(&mut supposition)
.expect("Échec de la lecture de l'entrée utilisateur");
let supposition: u32 = supposition.trim().parse().expect("Veuillez entrer un nombre !");
println!("Votre nombre : {}", supposition);
match supposition.cmp(&nombre_secret) {
Ordering::Less => println!("C'est plus !"),
Ordering::Greater => println!("C'est moins !"),
Ordering::Equal => println!("Vous avez gagné !"),
}
}
La nouvelle ligne est :
let supposition: u32 = supposition.trim().parse().expect("Veuillez entrer un nombre !");
Nous créons une variable qui s'appelle supposition
. Mais attendez, le
programme n'a-t-il pas déjà une variable qui s'appelle supposition
?
C'est le cas, mais heureusement Rust nous permet de masquer la valeur
précédente de supposition
avec une nouvelle.
Le masquage (shadowing) nous permet de réutiliser le nom de variable
supposition
, plutôt que de nous forcer à créer deux variables distinctes,
telles que supposition_str
et supposition
par exemple.
Nous verrons cela plus en détails au chapitre 3, mais pour le moment cette
fonctionnalité est souvent utilisée dans des situations où on veut convertir
une valeur d'un type à un autre.
Nous lions cette nouvelle variable à l'expression supposition.trim().parse()
.
Le supposition
dans l'expression se réfère à la variable supposition
initiale qui contenait la saisie utilisateur en tant que chaîne de caractères.
String
contenant la saisie utilisateur. La méthode trim
sur une instance
de String
va enlever les espaces et autres whitespaces au début et à la
fin, ce que nous devons faire pour comparer la chaîne au u32
, qui ne peut
être constitué que de chiffres. L'utilisateur doit appuyer sur
entrée pour mettre fin à read_line
et
récupérer leur supposition, ce qui va rajouter un caractère de fin de ligne à
la chaîne de caractères. Par exemple, si l'utilisateur écrit
5 et appuie sur entrée
, supposition
aura alors cette valeur : 5\n
.
Le \n
représente une fin de ligne (à noter que sur Windows, appuyer sur
entrée résulte en un retour chariot suivi d'une
fin de ligne, \r\n
). La méthode trim
enlève \n
et \r\n
, il ne reste donc
plus que 5
.
La méthode parse
des chaînes de caractères interprète
une chaîne de caractères en une sorte de nombre. Comme cette méthode peut
interpréter plusieurs types de nombres, nous devons indiquer à Rust le type
exact de nombre que nous voulons en utilisant let supposition: u32
.
Le deux-points (:
) après supposition
indique à Rust que nous voulons
préciser le type de la variable.
Rust embarque quelques types de nombres ; le u32
utilisé ici est un
entier non signé sur 32 bits.
C'est un bon choix par défaut pour un petit nombre positif.
Vous découvrirez d'autres types de nombres dans le chapitre 3.
De plus, l'annotation u32
dans ce programme d'exemple et la
comparaison avec nombre_secret
permet à Rust d'en déduire que nombre_secret
doit être lui aussi un u32
. Donc maintenant, la comparaison se fera
entre deux valeurs du même type !
La méthode parse
va fonctionner uniquement sur des caractères qui peuvent
être logiquement convertis en nombres et donc peut facilement mener à une
erreur. Si par exemple, le texte contient A👍%
, il ne sera pas possible de le
convertir en nombre. Comme elle peut échouer, la méthode parse
retourne un
type Result
, comme celui que la méthode read_line
retourne (comme nous
l'avons vu plus tôt dans “Gérer les erreurs potentielles avec le type
Result
”).
Nous allons gérer ce Result
de la même manière, avec à nouveau la méthode
expect
. Si parse
retourne une variante Err
de Result
car elle ne peut
pas créer un nombre à partir de la chaîne de caractères, l'appel à
expect
va faire planter le jeu et va afficher le message que nous lui avons
passé en paramètre. Si parse
arrive à convertir la chaîne de caractères en
nombre, alors elle retournera la variante Ok
de Result
, et expect
va
retourner le nombre qu'il nous faut qui est stocké dans la variante Ok
.
Exécutons ce programme, maintenant !
$ cargo run
Compiling jeu_du_plus_ou_du_moins v0.1.0 (file:///projects/jeu_du_plus_ou_du_moins)
Finished dev [unoptimized + debuginfo] target(s) in 0.43s
Running `target/debug/jeu_du_plus_ou_du_moins`
Devinez le nombre !
Le nombre secret est : 58
Veuillez entrer un nombre.
76
Votre nombre : 76
C'est moins !
Très bien ! Même si des espaces ont été ajoutées avant la supposition, le programme a quand même compris que l'utilisateur a saisi 76. Lancez le programme plusieurs fois pour vérifier qu'il se comporte correctement avec différentes saisies : devinez le nombre correctement, saisissez un nombre qui est trop grand, et saisissez un nombre qui est trop petit.
La majeure partie du jeu fonctionne désormais, mais l'utilisateur ne peut faire qu'une seule supposition. Corrigeons cela en ajoutant une boucle !
Permettre plusieurs suppositions avec les boucles
Le mot-clé loop
crée une boucle infinie. Nous allons ajouter une boucle pour
donner aux utilisateurs plus de chances de deviner le nombre :
Fichier : src/main.rs
use rand::Rng;
use std::cmp::Ordering;
use std::io;
fn main() {
println!("Devinez le nombre !");
let nombre_secret = rand::thread_rng().gen_range(1..101);
// -- partie masquée ici --
println!("Le nombre secret est : {}", nombre_secret);
loop {
println!("Veuillez entrer un nombre.");
// -- partie masquée ici --
let mut supposition = String::new();
io::stdin()
.read_line(&mut supposition)
.expect("Échec de la lecture de l'entrée utilisateur");
let supposition: u32 = supposition.trim().parse().expect("Veuillez entrer un nombre !");
println!("Votre nombre : {}", supposition);
match supposition.cmp(&nombre_secret) {
Ordering::Less => println!("C'est plus !"),
Ordering::Greater => println!("C'est moins !"),
Ordering::Equal => println!("Vous avez gagné !"),
}
}
}
Comme vous pouvez le remarquer, nous avons déplacé dans une boucle tout le code de l'invite à entrer le nombre. Assurez-vous d'indenter correctement les lignes dans la boucle avec quatre nouvelles espaces pour chacune, et lancez à nouveau le programme. Le programme va désormais demander un nombre à l'infini, ce qui est un nouveau problème. Il n'est pas possible pour l'utilisateur de l'arrêter !
L'utilisateur pourrait quand même interrompre le programme en utilisant le raccourci clavier ctrl-c. Mais il y a une autre façon d'échapper à ce monstre insatiable, comme nous l'avons abordé dans la partie “Comparer le nombre saisi au nombre secret” : si l'utilisateur saisit quelque chose qui n'est pas un nombre, le programme va planter. Nous pouvons procéder ainsi pour permettre à l'utilisateur de quitter, comme ci-dessous :
$ cargo run
Compiling jeu_du_plus_ou_du_moins v0.1.0 (file:///projects/jeu_du_plus_ou_du_moins)
Finished dev [unoptimized + debuginfo] target(s) in 1.50 secs
Running `target/debug/jeu_du_plus_ou_du_moins`
Devinez le nombre !
Le nombre secret est : 59
Veuillez entrer un nombre.
45
Votre nombre : 45
C'est plus !
Veuillez entrer un nombre.
60
Votre nombre : 60
C'est moins !
Veuillez entrer un nombre.
59
Votre nombre : 59
Vous avez gagné !
Veuillez entrer un nombre.
quitter
thread 'main' panicked at 'Veuillez entrer un nombre !: ParseIntError { kind: InvalidDigit }', src/main.rs:28:47
note: Run with `RUST_BACKTRACE=1` for a backtrace
Taper quitter
va bien fermer le jeu, mais comme vous pouvez le remarquer,
toute autre saisie qui n'est pas un nombre le ferait aussi. Ce mécanisme laisse
franchement à désirer ; nous voudrions que le jeu s'arrête aussi lorsque le bon
nombre est deviné.
Arrêter le programme après avoir gagné
Faisons en sorte que le jeu s'arrête quand le joueur gagne en ajoutant
l'instruction break
:
Fichier : src/main.rs
use rand::Rng;
use std::cmp::Ordering;
use std::io;
fn main() {
println!("Devinez le nombre !");
let nombre_secret = rand::thread_rng().gen_range(1..101);
println!("Le nombre secret est : {}", nombre_secret);
loop {
println!("Veuillez entrer un nombre.");
let mut supposition = String::new();
io::stdin()
.read_line(&mut supposition)
.expect("Échec de la lecture de l'entrée utilisateur");
let supposition: u32 = supposition.trim().parse().expect("Veuillez entrer un nombre !");
println!("Votre nombre : {}", supposition);
// -- partie masquée ici --
match supposition.cmp(&nombre_secret) {
Ordering::Less => println!("C'est plus !"),
Ordering::Greater => println!("C'est moins !"),
Ordering::Equal => {
println!("Vous avez gagné !");
break;
}
}
}
}
Ajouter la ligne break
après Vous avez gagné !
fait sortir le programme de
la boucle quand le joueur a correctement deviné le nombre secret. Et quitter la
boucle veut aussi dire terminer le programme, car ici la boucle est la dernière
partie de main
.
Gérer les saisies invalides
Pour améliorer le comportement du jeu, plutôt que de faire planter le programme
quand l'utilisateur saisit quelque chose qui n'est pas un nombre, faisons en
sorte que le jeu ignore ce qui n'est pas un nombre afin que l'utilisateur puisse
continuer à essayer de deviner. Nous pouvons faire ceci en modifiant la ligne où
supposition
est converti d'une String
en un u32
, comme dans l'encart 2-5 :
Fichier : src/main.rs
use rand::Rng;
use std::cmp::Ordering;
use std::io;
fn main() {
println!("Devinez le nombre !");
let nombre_secret = rand::thread_rng().gen_range(1..101);
println!("Le nombre secret est : {}", nombre_secret);
loop {
println!("Veuillez entrer un nombre.");
let mut supposition = String::new();
// -- partie masquée ici --
io::stdin()
.read_line(&mut supposition)
.expect("Échec de la lecture de l'entrée utilisateur");
let supposition: u32 = match supposition.trim().parse() {
Ok(nombre) => nombre,
Err(_) => continue,
};
println!("Votre nombre : {}", supposition);
// -- partie masquée ici --
match supposition.cmp(&nombre_secret) {
Ordering::Less => println!("C'est plus !"),
Ordering::Greater => println!("C'est moins !"),
Ordering::Equal => {
println!("Vous avez gagné !");
break;
}
}
}
}
Encart 2-5 : Ignorer une saisie qui n'est pas un nombre et demander un nouveau nombre plutôt que de faire planter le programme
Nous remplaçons un appel à expect
par une expression match
pour passer
d'une erreur qui fait planter le programme à une erreur proprement gérée.
N'oubliez pas que parse
retourne un type Result
et que Result
est une
énumération qui a pour variantes Ok
et Err
. Nous utilisons ici une
expression match
comme nous l'avons déjà fait avec le résultat de type
Ordering
de la méthode cmp
.
Si parse
arrive à convertir la chaîne de caractères en nombre, cela va
retourner la variante Ok
qui contient le nombre qui en résulte. Cette variante
va correspondre au motif de la première branche, et l'expression match
va
simplement retourner la valeur de nombre
que parse
a trouvée et qu'elle a
mise dans la variante Ok
.
Ce nombre va se retrouver là où nous en avons besoin,
dans la variable supposition
que nous sommes en train de créer.
Si parse
n'arrive pas à convertir la chaîne de caractères en nombre, elle
va retourner la variante Err
qui contient plus d'informations sur l'erreur. La
variante Err
ne correspond pas au motif Ok(nombre)
de la première branche,
mais elle correspond au motif Err(_)
de la seconde branche. Le tiret bas,
_
, est une valeur passe-partout ; dans notre exemple, nous disons
que nous voulons correspondre à toutes les valeurs de Err
, peu importe quelle
information elles ont à l'intérieur d'elles-mêmes. Donc le programme va exécuter
le code de la seconde branche, continue
, qui indique au programme de se rendre
à la prochaine itération de loop
et de demander un nouveau nombre. Ainsi, le
programme ignore toutes les erreurs que parse
pourrait rencontrer !
Maintenant, le programme devrait fonctionner correctement. Essayons-le :
$ cargo run
Compiling jeu_du_plus_ou_du_moins v0.1.0 (file:///projects/jeu_du_plus_ou_du_moins)
Finished dev [unoptimized + debuginfo] target(s) in 4.45s
Running `target/debug/jeu_du_plus_ou_du_moins`
Devinez le nombre !
Le nombre secret est : 61
Veuillez entrer un nombre.
10
Votre nombre : 10
C'est plus !
Veuillez entrer un nombre.
99
Votre nombre : 99
C'est moins !
Veuillez entrer un nombre.
foo
Veuillez entrer un nombre.
61
Votre nombre : 61
Vous avez gagné !
Super ! Avec notre petite touche finale, nous avons fini notre jeu du plus ou du
moins. Rappelez-vous que le programme affiche toujours le nombre secret. C'était
pratique pour les tests, mais cela gâche le jeu. Supprimons le println!
qui
affiche le nombre secret. L'encart 2-6 représente le code final.
Fichier : src/main.rs
use rand::Rng;
use std::cmp::Ordering;
use std::io;
fn main() {
println!("Devinez le nombre !");
let nombre_secret = rand::thread_rng().gen_range(1..101);
loop {
println!("Veuillez entrer un nombre.");
let mut supposition = String::new();
io::stdin()
.read_line(&mut supposition)
.expect("Échec de la lecture de l'entrée utilisateur");
let supposition: u32 = match supposition.trim().parse() {
Ok(nombre) => nombre,
Err(_) => continue,
};
println!("Votre nombre : {}", supposition);
match supposition.cmp(&nombre_secret) {
Ordering::Less => println!("C'est plus !"),
Ordering::Greater => println!("C'est moins !"),
Ordering::Equal => {
println!("Vous avez gagné !");
break;
}
}
}
}
Encart 2-6 : Code complet du jeu du plus ou du moins
Résumé
Si vous êtes arrivé jusqu'ici, c'est que vous avez construit avec succès le jeu du plus ou du moins. Félicitations !
Ce projet était une mise en pratique pour vous initier à de nombreux concepts de
Rust : let
, match
, les méthodes, les fonctions associées, l'utilisation de
crates externes, et bien plus. Dans les prochains chapitres, vous allez en
apprendre plus sur ces concepts. Le chapitre 3 va traiter des concepts utilisés
par la plupart des langages de programmation, comme les variables, les types de
données, et les fonctions, et vous montrera comment les utiliser avec Rust. Le
chapitre 4 expliquera la possession (ownership), qui est une fonctionnalité
qui distingue Rust des autres langages. Le chapitre 5 abordera les structures et
les syntaxes des méthodes, et le chapitre 6 expliquera comment les énumérations
fonctionnent.
Les concepts courants de programmation
Ce chapitre explique des concepts qui apparaissent dans presque tous les langages de programmation, et la manière dont ils fonctionnent en Rust. De nombreux langages sont basés sur des concepts communs. Les concepts présentés dans ce chapitre ne sont pas spécifiques à Rust, mais nous les appliquerons à Rust et nous expliquerons les conventions qui leur sont liées.
Plus précisément, vous allez apprendre les concepts de variables, les types de base, les fonctions, les commentaires, et les structures de contrôle. Ces notions fondamentales seront présentes dans tous les programmes Rust, et les apprendre dès le début vous procurera de solides bases pour débuter.
Mots-clés
Le langage Rust possède un ensemble de mots-clés qui ont été réservés pour l'usage exclusif du langage, tout comme le font d'autres langages. Gardez à l'esprit que vous ne pouvez pas utiliser ces mots pour des noms de variables ou de fonctions. La plupart des mots-clés ont une signification spéciale, et vous les utiliserez pour réaliser de différentes tâches dans vos programmes Rust ; quelques-uns n'ont aucune fonctionnalité active pour le moment, mais ont été réservés pour être ajoutés plus tard à Rust. Vous pouvez trouver la liste de ces mots-clés dans l'annexe A.
Les variables et la mutabilité
Tel qu'abordé au chapitre 2, par défaut, les variables sont immuables. C'est un des nombreux coups de pouce de Rust pour écrire votre code de façon à garantir la sécurité et la concurrence sans problème. Cependant, vous avez quand même la possibilité de rendre vos variables mutables (modifiables). Explorons comment et pourquoi Rust vous encourage à favoriser l'immuabilité, et pourquoi parfois vous pourriez choisir d'y renoncer.
Lorsqu'une variable est immuable, cela signifie qu'une fois qu'une valeur est
liée à un nom, vous ne pouvez pas changer cette valeur. À titre d'illustration,
générons un nouveau projet appelé variables dans votre dossier projects en
utilisant cargo new variables
.
Ensuite, dans votre nouveau dossier variables, ouvrez src/main.rs et remplacez son code par le code suivant. Ce code ne se compile pas pour le moment, nous allons commencer par étudier l'erreur d'immutabilité.
Fichier : src/main.rs
fn main() {
let x = 5;
println!("La valeur de x est : {}", x);
x = 6;
println!("La valeur de x est : {}", x);
}
Sauvegardez et lancez le programme en utilisant cargo run
. Vous devriez
avoir un message d'erreur comme celui-ci :
$ cargo run
Compiling variables v0.1.0 (file:///projects/variables)
error[E0384]: cannot assign twice to immutable variable `x`
--> src/main.rs:4:5
|
2 | let x = 5;
| -
| |
| first assignment to `x`
| help: consider making this binding mutable: `mut x`
3 | println!("La valeur de x est : {}", x);
4 | x = 6;
| ^^^^^ cannot assign twice to immutable variable
For more information about this error, try `rustc --explain E0384`.
error: could not compile `variables` due to previous error
Cet exemple montre comment le compilateur vous aide à trouver les erreurs dans vos programmes. Les erreurs de compilation peuvent s'avérer frustrantes, mais elles signifient en réalité que, pour le moment, votre programme n'est pas en train de faire ce que vous voulez qu'il fasse en toute sécurité ; elles ne signifient pas que vous êtes un mauvais développeur ! Même les Rustacés expérimentés continuent d'avoir des erreurs de compilation.
Ce message d'erreur indique que la cause du problème est qu'il est
impossible d'assigner à deux reprises la variable immuable `x`
(cannot assign twice to immutable variable `x`
).
Il est important que nous obtenions des erreurs au moment de la compilation lorsque nous essayons de changer une valeur qui a été déclarée comme immuable, car cette situation particulière peut donner lieu à des bogues. Si une partie de notre code part du principe qu'une valeur ne changera jamais et qu'une autre partie de notre code modifie cette valeur, il est possible que la première partie du code ne fasse pas ce pour quoi elle a été conçue. La cause de ce genre de bogue peut être difficile à localiser après coup, en particulier lorsque la seconde partie du code ne modifie que parfois cette valeur. Le compilateur Rust garantit que lorsque vous déclarez qu'une valeur ne change pas, il ne va jamais changer, donc vous n'avez pas à en vous soucier. Votre code est ainsi plus facile à maîtriser.
Mais la mutabilité peut s'avérer très utile, et peut faciliter la rédaction du
code. Les variables sont immuables par défaut ; mais comme vous l'avez fait au
chapitre 2, vous pouvez les rendre mutables en ajoutant mut
devant le nom de
la variable. L'ajout de mut
va aussi signaler l'intention aux futurs lecteurs
de ce code que d'autres parties du code vont modifier la valeur de cette
variable.
Par exemple, modifions src/main.rs ainsi :
Fichier : src/main.rs
fn main() { let mut x = 5; println!("La valeur de x est : {}", x); x = 6; println!("La valeur de x est : {}", x); }
Lorsque nous exécutons le programme, nous obtenons :
$ cargo run
Compiling variables v0.1.0 (file:///projects/variables)
Finished dev [unoptimized + debuginfo] target(s) in 0.30s
Running `target/debug/variables`
La valeur de x est : 5
La valeur de x est : 6
En utilisant mut
, nous avons permis à la valeur liée à x
de passer de 5
à
6
. Il y a d'autres compromis à envisager, en plus de la prévention des
bogues. Par exemple, dans le cas où vous utiliseriez des grosses structures de
données, muter une instance déjà existante peut être plus rapide que copier et
retourner une instance nouvellement allouée. Avec des structures de données
plus petites, créer de nouvelles instances avec un style de programmation
fonctionnelle peut rendre le code plus facile à comprendre, donc il peut valoir
le coup de sacrifier un peu de performance pour que le code gagne en clarté.
Les constantes
Comme les variables immuables, les constantes sont des valeurs qui sont liées à un nom et qui ne peuvent être modifiées, mais il y a quelques différences entre les constantes et les variables.
D'abord, vous ne pouvez pas utiliser mut
avec les constantes. Les constantes
ne sont pas seulement immuables par défaut − elles sont toujours immuables. On
déclare les constantes en utilisant le mot-clé const
à la place du mot-clé
let
, et le type de la valeur doit être indiqué. Nous allons aborder les
types et les annotations de types dans la prochaine section, “Les types de
données”, donc ne vous souciez pas des détails pour
le moment. Sachez seulement que vous devez toujours indiquer le type.
Les constantes peuvent être déclarées à n'importe quel endroit du code, y compris la portée globale, ce qui les rend très utiles pour des valeurs que de nombreuses parties de votre code ont besoin de connaître.
La dernière différence est que les constantes ne peuvent être définies que par une expression constante, et non pas le résultat d'une valeur qui ne pourrait être calculée qu'à l'exécution.
Voici un exemple d'une déclaration de constante :
#![allow(unused)] fn main() { const TROIS_HEURES_EN_SECONDES: u32 = 60 * 60 * 3; }
Le nom de la constante est TROIS_HEURES_EN_SECONDES
et sa valeur est définie
comme étant le résultat de la multiplication de 60 (le nombre de secondes dans
une minute) par 60 (le nombre de minutes dans une heure) par 3 (le nombre
d'heures que nous voulons calculer dans ce programme).
En Rust, la convention de nommage des constantes est de les écrire tout en
majuscule avec des tirets bas entre les mots. Le compilateur peut calculer un
certain nombre d'opérationss à la compilation, ce qui nous permet d'écrire
cette valeur de façon à la comprendre plus facilement et à la vérifier, plutôt
que de définir cette valeur à 10 800. Vous pouvez consulter la section de la
référence Rust à propos des évaluations des constantes pour en
savoir plus sur les opérations qui peuvent être utilisées pour déclarer des
constantes.
Les constantes sont valables pendant toute la durée d'exécution du programme au sein de la portée dans laquelle elles sont déclarées. Cette caractéristique rends les constantes très utiles lorsque plusieurs parties du programme doivent connaître certaines valeurs, comme par exemple le nombre maximum de points qu'un joueur est autorisé à gagner ou encore la vitesse de la lumière.
Déclarer des valeurs codées en dur et utilisées tout le long de votre programme en tant que constantes est utile pour faire comprendre la signification de ces valeurs dans votre code aux futurs développeurs. Cela permet également de n'avoir qu'un seul endroit de votre code à modifier si cette valeur codée en dur doit être mise à jour à l'avenir.
Le masquage
Comme nous l'avons vu dans le Chapitre
2, on peut déclarer
une nouvelle variable avec le même nom qu'une variable précédente. Les Rustacés
disent que la première variable est masquée par la seconde, ce qui signifie
que la valeur de la seconde variable sera ce que le programme verra lorsque
nous utiliserons cette variable. Nous pouvons créer un masque d'une variable en
utilisant le même nom de variable et en réutilisant le mot-clé let
comme
ci-dessous :
Fichier : src/main.rs
fn main() { let x = 5; let x = x + 1; { let x = x * 2; println!("La valeur de x dans la portée interne est : {}", x); } println!("La valeur de x est : {}", x); }
Au début, ce programme lie x
à la valeur 5
. Puis il crée un masque de x
en répétant let x =
, en récupérant la valeur d'origine et lui ajoutant 1
:
la valeur de x
est désormais 6
. Ensuite, à l'intérieur de la portée interne,
la troisième instruction let
crée un autre masque de x
, en récupérant la
précédente valeur et en la multipliant par 2
pour donner à x
la valeur
finale de 12
. Dès que nous sortons de cette portée, le masque prends fin, et
x
revient à la valeur 6
. Lorsque nous exécutons ce programme, nous obtenons
ceci :
$ cargo run
Compiling variables v0.1.0 (file:///projects/variables)
Finished dev [unoptimized + debuginfo] target(s) in 0.31s
Running `target/debug/variables`
La valeur de x dans la portée interne est : 12
La valeur de x est : 6
Créer un masque est différent que de marquer une variable comme étant mut
,
car à moins d'utiliser une nouvelle fois le mot-clé let
, nous obtiendrons une
erreur de compilation si nous essayons de réassigner cette variable par
accident. Nous pouvons effectuer quelques transformations sur une valeur en
utilisant let
, mais faire en sorte que la variable soit immuable après que ces
transformations ont été appliquées.
Comme nous créons une nouvelle variable lorsque nous utilisons le mot-clé let
une nouvelle fois, l'autre différence entre le mut
et la création d'un masque
est que cela nous permet de changer le type de la valeur, mais en réutilisant
le même nom. Par exemple, imaginons un programme qui demande à l'utilisateur
le nombre d'espaces qu'il souhaite entre deux portions de texte en saisissant
des espaces, et ensuite nous voulons stocker cette saisie sous forme de
nombre :
fn main() { let espaces = " "; let espaces = espaces.len(); }
La première variable espaces
est du type chaîne de caractères (string) et
la seconde variable espaces
est du type nombre. L'utilisation du masquage
nous évite ainsi d'avoir à trouver des noms différents, comme espaces_str
et
espaces_num
; nous pouvons plutôt simplement réutiliser le nom espaces
.
Cependant, si nous essayons d'utiliser mut
pour faire ceci, comme ci-dessous,
nous avons une erreur de compilation :
fn main() {
let mut espaces = " ";
espaces = espaces.len();
}
L'erreur indique que nous ne pouvons pas muter le type d'une variable :
$ cargo run
Compiling variables v0.1.0 (file:///projects/variables)
error[E0308]: mismatched types
--> src/main.rs:3:14
|
2 | let mut espaces = " ";
| ----- expected due to this value
3 | espaces = espaces.len();
| ^^^^^^^^^^^^^ expected `&str`, found `usize`
For more information about this error, try `rustc --explain E0308`.
error: could not compile `variables` due to previous error
Maintenant que nous avons découvert comment fonctionnent les variables, étudions les types de données qu'elles peuvent prendre.
Les types de données
Chaque valeur en Rust est d'un type bien déterminé, qui indique à Rust quel genre de données il manipule pour qu'il sache comment traiter ces données. Nous allons nous intéresser à deux catégories de types de données : les scalaires et les composés.
Gardez à l'esprit que Rust est un langage statiquement typé, ce qui signifie
qu'il doit connaître les types de toutes les variables au moment de la
compilation. Le compilateur peut souvent déduire quel type utiliser en se basant
sur la valeur et sur la façon dont elle est utilisée. Dans les cas où plusieurs
types sont envisageables, comme lorsque nous avons converti une chaîne de
caractères en un type numérique en utilisant parse
dans la section
“Comparer le nombre saisi au nombre
secret”
du chapitre 2, nous devons ajouter une annotation de type, comme ceci :
#![allow(unused)] fn main() { let supposition: u32 = "42".parse().expect("Ce n'est pas un nombre !"); }
Si nous n'ajoutons pas l'annotation de type ici, Rust affichera l'erreur suivante, signifiant que le compilateur a besoin de plus d'informations pour déterminer quel type nous souhaitons utiliser :
$ cargo build
Compiling no_type_annotations v0.1.0 (file:///projects/no_type_annotations)
error[E0282]: type annotations needed
--> src/main.rs:2:9
|
2 | let supposition = "42".parse().expect("Ce n'est pas un nombre !");
| ^^^^^^^^^^^ consider giving `supposition` a type
For more information about this error, try `rustc --explain E0282`.
error: could not compile `no_type_annotations` due to previous error
Vous découvrirez différentes annotations de type au fur et à mesure que nous aborderons les autres types de données.
Types scalaires
Un type scalaire représente une seule valeur. Rust possède quatre types principaux de scalaires : les entiers, les nombres à virgule flottante, les booléens et les caractères. Vous les connaissez sûrement d'autres langages de programmation. Regardons comment ils fonctionnent avec Rust.
Types de nombres entiers
Un entier est un nombre sans partie décimale. Nous avons utilisé un entier
précédemment dans le chapitre 2, le type u32
. Cette déclaration de type
indique que la valeur à laquelle elle est associée doit être un entier non signé
encodé sur 32 bits dans la mémoire (les entiers pouvant prendre des valeurs
négatives commencent par un i
(comme integer : “entier”), plutôt que par un
u
comme unsigned : “non signé”). Le tableau 3-1 montre les types
d'entiers intégrés au langage. Nous pouvons utiliser chacune de ces variantes
pour déclarer le type d'une valeur entière.
Tableau 3-1 : les types d'entiers en Rust
Taille | Signé | Non signé |
---|---|---|
8 bits | i8 | u8 |
16 bits | i16 | u16 |
32 bits | i32 | u32 |
64 bits | i64 | u64 |
128 bits | i128 | u128 |
archi | isize | usize |
Chaque variante peut être signée ou non signée et possède une taille explicite. Signé et non signé veut dire respectivement que le nombre peut prendre ou non des valeurs négatives — en d'autres termes, si l'on peut lui attribuer un signe (signé) ou s'il sera toujours positif et que l'on peut donc le représenter sans signe (non signé). C'est comme écrire des nombres sur du papier : quand le signe est important, le nombre est écrit avec un signe plus ou un signe moins ; en revanche, quand le nombre est forcément positif, on peut l'écrire sans son signe. Les nombres signés sont stockés en utilisant le complément à deux.
Chaque variante signée peut stocker des nombres allant de −(2n − 1)
à 2n − 1 − 1 inclus, où n est le nombre de bits que cette
variante utilise.
Un i8
peut donc stocker des nombres allant de −(27) à
27 − 1, c'est-à-dire de −128 à 127. Les variantes non signées peuvent
stocker des nombres de 0 à 2n − 1, donc un u8
peut stocker
des nombres allant de 0 à 28 − 1, c'est-à-dire de 0 à 255.
De plus, les types isize
et usize
dépendent de l'architecture de
l'ordinateur sur lequel votre programme va s'exécuter, d'où la ligne “archi” :
64 bits si vous utilisez une architecture 64 bits, ou 32 bits si vous utilisez
une architecture 32 bits.
Vous pouvez écrire des littéraux d'entiers dans chacune des formes décrites dans
le tableau 3-2. Notez qu'un littéral numérique peut être de différent type
numérique autorisent l'utilisation un suffixe de type, comme 57u8
, afin de
préciser leur type. Les nombres littéraux peuvent aussi utiliser _
comme
séparateur visuel afin de les rendre plus lisible, comme par exemple 1_000
,
qui a la même valeur que si vous aviez renseigné 1000
.
Tableau 3-2 : les littéraux d'entiers en Rust
Littéral numérique | Exemple |
---|---|
Décimal | 98_222 |
Hexadécimal | 0xff |
Octal | 0o77 |
Binaire | 0b1111_0000 |
Octet (u8 seulement) | b'A' |
Comment pouvez-vous déterminer le type d'entier à utiliser ? Si vous n'êtes pas
sûr, les choix par défaut de Rust sont généralement de bons choix : le type
d'entier par défaut est le i32
. La principale utilisation d'un isize
ou d'un
usize
est lorsque l'on indexe une quelconque collection.
Dépassement d'entier
Imaginons que vous avez une variable de type
u8
qui peut stocker des valeurs entre 0 et 255. Si vous essayez de changer la variable pour une valeur en dehors de cet intervalle, comme 256, vous aurez un dépassement d'entier (integer overflow), qui peut se compter de deux manière. Lorsque vous compilez en mode débogage, Rust embarque des vérifications pour détecter les cas de dépassements d'entiers qui pourraient faire paniquer votre programme à l'exécution si ce phénomène se produit. Rust utilise le terme paniquer quand un programme se termine avec une erreur ; nous verrons plus en détail les paniques dans une section du chapitre 9.Lorsque vous compilez en mode publication (release) avec le drapeau
--release
, Rust ne va pas vérifier les potentiels dépassements d'entiers qui peuvent faire paniquer le programme. En revanche, en cas de dépassement, Rust va effectuer un rebouclage du complément à deux. Pour faire simple, les valeurs supérieures à la valeur maximale du type seront “rebouclées” depuis la valeur minimale que le type peut stocker. Dans cas d'unu8
, la valeur 256 devient 0, la valeur 257 devient 1, et ainsi de suite. Le programme ne va paniquer, mais la variable va avoir une valeur qui n'est probablement pas ce que vous attendez à avoir. Se fier au comportement du rebouclage lors du dépassement d'entier est considéré comme une faute.Pour gérer explicitement le dépassement, vous pouvez utiliser les familles de méthodes suivantes qu'offrent la bibliothèque standard sur les types de nombres primitifs :
- Enveloppez les opérations avec les méthodes
wrapping_*
, comme par exemplewrapping_add
- Retourner la valeur
None
s'il y a un dépassement avec des méthodeschecked_*
- Retourner la valeur et un booléen qui indique s'il y a eu un dépassement avec des méthodes
overflowing_*
- Saturer à la valeur minimale ou maximale avec des méthodes
saturating_*
Types de nombres à virgule flottante
Rust possède également deux types primitifs pour les nombres à virgule
flottante (ou flottants), qui sont des nombres avec des décimales. Les types
de flottants en Rust sont les f32
et les f64
, qui ont respectivement une
taille en mémoire de 32 bits et 64 bits. Le type par défaut est le f64
car sur
les processeurs récents ce type est quasiment aussi rapide qu'un f32
mais est
plus précis. Tous les flottants ont un signe.
Voici un exemple montrant l'utilisation de nombres à virgule flottante :
Ficher : src/main.rs
fn main() { let x = 2.0; // f64 let y: f32 = 3.0; // f32 }
Les nombres à virgule flottante sont représentés selon la norme IEEE-754. Le
type f32
est un flottant à simple précision, et le f64
est à double
précision.
Les opérations numériques
Rust offre les opérations mathématiques de base dont vous auriez besoin pour
tous les types de nombres : addition, soustraction, multiplication, division et
modulo. Les divisions d'entiers arrondissent le résultat à l'entier le plus
près. Le code suivant montre comment utiliser chacune des opérations numériques
avec une instruction let
:
Fichier : src/main.rs
fn main() { // addition let somme = 5 + 10; // soustraction let difference = 95.5 - 4.3; // multiplication let produit = 4 * 30; // division let quotient = 56.7 / 32.2; let arrondi = 2 / 3; // retournera 0 // modulo let reste = 43 % 5; }
Chaque expression de ces instructions utilise un opérateur mathématique et calcule une valeur unique, qui est ensuite attribuée à une variable. L'annexe B présente une liste de tous les opérateurs que Rust fournit.
Le type booléen
Comme dans la plupart des langages de programmation, un type booléen a deux
valeurs possibles en Rust : true
(vrai) et false
(faux). Les booléens
prennent un octet en mémoire. Le type booléen est désigné en utilisant bool
.
Par exemple :
Fichier : src/main.rs
fn main() { let t = true; let f: bool = false; // avec une annotation de type explicite }
Les valeurs booléennes sont principalement utilisées par les structures
conditionnelles, comme l'expression if
. Nous aborderons le fonctionnement
de if
en Rust dans la section
“Les structures de contrôle”.
Le type caractère
Le type char
(comme character) est le type de caractère le plus
rudimentaire. Voici quelques exemples de déclaration de valeurs de type
char
:
Fichier : src/main.rs
fn main() { let c = 'z'; let z = 'ℤ'; let chat_aux_yeux_de_coeur = '😻'; }
Notez que nous renseignons un litéral char
avec des guillemets simples,
contrairement aux littéraux de chaîne de caractères, qui nécéssite des doubles
guillemets. Le type char
de Rust prend quatre octets en mémoire et représente
une valeur scalaire Unicode, ce qui veut dire que cela représente plus de
caractères que l'ASCII. Les lettres accentuées ; les caractères chinois,
japonais et coréens ; les emoji ; les espaces de largeur nulle ont tous une
valeur pour char
avec Rust. Les valeurs scalaires Unicode vont de U+0000
à
U+D7FF
et de U+E000
à U+10FFFF
inclus. Cependant, le concept de
“caractère” n'est pas clairement défini par Unicode, donc votre notion de
“caractère” peut ne pas correspondre à ce qu'est un char
en Rust. Nous
aborderons ce sujet plus en détail au chapitre 8.
Les types composés
Les types composés peuvent regrouper plusieurs valeurs dans un seul type. Rust a deux types composés de base : les tuples et les tableaux (arrays).
Le type tuple
Un tuple est une manière générale de regrouper plusieurs valeurs de types différents en un seul type composé. Les tuples ont une taille fixée : à partir du moment où ils ont été déclarés, on ne peut pas y ajouter ou enlever des valeurs.
Nous créons un tuple en écrivant une liste séparée par des virgules entre des parenthèses. Chaque emplacement dans le tuple a un type, et les types de chacune des valeurs dans le tuple n'ont pas forcément besoin d'être les mêmes. Nous avons ajouté des annotations de type dans cet exemple, mais c'est optionnel :
Fichier : src/main.rs
fn main() { let tup: (i32, f64, u8) = (500, 6.4, 1); }
La variable tup
est liée à tout le tuple, car un tuple est considéré
comme étant un unique élément composé. Pour obtenir un élément précis de ce
tuple, nous pouvons utiliser un filtrage par motif (pattern matching) pour
déstructurer ce tuple, comme ceci :
Fichier : src/main.rs
fn main() { let tup = (500, 6.4, 1); let (x, y, z) = tup; println!("La valeur de y est : {}", y); }
Le programme commence par créer un tuple et il l'assigne à la variable tup
.
Il utilise ensuite un motif avec let
pour prendre tup
et le scinder en
trois variables distinctes : x
, y
, et z
.
On appelle cela déstructurer, car il divise le tuple en trois parties.
Puis finalement, le programme affiche la valeur de y
, qui est 6.4
.
Nous pouvons aussi accéder directement à chaque élément du tuple en utilisant
un point (.
) suivi de l'indice de la valeur que nous souhaitons obtenir. Par
exemple :
Fichier : src/main.rs
fn main() { let x: (i32, f64, u8) = (500, 6.4, 1); let cinq_cents = x.0; let six_virgule_quatre = x.1; let un = x.2; }
Ce programme crée le tuple x
puis crée une nouvelle variable pour
chaque élément en utilisant leur indices respectifs. Comme dans de nombreux
langages de programmation, le premier indice d'un tuple est 0.
Le tuple sans aucune valeur, ()
, est un type spécial qui a une seule et unique
valeur, qui s'écrit aussi ()
. Ce type est aussi appelé le type unité et la
valeur est appelée valeur unité. Les expressions retournent implicitement la
valeur unité si elles ne retournent aucune autre valeur.
Le type tableau
Un autre moyen d'avoir une collection de plusieurs valeurs est d'utiliser un tableau. Contrairement aux tuples, chaque élément d'un tableau doit être du même type. Contrairement aux tableaux de certains autres langages, les tableaux de Rust ont une taille fixe.
Nous écrivons les valeurs dans un tableau via une liste entre des crochets, séparée par des virgules :
Fichier : src/main.rs
fn main() { let a = [1, 2, 3, 4, 5]; }
Les tableaux sont utiles quand vous voulez que vos données soient allouées sur la pile (stack) plutôt que sur le tas (heap) (nous expliquerons la pile et le tas au chapitre 4) ou lorsque vous voulez vous assurer que vous avez toujours un nombre fixe d'éléments. Cependant, un tableau n'est pas aussi flexible qu'un vecteur (vector). Un vecteur est un type de collection de données similaire qui est fourni par la bibliothèque standard qui, lui, peut grandir ou rétrécir en taille. Si vous ne savez pas si vous devez utiliser un tableau ou un vecteur, il y a de fortes chances que vous devriez utiliser un vecteur. Le chapitre 8 expliquera les vecteurs.
Toutefois, les tableaux s'avèrent plus utiles lorsque vous savez que le nombre d'éléments n'aura pas besoin de changer. Par exemple, si vous utilisez les noms des mois dans un programme, vous devriez probablement utiliser un tableau plutôt qu'un vecteur car vous savez qu'il contient toujours 12 éléments :
#![allow(unused)] fn main() { let mois = ["Janvier", "Février", "Mars", "Avril", "Mai", "Juin", "Juillet", "Août", "Septembre", "Octobre", "Novembre", "Décembre"]; }
Vous pouvez écrire le type d'un tableau en utilisant des crochets et entre ces crochets y ajouter le type de chaque élément, un point-virgule, et ensuite le nombre d'éléments dans le tableau, comme ceci :
#![allow(unused)] fn main() { let a: [i32; 5] = [1, 2, 3, 4, 5]; }
Ici, i32
est le type de chaque élément. Après le point-virgule, le nombre 5
indique que le tableau contient cinq éléments.
Vous pouvez initialiser un tableau pour qu'il contienne toujours la même valeur pour chaque élément, vous pouvez préciser la valeur initiale, suivie par un point-virgule, et ensuite la taille du tableau, le tout entre crochets, comme ci-dessous :
#![allow(unused)] fn main() { let a = [3; 5]; }
Le tableau a
va contenir 5
éléments qui auront tous la valeur
initiale 3
. C'est la même chose que d'écrire let a = [3, 3, 3, 3, 3];
mais
de manière plus concise.
Accéder aux éléments d'un tableau
Un tableau est un simple bloc de mémoire de taille connue et fixe, qui peut être alloué sur la pile. Vous pouvez accéder aux éléments d'un tableau en utilisant l'indexation, comme ceci :
Fichier : src/main.rs
fn main() { let a = [1, 2, 3, 4, 5]; let premier = a[0]; let second = a[1]; }
Dans cet exemple, la variable qui s'appelle premier
aura la valeur 1
, car
c'est la valeur à l'indice [0]
dans le tableau. La variable second
récupèrera la valeur 2
depuis l'indice [1]
du tableau.
Accès incorrect à un élément d'un tableau
Découvrons ce qui se passe quand vous essayez d'accéder à un élément d'un tableau qui se trouve après la fin du tableau ? Imaginons que vous exécutiez le code suivant, similaire au jeu du plus ou du moins du chapitre 2, pour demander un indice de tableau à l'utilisateur :
Fichier : src/main.rs
use std::io;
fn main() {
let a = [1, 2, 3, 4, 5];
println!("Veuillez entrer un indice de tableau.");
let mut indice = String::new();
io::stdin()
.read_line(&mut indice)
.expect("Échec de la lecture de l'entrée utilisateur");
let indice: usize = indice
.trim()
.parse()
.expect("L'indice entré n'est pas un nombre");
let element = a[indice];
println!(
"La valeur de l'élément d'indice {} est : {}",
indice, element
);
}
Ce code compile avec succès. Si vous exécutez ce code avec cargo run
et que
vous entrez 0, 1, 2, 3 ou 4, le programme affichera la valeur correspondante à
cet indice dans le tableau. Si au contraire, vous entrez un indice après la fin
du tableau tel que 10, ceci s'affichera :
thread 'main' panicked at 'index out of bounds: the len is 5 but the index is 10', src/main.rs:19:19
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
Le programme a rencontré une erreur à l'exécution, au moment d'utiliser une
valeur invalide comme indice. Le programme s'est arrêté avec un message d'erreur
et n'a pas exécuté la dernière instruction println!
. Quand vous essayez
d'accéder à un élément en utilisant l'indexation, Rust va vérifier que l'indice
que vous avez demandé est plus petit que la taille du tableau. Si l'indice est
supérieur ou égal à la taille du tableau, Rust va paniquer. Cette vérification
doit avoir lieu à l'exécution, surtout dans ce cas, parce que le compilateur ne
peut pas deviner la valeur qu'entrera l'utilisateur quand il exécutera le code
plus tard.
C'est un exemple de mise en pratique des principes de sécurité de la mémoire par Rust. Dans de nombreux langages de bas niveau, ce genre de vérification n'est pas effectuée, et quand vous utilisez un indice incorrect, de la mémoire invalide peut être récupérée. Rust vous protège de ce genre d'erreur en quittant immédiatement l'exécution au lieu de permettre l'accès en mémoire et continuer son déroulement. Le chapitre 9 expliquera la gestion d'erreurs de Rust.
Les fonctions
Les fonctions sont très utilisées dans le code Rust. Vous avez déjà vu l'une des
fonctions les plus importantes du langage : la fonction main
, qui est le point
d'entrée de beaucoup de programmes. Vous avez aussi vu le mot-clé fn
, qui vous
permet de déclarer des nouvelles fonctions.
Le code Rust utilise le snake case comme convention de style de nom des fonctions et des variables, toutes les lettres sont en minuscule et on utilise des tirets bas pour séparer les mots. Voici un programme qui est un exemple de définition de fonction :
Fichier : src/main.rs
fn main() { println!("Hello, world!"); une_autre_fonction(); } fn une_autre_fonction() { println!("Une autre fonction."); }
Nous définissons une fonction avec Rust en saisissant fn
suivi par un nom de
fonction ainsi qu'une paire de parenthèses. Les accolades indiquent au
compilateur où le corps de la fonction commence et où il se termine.
Nous pouvons appeler n'importe quelle fonction que nous avons définie en
utilisant son nom, suivi d'une paire de parenthèses. Comme une_autre_fonction
est définie dans le programme, elle peut être appelée à l'intérieur de la
fonction main
. Remarquez que nous avons défini une_autre_fonction
après
la fonction main
dans le code source ; nous aurions aussi pu la définir avant.
Rust ne se soucie pas de l'endroit où vous définissez vos fonctions, du moment
qu'elles sont bien définies quelque part.
Créons un nouveau projet de binaire qui s'appellera functions afin d'en
apprendre plus sur les fonctions. Ajoutez l'exemple une_autre_fonction
dans le
src/main.rs et exécutez-le. Vous devriez avoir ceci :
$ cargo run
Compiling functions v0.1.0 (file:///projects/functions)
Finished dev [unoptimized + debuginfo] target(s) in 0.28s
Running `target/debug/functions`
Hello, world!
Une autre fonction.
Les lignes s'exécutent dans l'ordre dans lequel elles apparaissent dans la
fonction main
. D'abord, le message Hello, world!
est écrit, et ensuite
une_autre_fonction
est appelée et son message est affiché.
Les paramètres
Nous pouvons définir des fonctions avec des paramètres, qui sont des variables spéciales qui font partie de la signature de la fonction. Quand une fonction a des paramètres, vous pouvez lui fournir des valeurs concrètes avec ces paramètres. Techniquement, ces valeurs concrètes sont appelées des arguments, mais dans une conversation courante, on a tendance à confondre les termes paramètres et arguments pour désigner soit les variables dans la définition d'une fonction, soit les valeurs concrètes passées quand on appelle une fonction.
Dans cette version de une_autre_fonction
, nous ajoutons un paramètre :
Fichier : src/main.rs
fn main() { une_autre_fonction(5); } fn une_autre_fonction(x: i32) { println!("La valeur de x est : {}", x); }
En exécutant ce programme, vous devriez obtenir ceci :
$ cargo run
Compiling functions v0.1.0 (file:///projects/functions)
Finished dev [unoptimized + debuginfo] target(s) in 1.21s
Running `target/debug/functions`
La valeur de x est : 5
La déclaration de une_autre_fonction
a un paramètre nommé x
. Le type de
x
a été déclaré comme i32
. Quand nous passons 5
à une_autre_fonction
, la
macro println!
place 5
là où la paire d'accolades {}
a été placée dans la
chaîne de formatage.
Dans la signature d'une fonction, vous devez déclarer le type de chaque paramètre. C'est un choix délibéré de conception de Rust : exiger l'annotation de type dans la définition d'une fonction fait en sorte que le compilateur n'a presque plus besoin que vous les utilisiez autre part pour qu'il comprenne avec quel type vous souhaitez travailler.
Lorsque vous définissez plusieurs paramètres, séparez les paramètres avec des virgules, comme ceci :
Fichier : src/main.rs
fn main() { afficher_mesure_avec_unite(5, 'h'); } fn afficher_mesure_avec_unite(valeur: i32, unite: char) { println!("La mesure est : {}{}", valeur, unite); }
Cet exemple crée la fonction afficher_mesure_avec_unite
qui a deux paramètres.
Le premier paramètre s'appelle valeur
et est un i32
. Le second, nom_unite
,
est de type char
. La fonction affiche ensuite le texte qui contient les
valeurs de valeur
et de nom_unite
.
Essayons d'exécuter ce code. Remplacez le programme présent actuellement dans
votre fichier src/main.rs de votre projet functions par l'exemple précédent
et lancez-le en utilisant cargo run
:
$ cargo run
Compiling functions v0.1.0 (file:///projects/functions)
Finished dev [unoptimized + debuginfo] target(s) in 0.31s
Running `target/debug/functions`
La mesure est : 5h
Comme nous avons appelé la fonction avec la valeur 5
pour valeur
et 'h'
pour nom_unite
, la sortie de ce programme contient ces valeurs.
Instructions et expressions
Les corps de fonctions sont constitués d'une série d'instructions qui se termine éventuellement par une expression. Jusqu'à présent, les fonctions que nous avons vu n'avaient pas d'expression à la fin, mais vous avez déjà vu une expression faire partie d'une instruction. Comme Rust est un langage basé sur des expressions, il est important de faire la distinction. D'autres langages ne font pas de telles distinctions, donc penchons-nous sur ce que sont les instructions et les expressions et comment leurs différences influent sur le corps des fonctions.
Les instructions effectuent des actions et ne retournent aucune valeur. Les expressions sont évaluées pour retourner une valeur comme résultat. Voyons quelques exemples.
Nous avons déjà utilisé des instructions et des expressions. La création d'une
variable en lui assignant une valeur avec le mot-clé let
est une instruction.
Dans l'encart 3-1, let y = 6;
est une instruction.
Fichier : src/main.rs
fn main() { let y = 6; }
Encart 3-1 : une fonction main
qui contient une
instruction
La définition d'une fonction est aussi une instruction ; l'intégralité de l'exemple précédent est une instruction à elle toute seule.
Une instruction ne retourne pas de valeur. Ainsi, vous ne pouvez pas assigner
le résultat d'une instruction let
à une autre variable, comme le code suivant
essaye de le faire, car vous obtiendrez une erreur :
Fichier : src/main.rs
fn main() {
let x = (let y = 6);
}
Quand vous exécutez ce programme, l'erreur que vous obtenez devrait ressembler à ceci :
$ cargo run
Compiling functions v0.1.0 (file:///projects/functions)
error: expected expression, found statement (`let`)
--> src/main.rs:2:14
|
2 | let x = (let y = 6);
| ^^^^^^^^^
|
= note: variable declaration using `let` is a statement
error[E0658]: `let` expressions in this position are experimental
--> src/main.rs:2:14
|
2 | let x = (let y = 6);
| ^^^^^^^^^
|
= note: see issue #53667 <https://github.com/rust-lang/rust/issues/53667> for more information
= help: you can write `matches!(<expr>, <pattern>)` instead of `let <pattern> = <expr>`
warning: unnecessary parentheses around assigned value
--> src/main.rs:2:13
|
2 | let x = (let y = 6);
| ^ ^
|
= note: `#[warn(unused_parens)]` on by default
help: remove these parentheses
|
2 - let x = (let y = 6);
2 + let x = let y = 6;
|
For more information about this error, try `rustc --explain E0658`.
warning: `functions` (bin "functions") generated 1 warning
error: could not compile `functions` due to 2 previous errors; 1 warning emitted
L'instruction let y = 6
ne retourne pas de valeur, donc cela ne peut pas
devenir une valeur de x
. Ceci est différent d'autres langages, comme le C ou
Ruby, où l'assignation retourne la valeur de l'assignation. Dans ces
langages, vous pouvez écrire x = y = 6
et avoir ainsi x
et y
qui ont
chacun la valeur 6
; cela n'est pas possible avec Rust.
Les expressions sont calculées en tant que valeur et seront ce que vous écrirez
le plus en Rust (hormis les instructions). Prenez une opération mathématique,
comme 5 + 6
, qui est une expression qui s'évalue à la valeur 11
. Les
expressions peuvent faire partie d'une instruction : dans l'encart 3-1, le 6
dans l'instruction let y = 6;
est une expression qui s'évalue à la valeur 6
.
L'appel de fonction est aussi une expression. L'appel de macro est une
expression. Un nouveau bloc de portée que nous créons avec des accolades est
une expression, par exemple :
Fichier : src/main.rs
fn main() { let y = { let x = 3; x + 1 }; println!("La valeur de y est : {}", y); }
L'expression suivante…
{
let x = 3;
x + 1
}
… est un bloc qui, dans ce cas, s'évalue à 4
. Cette valeur est assignée à y
dans le cadre de l'instruction let
. Remarquez la ligne x + 1
ne se termine
pas par un point-virgule, ce qui est différent de la plupart des lignes que
vous avez vues jusque là. Les expressions n'ont pas de point-virgule de fin de
ligne. Si vous ajoutez un point-virgule à la fin de l'expression, vous la
transformez en instruction, et elle ne va donc pas retourner de valeur. Gardez
ceci à l'esprit quand nous aborderons prochainement les valeurs de retour des
fonctions ainsi que les expressions.
Les fonctions qui retournent des valeurs
Les fonctions peuvent retourner des valeurs au code qui les appelle.
Nous ne nommons pas les valeurs de retour, mais nous devons déclarer
leur type après une flèche (->
). En Rust, la valeur de retour de la fonction
est la même que la valeur de l'expression finale dans le corps de la fonction.
Vous pouvez sortir prématurément d'une fonction en utilisant le mot-clé return
et en précisant la valeur de retour, mais la plupart des fonctions vont
retourner implicitement la dernière expression.
Voici un exemple d'une fonction qui retourne une valeur :
Fichier : src/main.rs
fn cinq() -> i32 { 5 } fn main() { let x = cinq(); println!("La valeur de x est : {}", x); }
Il n'y a pas d'appel de fonction, de macro, ni même d'instruction let
dans la
fonction cinq
— uniquement le nombre 5
tout seul. C'est une fonction
parfaitement valide avec Rust. Remarquez que le type de retour de la fonction a
été précisé aussi, avec -> i32
. Essayez d'exécuter ce code ; le résultat
devrait ressembler à ceci :
$ cargo run
Compiling functions v0.1.0 (file:///projects/functions)
Finished dev [unoptimized + debuginfo] target(s) in 0.30s
Running `target/debug/functions`
La valeur de x est : 5
Le 5
dans cinq
est la valeur de retour de la fonction, ce qui explique le
type de retour de i32
. Regardons cela plus en détail. Il y a deux éléments
importants : premièrement, la ligne let x = cinq();
dit que nous utilisons
la valeur de retour de la fonction pour initialiser la variable. Comme la
fonction cinq
retourne un 5
, cette ligne revient à faire ceci :
#![allow(unused)] fn main() { let x = 5; }
Deuxièmement, la fonction cinq
n'a pas de paramètre et déclare le type de
valeur de retour, mais le corps de la fonction est un simple 5
sans
point-virgule car c'est une expression dont nous voulons retourner la valeur.
Regardons un autre exemple :
Fichier : src/main.rs
fn main() { let x = plus_un(5); println!("La valeur de x est : {}", x); } fn plus_un(x: i32) -> i32 { x + 1 }
Exécuter ce code va afficher La valeur de x est : 6
. Mais si nous ajoutons un
point-virgule à la fin de la ligne qui contient x + 1
, ce qui la transforme
d'une expression à une instruction, nous obtenons une erreur.
Fichier : src/main.rs
fn main() {
let x = plus_un(5);
println!("La valeur de x est : {}", x);
}
fn plus_un(x: i32) -> i32 {
x + 1;
}
Compiler ce code va produire une erreur, comme ci-dessous :
$ cargo run
Compiling functions v0.1.0 (file:///projects/functions)
error[E0308]: mismatched types
--> src/main.rs:7:24
|
7 | fn plus_un(x: i32) -> i32 {
| ------- ^^^ expected `i32`, found `()`
| |
| implicitly returns `()` as its body has no tail or `return` expression
8 | x + 1;
| - help: consider removing this semicolon
For more information about this error, try `rustc --explain E0308`.
error: could not compile `functions` due to previous error
Le message d'erreur principal, “mismatched types” (types inadéquats) donne le
cœur du problème de ce code. La définition de la fonction plus_un
dit qu'elle
va retourner un i32
, mais les instructions ne retournent pas de valeur, ceci
est donc représenté par ()
, le type unité. Par conséquent, rien n'est
retourné, ce qui contredit la définition de la fonction et provoque une erreur.
Rust affiche un message qui peut aider à corriger ce problème : il suggère
d'enlever le point-virgule, ce qui va résoudre notre problème.
Les commentaires
Tous les développeurs s'efforcent de rendre leur code facile à comprendre, mais parfois il est nécessaire d'écrire des explications supplémentaires. Dans ce cas, les développeurs laissent des commentaires dans leur code source que le compilateur va ignorer mais qui peuvent être utiles pour les personnes qui lisent le code source.
Voici un simple commentaire :
#![allow(unused)] fn main() { // hello, world }
Avec Rust, les commentaires classiques commencent avec deux barres obliques et
continuent jusqu'à la fin de la ligne. Pour les commentaires qui font plus
d'une seule ligne, vous aurez besoin d'ajouter //
sur chaque ligne, comme
ceci :
#![allow(unused)] fn main() { // Donc ici on fait quelque chose de compliqué, tellement long que nous avons // besoin de plusieurs lignes de commentaires pour le faire ! Heureusement, // ce commentaire va expliquer ce qui se passe. }
Les commentaires peuvent aussi être aussi ajoutés à la fin d'une ligne qui contient du code :
Fichier : src/main.rs
fn main() { let nombre_chanceux = 7; // Je me sens chanceux aujourd'hui }
Mais parfois, vous pourrez les voir utilisés de cette manière, avec le commentaire sur une ligne séparée au-dessus du code qu'il annote :
Fichier : src/main.rs
fn main() { // Je me sens chanceux aujourd'hui let nombre_chanceux = 7; }
Rust a aussi un autre type de commentaire, les commentaires de documentation, que nous aborderons au chapitre 14.
Les structures de contrôle
Pouvoir exécuter ou non du code si une condition est vérifiée, ou exécuter du
code de façon répétée tant qu'une condition est vérifiée, sont des
constructions élémentaires dans la plupart des langages de programmation. Les
structures de contrôle les plus courantes en Rust sont les expressions if
et
les boucles.
Les expressions if
Une expression if
vous permet de diviser votre code en fonction de conditions.
Vous précisez une condition et vous choisissez ensuite : “Si cette condition est
remplie, alors exécuter ce bloc de code. Si la condition n'est pas remplie,
ne pas exécuter ce bloc de code.”
Créez un nouveau projet appelé branches dans votre dossier projects pour
découvrir les expressions if
. Dans le fichier src/main.rs, écrivez ceci :
Fichier : src/main.rs
fn main() { let nombre = 3; if nombre < 5 { println!("La condition est vérifiée"); } else { println!("La condition n'est pas vérifiée"); } }
Une expression if
commence par le mot-clé if
, suivi d'une condition.
Dans notre cas, la condition vérifie si oui ou non la variable nombre
a une
valeur inférieure à 5. Nous ajoutons le bloc de code à exécuter si la condition
est vérifiée immédiatement après la condition entre des accolades. Les blocs de
code associés à une condition dans une expression if
sont parfois appelés des
branches, exactement comme les branches dans les expressions match
que nous
avons vu dans la section “Comparer le nombre saisi au nombre
secret” du
chapitre 2.
Éventuellement, vous pouvez aussi ajouter une expression else
, ce que nous
avons fait ici, pour préciser un bloc alternatif de code qui sera exécuté dans
le cas où la condition est fausse (elle n'est pas vérifiée). Si
vous ne renseignez pas d'expression else
et que la condition n'est pas
vérifiée, le programme va simplement sauter le bloc de if
et passer au
prochain morceau de code.
Essayez d'exécuter ce code ; vous verrez ceci :
$ cargo run
Compiling branches v0.1.0 (file:///projects/branches)
Finished dev [unoptimized + debuginfo] target(s) in 0.31s
Running `target/debug/branches`
La condition est vérifiée
Essayons de changer la valeur de nombre
pour une valeur qui rend la condition
non vérifiée pour voir ce qui se passe :
fn main() {
let nombre = 7;
if nombre < 5 {
println!("La condition est vérifiée");
} else {
println!("La condition n'est pas vérifiée");
}
}
Exécutez à nouveau le programme, et regardez le résultat :
$ cargo run
Compiling branches v0.1.0 (file:///projects/branches)
Finished dev [unoptimized + debuginfo] target(s) in 0.31s
Running `target/debug/branches`
La condition n'est pas vérifiée
Il est aussi intéressant de noter que la condition dans ce code doit être un
bool
. Si la condition n'est pas un bool
, nous aurons une erreur. Par
exemple, essayez d'exécuter le code suivant :
Fichier : src/main.rs
fn main() {
let nombre = 3;
if nombre {
println!("Le nombre était trois");
}
}
La condition if
vaut 3
cette fois, et Rust lève une erreur :
$ cargo run
Compiling branches v0.1.0 (file:///projects/branches)
error[E0308]: mismatched types
--> src/main.rs:4:8
|
4 | if nombre {
| ^^^^^^ expected bool, found integer
For more information about this error, try `rustc --explain E0308`.
error: could not compile `branches` due to previous error
Cette erreur explique que Rust attendait un bool
mais a obtenu un entier
(integer). Contrairement à des langages comme Ruby et JavaScript, Rust
ne va pas essayer de convertir automatiquement les types non booléens en
booléens. Vous devez être précis et toujours fournir un booléen à la condition
d'un if
. Si nous voulons que le bloc de code du if
soit exécuté quand le
nombre est différent de 0
, par exemple, nous pouvons changer l'expression if
par la suivante :
Fichier: src/main.rs
fn main() { let nombre = 3; if nombre != 0 { println!("Le nombre valait autre chose que zéro"); } }
Exécuter ce code va bien afficher Le nombre valait autre chose que zéro
.
Gérer plusieurs conditions avec else if
Vous pouvez utiliser plusieurs conditions en combinant if
et else
dans une
expression else if
. Par exemple :
Fichier : src/main.rs
fn main() { let nombre = 6; if nombre % 4 == 0 { println!("Le nombre est divisible par 4"); } else if nombre % 3 == 0 { println!("Le nombre est divisible par 3"); } else if nombre % 2 == 0 { println!("Le nombre est divisible par 2"); } else { println!("Le nombre n'est pas divisible par 4, 3 ou 2"); } }
Ce programme peut choisir entre quatre chemins différents. Après l'avoir exécuté, vous devriez voir le résultat suivant :
$ cargo run
Compiling branches v0.1.0 (file:///projects/branches)
Finished dev [unoptimized + debuginfo] target(s) in 0.31s
Running `target/debug/branches`
Le nombre est divisible par 3
Quand ce programme s'exécute, il vérifie chaque expression if
à tour de rôle
et exécute le premier bloc dont la condition est vérifiée. Notez que même si 6
est divisible par 2, nous ne voyons pas le message Le nombre est divisible par 2
, ni le message Le nombre n'est pas divisible par 4, 3 ou 2
du bloc else
.
C'est parce que Rust n'exécute que le bloc de la première condition vérifiée,
et dès lors qu'il en a trouvé une, il ne va pas chercher à vérifier les
suivantes.
Utiliser trop d'expressions else if
peut encombrer votre code, donc si vous
en avez plus d'une, vous devriez envisager de remanier votre code. Le chapitre 6
présente une construction puissante appelée match
pour de tels cas.
Utiliser if
dans une instruction let
Comme if
est une expression, nous pouvons l'utiliser à droite d'une
instruction let
pour assigner le résultat à une variable, comme dans l'encart
3-2.
Fichier : src/main.rs
fn main() { let condition = true; let nombre = if condition { 5 } else { 6 }; println!("La valeur du nombre est : {}", nombre); }
Encart 3-2 : assigner le résultat d'une expression if
à
une variable
La variable nombre
va avoir la valeur du résultat de l'expression if
.
Exécutez ce code pour découvrir ce qui va se passer :
$ cargo run
Compiling branches v0.1.0 (file:///projects/branches)
Finished dev [unoptimized + debuginfo] target(s) in 0.30s
Running `target/debug/branches`
La valeur du nombre est : 5
Souvenez-vous que les blocs de code s'exécutent jusqu'à la dernière expression
qu'ils contiennent, et que les nombres tout seuls sont aussi des expressions.
Dans notre cas, la valeur de toute l'expression if
dépend de quel bloc de code
elle va exécuter. Cela veut dire que chaque valeur qui peut être le résultat de
chaque branche du if
doivent être du même type ; dans l'encart 3-2, les
résultats des branches if
et else
sont tous deux des entiers i32
. Si
les types ne sont pas identiques, comme dans l'exemple suivant, nous allons
obtenir une erreur :
Fichier : src/main.rs
fn main() {
let condition = true;
let nombre = if condition { 5 } else { "six" };
println!("La valeur du nombre est : {}", nombre);
}
Lorsque nous essayons de compiler ce code, nous obtenons une erreur. Les
branches if
et else
ont des types de valeurs qui ne sont pas compatibles, et
Rust indique exactement où trouver le problème dans le programme :
$ cargo run
Compiling branches v0.1.0 (file:///projects/branches)
error[E0308]: `if` and `else` have incompatible types
--> src/main.rs:4:44
|
4 | let nombre = if condition { 5 } else { "six" };
| - ^^^^^ expected integer, found `&str`
| |
| expected because of this
For more information about this error, try `rustc --explain E0308`.
error: could not compile `branches` due to previous error
L'expression dans le bloc if
donne un entier, et l'expression dans le bloc
else
donne une chaîne de caractères. Ceci ne fonctionne pas car les variables
doivent avoir un seul type, et Rust a besoin de savoir de quel type est la
variable nombre
au moment de la compilation. Savoir le type de nombre
permet au compilateur de vérifier que le type est valable n'importe où nous
utilisons nombre
. Rust ne serait pas capable de faire cela si le type de
nombre
était déterminé uniquement à l'exécution ; car le compilateur
deviendrait plus complexe et nous donnerait moins de garanties sur le code s'il
devait prendre en compte tous les types hypothétiques pour une variable.
Les répétitions avec les boucles
Il est parfois utile d'exécuter un bloc de code plus d'une seule fois. Dans ce but, Rust propose plusieurs types de boucles, qui parcourt le code à l'intérieur du corps de la boucle jusqu'à la fin et recommence immédiatement du début. Pour tester les boucles, créons un nouveau projet appelé loops.
Rust a trois types de boucles : loop
, while
, et for
. Essayons chacune
d'elles.
Répéter du code avec loop
Le mot-clé loop
demande à Rust d'exécuter un bloc de code encore et encore
jusqu'à l'infini ou jusqu'à ce que vous lui demandiez explicitement de
s'arrêter.
Par exemple, changez le fichier src/main.rs dans votre dossier loops comme ceci :
Fichier : src/main.rs
fn main() {
loop {
println!("À nouveau !");
}
}
Quand nous exécutons ce programme, nous voyons À nouveau !
s'afficher encore
et encore en continu jusqu'à ce qu'on arrête le programme manuellement. La
plupart des terminaux utilisent un raccourci clavier,
ctrl-c, pour arrêter un programme qui est bloqué dans une boucle infinie.
Essayons cela :
$ cargo run
Compiling loops v0.1.0 (file:///projects/loops)
Finished dev [unoptimized + debuginfo] target(s) in 0.29s
Running `target/debug/loops`
À nouveau !
À nouveau !
À nouveau !
À nouveau !
^CÀ nouveau !
Le symbole ^C
représente le moment où vous avez appuyé sur
ctrl-c. Vous devriez voir ou non le texte
À nouveau !
après le ^C
, en fonction de là où la boucle en était dans votre
code quand elle a reçu le signal d'arrêt.
Heureusement, Rust fournit aussi un autre moyen de sortir d'une boucle en
utilisant du code. Vous pouvez ajouter le mot-clé break
à l'intérieur de la boucle
pour demander au programme d'arrêter la boucle. Souvenez-vous que nous avions
fait ceci dans le jeu de devinettes, dans la section “Arrêter le programme
après avoir gagné” du chapitre 2
afin de quitter le programme quand l'utilisateur gagne le jeu en devinant le
bon nombre.
Nous avons également continue
dans le jeu du plus ou du moins, qui dans une
boucle demande au programme de sauter le code restant dans cette iteration de
la boucle et passer directement à la prochaine itération.
Si vous avez des boucles imbriquées dans d'autres boucles, break
et continue
s'appliquent uniquement à la boucle au plus bas niveau. Si vous en avez besoin,
vous pouvez associer une etiquette de boucle à une boucle que nous pouvons
ensuite utiliser en association avec break
ou continue
pour préciser que
ces mot-clés s'appliquent sur la boucle correspondant à l'étiquette plutôt qu'à
la boucle la plus proche possible. Voici un exemple avec deux boucles
imbriquées :
fn main() { let mut compteur = 0; 'increment: loop { println!("compteur = {}", compteur); let mut restant = 10; loop { println!("restant = {}", restant); if restant == 9 { break; } if compteur == 2 { break 'increment; } restant -= 1; } compteur += 1; } println!("Fin du compteur = {}", compteur); }
La boucle la plus à l'extérieur a l'étiquette increment
, et elle va
incrémenter de 0 à 2. La boucle à l'intérieur n'a pas d'étiquette et va
décrementer de 10 à 9. Le premier break
qui ne précise pas d'étiquette va
arrêter uniquement la boucle interne. L'instruction break 'increment;
va
arrêter la boucle la plus à l'extérieur. Ce code va afficher :
$ cargo run
Compiling loops v0.1.0 (file:///projects/loops)
Finished dev [unoptimized + debuginfo] target(s) in 0.58s
Running `target/debug/loops`
compteur = 0
restant = 10
restant = 9
compteur = 1
restant = 10
restant = 9
compteur = 2
restant = 10
Fin du compteur = 2
Retourner des valeurs d'une boucle
L'une des utilisations d'une boucle loop
est de réessayer une opération qui
peut échouer, comme vérifier si une tâche a terminé son travail. Vous aurez
aussi peut-être besoin de passer le résultat de l'opération au reste de votre
code à l'extérieur de cette boucle. Pour ce faire, vous pouvez ajouter la
valeur que vous voulez retourner après l'expression break
que vous utilisez
pour stopper la boucle ; cette valeur sera retournée à l'extérieur de la boucle
pour que vous puissiez l'utiliser, comme ci-dessous :
fn main() { let mut compteur = 0; let resultat = loop { compteur += 1; if compteur == 10 { break compteur * 2; } }; println!("Le résultat est {}", resultat); }
Avant la boucle, nous déclarons une variable avec le nom compteur
et nous
l'initialisons à 0
. Ensuite, nous déclarons une variable resultat
pour
stocker la valeur retournée de la boucle. À chaque itération de la boucle, nous
ajoutons 1
à la variable compteur
, et ensuite nous vérifions si le compteur
est égal à 10
. Lorsque c'est le cas, nous utilisons le mot-clé break
avec la
valeur compteur * 2
. Après la boucle, nous utilisons un point-virgule pour
terminer l'instruction qui assigne la valeur à resultat
. Enfin, nous
affichons la valeur de resultat
, qui est 20 dans ce cas-ci.
Les boucles conditionnelles avec while
Un programme a souvent besoin d'évaluer une condition dans une boucle.
Tant que la condition est vraie, la boucle tourne. Quand la condition arrête
d'être vraie, le programme appelle break
, ce qui arrête la boucle. Il est
possible d'implémenter un comportement comme celui-ci en combinant loop
,
if
, else
et break
; vous pouvez essayer de le faire, si vous voulez.
Cependant, cette utilisation est si fréquente que Rust a une construction pour
cela, intégrée dans le langage, qui s'appelle une boucle while
. Dans l'encart
3-3, nous utilisons while
pour boucler trois fois, en décrémentant à chaque
fois, et ensuite, après la boucle, il va afficher un message et se fermer.
Fichier : src/main.rs
fn main() { let mut nombre = 3; while nombre != 0 { println!("{} !", nombre); nombre -= 1; } println!("DÉCOLLAGE !!!"); }
Encart 3-3: utiliser une boucle while
pour exécuter du
code tant qu'une condition est vraie
Cette construction élimine beaucoup d'imbrications qui seraient nécessaires si
vous utilisiez loop
, if
, else
et break
, et c'est aussi plus clair. Tant
que la condition est vraie, le code est exécuté ; sinon, il quitte la boucle.
Boucler dans une collection avec for
Vous pouvez choisir d'utiliser la construction while
pour itérer sur les
éléments d'une collection, comme les tableaux. Par exemple, la boucle dans
l'encart 3-4 affiche chaque élément présent dans le tableau a
.
Fichier : src/main.rs
fn main() { let a = [10, 20, 30, 40, 50]; let mut indice = 0; while indice < 5 { println!("La valeur est : {}", a[indice]); indice += 1; } }
Encart 3-4 : itération sur les éléments d'une collection
en utilisant une boucle while
Ici, le code parcourt le tableau élément par élément.
Il commence à l'indice 0
, et ensuite boucle jusqu'à ce qu'il atteigne l'indice
final du tableau (ce qui correspond au moment où la condition index < 5
n'est
plus vraie). Exécuter ce code va afficher chaque élément du tableau :
$ cargo run
Compiling loops v0.1.0 (file:///projects/loops)
Finished dev [unoptimized + debuginfo] target(s) in 0.32s
Running `target/debug/loops`
La valeur est : 10
La valeur est : 20
La valeur est : 30
La valeur est : 40
La valeur est : 50
Les cinq valeurs du tableau s'affichent toutes dans le terminal, comme attendu.
Même si indice
va atteindre la valeur 5
à un moment, la boucle arrêtera de
s'exécuter avant d'essayer de récupérer une sixième valeur du tableau.
Cependant, cette approche pousse à l'erreur ; nous pourrions faire paniquer le
programme si la valeur de l'indice est trop grand ou que la condition du test
est incorrecte. Par exemple, si vous changez la définition du tableau a
pour
avoir quatre éléments, mais que nous oublions de modifier la condition dans
while indice < 4
, le code paniquera. De plus, c'est lent, car le compilateur
ajoute du code pour effectuer à l'exécution la vérification que l'indice est
compris dans les limites du tableau, et cela à chaque itération de la boucle.
Pour une alternative plus concise, vous pouvez utiliser une boucle for
et
exécuter du code pour chaque élément dans une collection. Une boucle for
s'utilise comme dans le code de l'encart 3-5.
Fichier : src/main.rs
fn main() { let a = [10, 20, 30, 40, 50]; for element in a { println!("La valeur est : {}", element); } }
Encart 3-5 : itérer sur chaque élément d'une collection
en utilisant une boucle for
Lorsque nous exécutons ce code, nous obtenons les mêmes messages que dans l'encart 3-4. Mais ce qui est plus important, c'est que nous avons amélioré la sécurité de notre code et éliminé le risque de bogues qui pourraient survenir si on dépassait la fin du tableau, ou si on n'allait pas jusqu'au bout et qu'on ratait quelques éléments.
En utilisant la boucle for
, vous n'aurez pas à vous rappeler de changer le
code si vous changez le nombre de valeurs dans le tableau, comme vous devriez
le faire dans la méthode utilisée dans l'encart 3-4.
La sécurité et la concision de la boucle for
en font la construction de boucle
la plus utilisée avec Rust. Même dans des situations dans lesquelles vous
voudriez exécuter du code plusieurs fois, comme l'exemple du décompte qui
utilisait une boucle while
dans l'encart 3-3, la plupart des Rustacés
utiliseraient une boucle for
. Il faut pour cela utiliser un intervalle
Range
, fourni par la bibliothèque standard pour générer dans l'ordre tous les
nombres compris entre un certain nombre et un autre nombre.
Voici ce que le décompte aurait donné en utilisant une boucle for
et une autre
méthode que nous n'avons pas encore vue, rev
, qui inverse l'intervalle :
Fichier : src/main.rs
fn main() { for nombre in (1..4).rev() { println!("{} !", nombre); } println!("DÉCOLLAGE !!!"); }
Ce code est un peu plus sympa, non ?
Résumé
Vous y êtes arrivé ! C'était un chapitre important : vous avez appris les
variables, les types scalaires et composés, les fonctions, les commentaires, les
expressions if
, et les boucles !
Pour pratiquer un peu les concepts abordés dans ce chapitre, voici quelques
programmes que vous pouvez essayer de créer :
- Convertir des températures entre les degrés Fahrenheit et Celsius.
- Générer le n-ième nombre de Fibonacci.
- Afficher les paroles de la chanson de Noël The Twelve Days of Christmas en profitant de l'aspect répétitif de la chanson.
Quand vous serez prêt à aller plus loin, nous aborderons une notion de Rust qui n'existe pas dans les autres langages de programmation : la possession (ownership).
Comprendre la possession
La possession (ownership) est la fonctionnalité la plus remarquable de Rust, et a des implications en profondeur dans l'ensemble du langage. Elle permet à Rust de garantir la sécurité de la mémoire sans avoir besoin d'un ramasse-miettes (garbage collector), donc il est important de comprendre comment la possession fonctionne. Dans ce chapitre, nous aborderons la possession, ainsi que d'autres fonctionnalités associées : l'emprunt, les slices et la façon dont Rust agence les données en mémoire.
Qu'est-ce que la possession ?
La possession est un jeu de règles qui gouvernent la gestion de la mémoire par un programme Rust. Tous les programmes doivent gérer la façon dont ils utilisent la mémoire lorsqu'ils s'exécutent. Certains langages ont un ramasse-miettes qui scrute constamment la mémoire qui n'est plus utilisée pendant qu'il s'exécute ; dans d'autres langages, le développeur doit explicitement allouer et libérer la mémoire. Rust adopte une troisième approche : la mémoire est gérée avec un système de possession qui repose sur un jeu de règles que le compilateur vérifie au moment de la compilation. Si une de ces règles a été enfreinte, le programme ne sera pas compilé. Aucune des fonctionnalités de la possession ne ralentit votre programme à l'exécution.
Comme la possession est un nouveau principe pour de nombreux développeurs, cela prend un certain temps pour s'y familiariser. La bonne nouvelle est que plus vous devenez expérimenté avec Rust et ses règles de possession, plus vous développerez naturellement et facilement du code sûr et efficace. Gardez bien cela à l'esprit !
Lorsque vous comprendrez la possession, vous aurez des bases solides pour comprendre les fonctionnalités qui font la particularité de Rust. Dans ce chapitre, vous allez apprendre la possession en pratiquant avec plusieurs exemples qui se concentrent sur une structure de données très courante : les chaînes de caractères.
La pile et le tas
De nombreux langages ne nécessitent pas de se préoccuper de la pile (stack) et du tas (heap). Mais dans un langage de programmation système comme Rust, si une donnée soit sur la pile ou sur le tas a une influence sur le comportement du langage et explique pourquoi nous devons faire certains choix. Nous décrirons plus loin dans ce chapitre comment la possession fonctionne vis-à-vis de la pile et du tas, voici donc une brève explication au préalable.
La pile et le tas sont tous les deux des emplacements de la mémoire à disposition de votre code lors de son exécution, mais sont organisés de façon différente. La pile enregistre les valeurs dans l'ordre qu'elle les reçoit et enlève les valeurs dans l'autre sens. C'est ce que l'on appelle le principe de dernier entré, premier sorti. C'est comme une pile d'assiettes : quand vous ajoutez des nouvelles assiettes, vous les déposez sur le dessus de la pile, et quand vous avez besoin d'une assiette, vous en prenez une sur le dessus. Ajouter ou enlever des assiettes au milieu ou en bas ne serait pas aussi efficace ! Ajouter une donnée sur la pile se dit empiler et en retirer une se dit dépiler. Toutes donnée stockée dans la pile doit avoir une taille connue et fixe. Les données avec une taille inconnue au moment de la compilation ou une taille qui peut changer doivent plutôt être stockées sur le tas.
Le tas est moins bien organisé : lorsque vous ajoutez des données sur le tas, vous demandez une certaine quantité d'espace mémoire. Le gestionnaire de mémoire va trouver un emplacement dans le tas qui est suffisamment grand, va le marquer comme étant en cours d'utilisation, et va retourner un pointeur, qui est l'adresse de cet emplacement. Cette procédure est appelée allocation sur le tas, ce qu'on abrège parfois en allocation tout court. L'ajout de valeurs sur la pile n'est pas considéré comme une allocation. Comme le pointeur vers le tas a une taille connue et fixe, on peut stocker ce pointeur sur la pile, mais quand on veut la vraie donnée, il faut suivre le pointeur.
C'est comme si vous vouliez manger au restaurant. Quand vous entrez, vous indiquez le nombre de personnes dans votre groupe, et le personnel trouve une table vide qui peut recevoir tout le monde, et vous y conduit. Si quelqu'un dans votre groupe arrive en retard, il peut leur demander où vous êtes assis pour vous rejoindre.
Empiler sur la pile est plus rapide qu'allouer sur le tas car le gestionnaire ne va jamais avoir besoin de chercher un emplacement pour y stocker les nouvelles données ; il le fait toujours au sommet de la pile. En comparaison, allouer de la place sur le tas demande plus de travail, car le gestionnaire doit d'abord trouver un espace assez grand pour stocker les données et mettre à jour son suivi pour préparer la prochaine allocation.
Accéder à des données dans le tas est plus lent que d'accéder aux données sur la pile car nous devons suivre un pointeur pour les obtenir. Les processeurs modernes sont plus rapides s'ils se déplacent moins dans la mémoire. Pour continuer avec notre analogie, imaginez un serveur dans un restaurant qui prend les commandes de nombreuses tables. C'est plus efficace de récupérer toutes les commandes à une seule table avant de passer à la table suivante. Prendre une commande à la table A, puis prendre une commande à la table B, puis ensuite une autre à la table A, puis une autre à la table B serait un processus bien plus lent. De la même manière, un processeur sera plus efficace dans sa tâche s'il travaille sur des données qui sont proches les unes des autres (comme c'est le cas sur la pile) plutôt que si elles sont plus éloignées (comme cela peut être le cas sur le tas). Allouer une grande quantité de mémoire sur le tas peut aussi prendre beaucoup de temps.
Quand notre code utilise une fonction, les valeurs passées à la fonction (incluant, potentiellement, des pointeurs de données sur le tas) et les variables locales à la fonction sont déposées sur la pile. Quand l'utilisation de la fonction est terminée, ces données sont retirées de la pile.
La possession nous aide à ne pas nous préoccuper de faire attention à quelles parties du code utilisent quelles données sur le tas, de minimiser la quantité de données en double sur le tas, ou encore de veiller à libérer les données inutilisées sur le tas pour que nous ne soyons pas à court d'espace. Quand vous aurez compris la possession, vous n'aurez plus besoin de vous préoccuper de la pile et du tas très souvent, mais savoir que le but principal de la possession est de gérer les données du tas peut vous aider à comprendre pourquoi elle fonctionne de cette manière.
Les règles de la possession
Tout d'abord, définissons les règles de la possession. Gardez à l'esprit ces règles pendant que nous travaillons sur des exemples qui les illustrent :
- Chaque valeur en Rust a une variable qui s'appelle son propriétaire.
- Il ne peut y avoir qu'un seul propriétaire à la fois.
- Quand le propriétaire sortira de la portée, la valeur sera supprimée.
Portée de la variable
Maintenant
que nous avons vu la syntaxe Rust de base, nous n'allons plus ajouter tout le
code du style fn main() {
dans les exemples, donc si vous voulez reproduire
les exemples, assurez-vous de placer manuellement dans une fonction main
. Par
conséquent, nos exemples seront plus concis, nous permettant de nous concentrer
sur les détails de la situation plutôt que sur du code normalisé.
Pour le premier exemple de possession, nous allons analyser la portée de certaines variables. Une portée est une zone dans un programme dans laquelle un élément est en vigueur. Admettons la variable suivante :
#![allow(unused)] fn main() { let s = "hello"; }
La variable s
fait référence à un littéral de chaîne de caractères, où la
valeur de la chaîne est codée en dur dans notre programme. La variable est en
vigueur à partir du moment où elle est déclarée jusqu'à la fin de la portée
actuelle. L'encart 4-1 nous présente un programme avec des commentaires pour
indiquer quand la variable s
est en vigueur :
fn main() { { // s n'est pas en vigueur ici, elle n'est pas encore déclarée let s = "hello"; // s est en vigueur à partir de ce point // on fait des choses avec s ici } // cette portée est maintenant terminée, et s n'est plus en vigueur }
Encart 4-1 : Une variable et la portée dans laquelle elle est en vigueur.
Autrement dit, il y a ici deux étapes importantes :
- Quand
s
rentre dans la portée, elle est en vigueur. - Cela reste ainsi jusqu'à ce qu'elle sorte de la portée.
Pour le moment, la relation entre les portées et les conditions pour lesquelles
les variables sont en vigueur sont similaires à d'autres langages de
programmation. Maintenant, nous allons aller plus loin en y ajoutant le type
String
.
Le type String
Pour illustrer les règles de la possession, nous avons besoin d'un type de
donnée qui est plus complexe que ceux que nous avons rencontrés dans la section
“Types de données” du chapitre 3. Les types que
nous avons vus précédemment ont tous une taille connue et peuvent être stockés
sur la pile ainsi que retirés de la pile lorsque la portée n'en a plus besoin,
et peuvent aussi être rapidement et facilement afin de constituer une nouvelle
instance indépendante si une autre partie du code a besoin d'utiliser la même
valeur dans une portée différente. Mais nous voulons expérimenter le stockage
de données sur le tas et découvrir comment Rust sait quand il doit nettoyer ces
données, et le type String
est un bon exemple.
Nous allons nous concentrer sur les caractéristiques de String
qui sont liées
à la possession. Ces aspects s'appliquent également à d'autres types de données
complexes, qu'ils soient fournis par la bibliothèque standard ou qu'ils soient
créés par vous. Nous verrons String
plus en détail dans le chapitre
8.
Nous avons déjà vu les littéraux de chaînes de caractères, quand une valeur de
chaîne est codée en dur dans notre programme. Les littéraux de chaînes sont
pratiques, mais ils ne conviennent pas toujours à tous les cas où on veut
utiliser du texte. Une des raisons est qu'ils sont immuables. Une autre raison
est qu'on ne connaît pas forcément le contenu des chaînes de caractères quand
nous écrivons notre code : par exemple, comment faire si nous voulons récupérer
du texte saisi par l'utilisateur et l'enregistrer ? Pour ces cas-ci, Rust a un
second type de chaîne de caractères, String
. Ce type gère ses données sur le
tas et est ainsi capable de stocker une quantité de texte qui nous est inconnue
au moment de la compilation. Vous pouvez créer une String
à partir d'un
littéral de chaîne de caractères en utilisant la fonction from
, comme ceci :
#![allow(unused)] fn main() { let s = String::from("hello"); }
L'opérateur double deux-points ::
nous permet d'appeler cette fonction
spécifique dans l'espace de nom du type String
plutôt que d'utiliser un nom
comme string_from
. Nous verrons cette syntaxe plus en détail dans la section
“Syntaxe de méthode” du chapitre 5 et lorsque
nous aborderons les espaces de noms dans la section “Les chemins pour désigner
un élément dans l'arborescence de module” du
chapitre 7.
Ce type de chaîne de caractères peut être mutable :
fn main() { let mut s = String::from("hello"); s.push_str(", world!"); // push_str() ajoute un littéral de chaîne dans une String println!("{}", s); // Cela va afficher `hello, world!` }
Donc, quelle est la différence ici ? Pourquoi String
peut être mutable, mais
pourquoi les littéraux de chaînes ne peuvent pas l'être ? La différence
se trouve dans la façon dont ces deux types travaillent avec la mémoire.
Mémoire et allocation
Dans le cas d'un littéral de chaîne de caractères, nous connaissons le contenu au moment de la compilation donc le texte est codé en dur directement dans l'exécutable final. Voilà pourquoi ces littéraux de chaînes de caractères sont performants et rapides. Mais ces caractéristiques viennent de leur immuabilité. Malheureusement, on ne peut pas accorder une grosse région de mémoire dans le binaire pour chaque morceau de texte qui n'a pas de taille connue au moment de la compilation et dont la taille pourrait changer pendant l'exécution de ce programme.
Avec le type String
, pour nous permettre d'avoir un texte mutable et qui peut
s'agrandir, nous devons allouer une quantité de mémoire sur le tas, inconnue
au moment de la compilation, pour stocker le contenu. Cela signifie que :
- La mémoire doit être demandée auprès du gestionnaire de mémoire lors de l'exécution.
- Nous avons besoin d'un moyen de rendre cette mémoire au gestionnaire lorsque
nous aurons fini d'utiliser notre
String
.
Nous nous occupons de ce premier point : quand nous appelons String::from
, son
implémentation demande la mémoire dont elle a besoin. C'est pratiquement
toujours ainsi dans la majorité des langages de programmation.
Cependant, le deuxième point est différent. Dans des langages avec un
ramasse-miettes, le ramasse-miettes surveille et nettoie la mémoire qui n'est
plus utilisée, sans que nous n'ayons à nous en préoccuper. Dans la pluspart des
langages sans ramasse-miettes, c'est de notre responsabilité d'identifier quand
cette mémoire n'est plus utilisée et d'appeler du code pour explicitement la
libérer, comme nous l'avons fait pour la demander auparavant. Historiquement,
faire ceci correctement a toujours été une difficulté pour les développeurs. Si
nous oublions de le faire, nous allons gaspiller de la mémoire. Si nous le
faisons trop tôt, nous allons avoir une variable invalide. Si nous le faisons
deux fois, cela produit aussi un bogue. Nous devons associer exactement un
allocate
avec exactement un free
.
Rust prend un chemin différent : la mémoire est automatiquement libérée dès
que la variable qui la possède sort de la portée. Voici une version de notre
exemple de portée de l'encart 4-1 qui utilise une String
plutôt qu'un littéral
de chaîne de caractères :
fn main() { { let s = String::from("hello"); // s est en vigueur à partir de ce point // on fait des choses avec s ici } // cette portée est désormais terminée, et s // n'est plus en vigueur maintenant }
Il y a un moment naturel où nous devons rendre la mémoire de notre
String
au gestionnaire : quand s
sort de la portée. Quand une variable sort
de la portée, Rust appelle une fonction spéciale pour nous. Cette fonction
s'appelle drop
, et c'est dans celle-ci que l'auteur de
String
a pu mettre le code pour libérer la mémoire. Rust appelle
automatiquement drop
à l'accolade fermante }
.
Remarque : en C++, cette façon de libérer des ressources à la fin de la durée de vie d'un élément est parfois appelée l'acquisition d'une ressource est une initialisation (RAII). La fonction
drop
de Rust vous sera familière si vous avez déjà utilisé des techniques de RAII.
Cette façon de faire a un impact profond sur la façon dont le code Rust est écrit. Cela peut sembler simple dans notre cas, mais le comportement du code peut être surprenant dans des situations plus compliquées où nous voulons avoir plusieurs variables utilisant des données que nous avons affectées sur le tas. Examinons une de ces situations dès à présent.
Les interactions entre les variables et les données : le déplacement
Plusieurs variables peuvent interagir avec les mêmes données de différentes manières en Rust. Regardons un exemple avec un entier dans l'encart 4-2 :
fn main() { let x = 5; let y = x; }
Encart 4-2 : Assigner l'entier de la variable x
à y
Nous pouvons probablement deviner ce que ce code fait : “Assigner la valeur 5
à x
; ensuite faire une copie de cette valeur de x
et l'assigner à y
.”
Nous avons maintenant deux variables, x
et y
, et chacune vaut 5
. C'est
effectivement ce qui se passe, car les entiers sont des valeurs simples avec une
taille connue et fixée, et ces deux valeurs 5
sont stockées sur la pile.
Maintenant, essayons une nouvelle version avec String
:
fn main() { let s1 = String::from("hello"); let s2 = s1; }
Cela ressemble beaucoup, donc nous allons supposer que cela fonctionne pareil
que précédemment : ainsi, la seconde ligne va faire une copie de la valeur de
s1
et l'assigner à s2
. Mais ce n'est pas tout à fait ce qu'il se passe.
Regardons l'illustration 4-1 pour découvrir ce qui arrive à String
sous le
capot. Une String
est constituée de trois éléments, présents sur la gauche :
un pointeur vers la mémoire qui contient le contenu de la chaîne de caractères,
une taille, et une capacité. Ce groupe de données est stocké sur la pile. À
droite, nous avons la mémoire sur le tas qui contient les données.
Illustration 4-1 : Représentation en mémoire d'une
String
qui contient la valeur "hello"
assignée à s1
.
La taille est la quantité de mémoire, en octets, que le contenu de la String
utilise actuellement. La capacité est la quantité totale de mémoire, en octets,
que la String
a reçue du gestionnaire. La différence entre la taille et la
capacité est importante, mais pas pour notre exemple, donc pour l'instant, ce
n'est pas grave d'ignorer la capacité.
Quand nous assignons s1
à s2
, les données de la String
sont copiées, ce
qui veut dire que nous copions le pointeur, la taille et la capacité qui sont
stockés sur la pile. Nous ne copions pas les données stockées sur le tas
auxquelles le pointeur se réfère. Autrement dit, la représentation des données
dans la mémoire ressemble à l'illustration 4-2.
Illustration 4-2 : Représentation en mémoire de la
variable s2
qui a une copie du pointeur, de la taille et de la capacité de
s1
Cette représentation n'est pas comme l'illustration 4-3, qui représenterait la
mémoire si Rust avait aussi copié les données sur le tas. Si Rust faisait ceci,
l'opération s2 = s1
pourrait potentiellement être très coûteuse en termes de
performances d'exécution si les données sur le tas étaient volumineuses.
Illustration 4-3 : Une autre possibilité de ce que
pourrait faire s2 = s1
si Rust copiait aussi les données du tas
Précédemment, nous avons dit que quand une variable sortait de la portée, Rust
appelait automatiquement la fonction drop
et nettoyait la mémoire sur le tas
allouée pour cette variable. Mais l'illustration 4-2 montre que les deux
pointeurs de données pointeraient au même endroit. C'est un problème : quand
s2
et s1
sortent de la portée, elles vont essayer toutes les deux de
libérer la même mémoire. C'est ce qu'on appelle une erreur de double
libération et c'est un des bogues de sécurité de mémoire que nous avons
mentionnés précédemment. Libérer la mémoire deux fois peut mener à des
corruptions de mémoire, ce qui peut potentiellement mener à des vulnérabilités
de sécurité.
Pour garantir la sécurité de la mémoire, après la ligne let s2 = s1
, Rust
considère que s1
n'est plus en vigueur. Par conséquent, Rust n'a pas besoin
de libérer quoi que ce soit lorsque s1
sort de la portée. Regardez ce qu'il
se passe quand vous essayez d'utiliser s1
après que s2
est créé, cela ne va
pas fonctionner :
fn main() {
let s1 = String::from("hello");
let s2 = s1;
println!("{}, world!", s1);
}
Vous allez avoir une erreur comme celle-ci, car Rust vous défend d'utiliser la référence qui n'est plus en vigueur :
$ cargo run
Compiling ownership v0.1.0 (file:///projects/ownership)
error[E0382]: borrow of moved value: `s1`
--> src/main.rs:5:28
|
2 | let s1 = String::from("hello");
| -- move occurs because `s1` has type `String`, which does not implement the `Copy` trait
3 | let s2 = s1;
| -- value moved here
4 |
5 | println!("{}, world!", s1);
| ^^ value borrowed here after move
For more information about this error, try `rustc --explain E0382`.
error: could not compile `ownership` due to previous error
Si vous avez déjà entendu parler de copie superficielle et de copie
profonde en utilisant d'autres langages, l'idée de copier le pointeur, la
taille et la capacité sans copier les données peut vous faire penser à de la
copie superficielle. Mais comme Rust neutralise aussi la première variable, au
lieu d'appeler cela une copie superficielle, on appelle cela un déplacement.
Ici, nous pourrions dire que s1
a été déplacé dans s2
. Donc ce qui se
passe réellement est décrit par l'illustration 4-4.
Illustration 4-4 : Représentation de la mémoire après que
s1
a été neutralisée
Cela résout notre problème ! Avec seulement s2
en vigueur, quand elle
sortira de la portée, elle seule va libérer la mémoire, et c'est tout.
De plus, cela signifie qu'il y a eu un choix de conception : Rust ne va jamais créer automatiquement de copie “profonde” de vos données. Par conséquent, toute copie automatique peut être considérée comme peu coûteuse en termes de performances d'exécution.
Les interactions entre les variables et les données : le clonage
Si nous voulons faire une copie profonde des données sur le tas d'une
String
, et pas seulement des données sur la pile, nous pouvons utiliser une
méthode commune qui s'appelle clone
. Nous aborderons la syntaxe des méthodes
au chapitre 5, mais comme les méthodes sont des outils courants dans de
nombreux langages, vous les avez probablement utilisées auparavant.
Voici un exemple d'utilisation de la méthode clone
:
fn main() { let s1 = String::from("hello"); let s2 = s1.clone(); println!("s1 = {}, s2 = {}", s1, s2); }
Cela fonctionne très bien et c'est ainsi que vous pouvez reproduire le comportement décrit dans l'illustration 4-3, où les données du tas sont copiées.
Quand vous voyez un appel à clone
, vous savez que du code arbitraire est
exécuté et que ce code peut être coûteux. C'est un indicateur visuel qu'il se
passe quelque chose de différent.
Données uniquement sur la pile : la copie
Il y a un autre détail dont on n'a pas encore parlé. Le code suivant utilise des entiers - on en a vu une partie dans l'encart 4-2 - il fonctionne et est correct :
fn main() { let x = 5; let y = x; println!("x = {}, y = {}", x, y); }
Mais ce code semble contredire ce que nous venons d'apprendre : nous n'avons
pas appelé clone
, mais x
est toujours en vigueur et n'a pas été déplacé
dans y
.
La raison est que les types comme les entiers ont une taille connue au moment de
la compilation et sont entièrement stockés sur la pile, donc la copie des
vraies valeurs est rapide à faire. Cela signifie qu'il n'y a pas de raison que
nous voudrions neutraliser x
après avoir créé la variable y
. En d'autres
termes, il n'y a pas ici de différence entre la copie superficielle et profonde,
donc appeler clone
ne ferait rien d'autre qu'une copie superficielle classique
et on peut s'en passer.
Rust a une annotation spéciale appelée le trait Copy
que nous pouvons utiliser
sur des types comme les entiers qui sont stockés sur la pile (nous verrons les
traits dans le chapitre 10). Si un type implémente le trait Copy
, une
variable sera toujours en vigueur après avoir été affectée à une autre
variable. Rust ne nous autorisera pas à annoter un type avec le trait Copy
si
ce type, ou un de ses éléments, a implémenté le trait Drop
. Si ce type a
besoin que quelque chose de spécial se produise quand la valeur sort de la
portée et que nous ajoutons l'annotation Copy
sur ce type, nous aurons une
erreur au moment de la compilation. Pour savoir comment ajouter l'annotation
Copy
sur votre type pour implémenter le trait, référez-vous à l'annexe
C sur les traits dérivables.
Donc, quels sont les types qui implémentent le trait Copy
? Vous pouvez
regarder dans la documentation pour un type donné pour vous en assurer, mais de
manière générale, tout groupe de valeur scalaire peut implémenter Copy
, et
tout ce qui ne nécessite pas d'allocation de mémoire ou tout autre forme de
ressource qui implémente Copy
. Voici quelques types qui implémentent Copy
:
- Tous les types d'entiers, comme
u32
. - Le type booléen,
bool
, avec les valeurstrue
etfalse
. - Tous les types de flottants, comme
f64
. - Le type de caractère,
char
. - Les tuples, mais uniquement s'ils contiennent des types qui implémentent
aussi
Copy
. Par exemple, le(i32, i32)
implémenteCopy
, mais pas(i32, String)
.
La possession et les fonctions
La syntaxe pour passer une valeur à une fonction est similaire à celle pour assigner une valeur à une variable. Passer une variable à une fonction va la déplacer ou la copier, comme l'assignation. L'encart 4-3 est un exemple avec quelques commentaires qui montrent où les variables rentrent et sortent de la portée :
Fichier : src/main.rs
fn main() { let s = String::from("hello"); // s rentre dans la portée. prendre_possession(s); // La valeur de s est déplacée dans la fonction… // … et n'est plus en vigueur à partir d'ici let x = 5; // x rentre dans la portée. creer_copie(x); // x va être déplacée dans la fonction, // mais i32 est Copy, donc on peut // utiliser x ensuite. } // Ici, x sort de la portée, puis ensuite s. Mais puisque la valeur de s a // été déplacée, il ne se passe rien de spécial. fn prendre_possession(texte: String) { // texte rentre dans la portée. println!("{}", texte); } // Ici, texte sort de la portée et `drop` est appelé. La mémoire est libérée. fn creer_copie(entier: i32) { // entier rentre dans la portée. println!("{}", entier); } // Ici, entier sort de la portée. Il ne se passe rien de spécial.
Encart 4-3 : Les fonctions avec les possessions et les portées qui sont commentées
Si on essayait d'utiliser s
après l'appel à prendre_possession
, Rust
déclencherait une erreur à la compilation. Ces vérifications statiques
nous protègent des erreurs. Essayez d'ajouter du code au main
qui utilise s
et x
pour découvrir lorsque vous pouvez les utiliser et lorsque les règles de
la possession vous empêchent de le faire.
Les valeurs de retour et les portées
Retourner des valeurs peut aussi transférer leur possession. L'encart 4-4 montre un exemple d'une fonction qui retourne une valeur, avec des annotations similaires à celles de l'encart 4-3 :
Fichier : src/main.rs
fn main() { let s1 = donne_possession(); // donne_possession déplace sa valeur de // retour dans s1 let s2 = String::from("hello"); // s2 rentre dans la portée let s3 = prend_et_rend(s2); // s2 est déplacée dans // prend_et_rend, qui elle aussi // déplace sa valeur de retour dans s3. } // Ici, s3 sort de la portée et est éliminée. s2 a été déplacée donc il ne se // passe rien. s1 sort aussi de la portée et est éliminée. fn donne_possession() -> String { // donne_possession va déplacer sa // valeur de retour dans la // fonction qui l'appelle. let texte = String::from("yours"); // texte rentre dans la portée. texte // texte est retournée et // est déplacée vers le code qui // l'appelle. } // Cette fonction va prendre une String et en retourne aussi une. fn prend_et_rend(texte: String) -> String { // texte rentre dans la portée. texte // texte est retournée et déplacée vers le code qui l'appelle. }
Encart 4-4 : Transferts de possession des valeurs de retour
La possession d'une variable suit toujours le même schéma à chaque fois :
assigner une valeur à une autre variable la déplace. Quand une variable qui
contient des données sur le tas sort de la portée, la valeur sera nettoyée avec
drop
à moins que la possession de cette donnée soit donnée à une autre
variable.
Même si cela fonctionne, il est un peu fastidieux de prendre la possession puis ensuite de retourner la possession à chaque fonction. Et qu'est-ce qu'il se passe si nous voulons qu'une fonction utilise une valeur, mais n'en prenne pas possession ? C'est assez pénible que tout ce que nous passons doit être retourné si nous voulons l'utiliser à nouveau, en plus de toutes les données qui découlent du corps de la fonction que nous voulons aussi récupérer.
Rust nous permet de retourner plusieurs valeurs à l'aide d'un tuple, comme ceci :
Fichier : src/main.rs
fn main() { let s1 = String::from("hello"); let (s2, taille) = calculer_taille(s1); println!("La taille de '{}' est {}.", s2, taille); } fn calculer_taille(s: String) -> (String, usize) { let taille = s.len(); // len() retourne la taille d'une String. (s, taille) }
Encart 4-5 : Retourner la possession des paramètres
Mais c'est trop laborieux et beaucoup de travail pour un principe qui devrait être banal. Heureusement pour nous, Rust a une fonctionnalité pour utiliser une valeur sans avoir à transférer la possession, avec ce qu'on appelle les références.
Les références et l'emprunt
La difficulté avec le code du tuple à la fin de la section précédente est que
nous avons besoin de retourner la String
au code appelant pour qu'il puisse
continuer à utiliser la String
après l'appel à calculer_taille
, car la
String
a été déplacée dans calculer_taille
. À la place, nous pouvons
fournir une référence à la valeur de la String. Une référence est comme un
pointeur dans le sens où c'est une adresse que nous pouvons suivre pour accéder
à la donnée stockée à cette adresse qui est possédée par une autre variable.
Mais contrairement aux pointeurs, une référence garantit de pointer vers une
valeur en vigueur, d'un type bien déterminé. Voici comment définir et utiliser
une fonction calculer_taille
qui prend une référence à un objet en
paramètre plutôt que de prendre possession de la valeur :
Fichier : src/main.rs
fn main() { let s1 = String::from("hello"); let long = calculer_taille(&s1); println!("La taille de '{}' est {}.", s1, long); } fn calculer_taille(s: &String) -> usize { s.len() }
Premièrement, on peut observer que tout le code des tuples dans la
déclaration des variables et dans la valeur de retour de la fonction a été
enlevé. Deuxièmement, remarquez que nous passons &s1
à calculer_taille
, et
que dans sa définition, nous utilisons &String
plutôt que String
. Ces
esperluettes représentent les références, et elles permettent de vous référer
à une valeur sans en prendre possession. L'illustration 4-5 illustre ce
concept.
Illustration 4-5 : Un schéma de la &String s
qui pointe
vers la String s1
Remarque : l'opposé de la création de références avec
&
est le déréférencement, qui s'effectue avec l'opérateur de déréférencement,*
. Nous allons voir quelques utilisations de l'opérateur de déréférencement dans le chapitre 8 et nous aborderons les détails du déréférencement dans le chapitre 15.
Regardons de plus près l'appel à la fonction :
fn main() { let s1 = String::from("hello"); let long = calculer_taille(&s1); println!("La taille de '{}' est {}.", s1, long); } fn calculer_taille(s: &String) -> usize { s.len() }
La syntaxe &s1
nous permet de créer une référence qui se réfère à la valeur
de s1
mais n'en prend pas possession. Et comme elle ne la possède pas, la
valeur vers laquelle elle pointe ne sera pas libérée quand cette référence
ne sera plus utilisée.
De la même manière, la signature de la fonction utilise &
pour indiquer que
le type du paramètre s
est une référence. Ajoutons quelques commentaires
explicatifs :
fn main() { let s1 = String::from("hello"); let long = calculer_taille(&s1); println!("La taille de '{}' est {}.", s1, long); } fn calculer_taille(s: &String) -> usize { // s est une référence à une String s.len() } // Ici, s sort de la portée. Mais comme elle ne prend pas possession de ce // à quoi elle fait référence, il ne se passe rien.
La portée dans laquelle la variable s
est en vigueur est la même que toute
portée d'un paramètre de fonction, mais la valeur pointée par la référence
n'est pas libérée quand s
n'est plus utilisé, car s
n'en prends pas
possession. Lorsque les fonctions ont des références en paramètres au lieu des
valeurs réelles, nous n'avons pas besoin de retourner les valeurs pour les
rendre, car nous n'en avons jamais pris possession.
Nous appelons l'emprunt l'action de créer une référence. Comme dans la vie réelle, quand un objet appartient à quelqu'un, vous pouvez le lui emprunter. Et quand vous avez fini, vous devez le lui rendre. Vous ne le possédez pas.
Donc qu'est-ce qui se passe si nous essayons de modifier quelque chose que nous empruntons ? Essayez le code dans l'encart 4-6. Attention, spoiler : cela ne fonctionne pas !
Fichier : src/main.rs
fn main() {
let s = String::from("hello");
changer(&s);
}
fn changer(texte: &String) {
texte.push_str(", world");
}
Entrée 4-6 : Tentative de modification d'une valeur empruntée.
Voici l'erreur :
$ cargo run
Compiling ownership v0.1.0 (file:///projects/ownership)
error[E0596]: cannot borrow `*texte` as mutable, as it is behind a `&` reference
--> src/main.rs:8:5
|
7 | fn changer(texte: &String) {
| ------- help: consider changing this to be a mutable reference: `&mut String`
8 | texte.push_str(", world");
| ^^^^^^^^^^^^^^^^^^^^^^^^^ `texte` is a `&` reference, so the data it refers to cannot be borrowed as mutable
For more information about this error, try `rustc --explain E0596`.
error: could not compile `ownership` due to previous error
Comme les variables sont immuables par défaut, les références le sont aussi. Nous ne sommes pas autorisés à modifier une chose quand nous avons une référence vers elle.
Les références mutables
Nous pouvons résoudre le code de l'encart 4-6 pour nous permettre de modifier une valeur empruntée avec quelques petites modification qui utilisent plutôt une référence mutable :
Fichier : src/main.rs
fn main() { let mut s = String::from("hello"); changer(&mut s); } fn changer(texte: &mut String) { texte.push_str(", world"); }
D'abord, nous précisons que s
est mut
. Ensuite, nous avons créé une
référence mutable avec &mut s
où nous appelons la fonction change
et nous
avons modifié la signature pour accepter de prendre une référence mutable avec
texte: &mut String
. Cela précise clairement que la fonction change
va faire
muter la valeur qu'elle emprunte.
Les références mutables ont une grosse contrainte : vous ne pouvez avoir
qu'une seule référence mutable pour chaque donnée au même moment. Le code
suivant qui va tenter de créer deux références mutables à s
va échouer :
Fichier : src/main.rs
fn main() {
let mut s = String::from("hello");
let r1 = &mut s;
let r2 = &mut s;
println!("{}, {}", r1, r2);
}
Voici l'erreur :
$ cargo run
Compiling ownership v0.1.0 (file:///projects/ownership)
error[E0499]: cannot borrow `s` as mutable more than once at a time
--> src/main.rs:5:14
|
4 | let r1 = &mut s;
| ------ first mutable borrow occurs here
5 | let r2 = &mut s;
| ^^^^^^ second mutable borrow occurs here
6 |
7 | println!("{}, {}", r1, r2);
| -- first borrow later used here
For more information about this error, try `rustc --explain E0499`.
error: could not compile `ownership` due to previous error
Cette erreur nous explique que ce code est invalide car nous ne pouvons pas
emprunter s
de manière mutable plus d'une fois au même moment. Le premier
emprunt mutable est dans r1
et doit perdurer jusqu'à ce qu'il soit utilisé
dans le println!
, mais pourtant entre la création de cette référence mutable
et son utilisation, nous avons essayé de créer une autre référence mutable dans
r2
qui emprunte la même donnée que dans r1
.
La limitation qui empêche d'avoir plusieurs références mutables vers la même donnée au même moment autorise les mutations, mais de manière très contrôlée. C'est quelque chose que les nouveaux Rustacés ont du mal à surmonter, car la plupart des langages vous permettent de modifier les données quand vous le voulez. L'avantage d'avoir cette contrainte est que Rust peut empêcher les accès concurrents au moment de la compilation. Un accès concurrent est une situation de concurrence qui se produit lorsque ces trois facteurs se combinent :
- Deux pointeurs ou plus accèdent à la même donnée au même moment.
- Au moins un des pointeurs est utilisé pour écrire dans cette donnée.
- On n'utilise aucun mécanisme pour synchroniser l'accès aux données.
L'accès concurrent provoque des comportements indéfinis et rend difficile le diagnostic et la résolution de problèmes lorsque vous essayez de les reproduire au moment de l'exécution ; Rust évite ce problème en refusant de compiler du code avec des accès concurrents !
Comme d'habitude, nous pouvons utiliser des accolades pour créer une nouvelle portée, pour nous permettre d'avoir plusieurs références mutables, mais pas en même temps :
fn main() { let mut s = String::from("hello"); { let r1 = &mut s; } // r1 sort de la portée ici, donc nous pouvons créer une nouvelle référence // sans problèmes. let r2 = &mut s; }
Rust impose une règle similaire pour combiner les références immuables et mutables. Ce code va mener à une erreur :
fn main() {
let mut s = String::from("hello");
let r1 = &s; // sans problème
let r2 = &s; // sans problème
let r3 = &mut s; // GROS PROBLEME
println!("{}, {}, and {}", r1, r2, r3);
}
Voici l'erreur :
$ cargo run
Compiling ownership v0.1.0 (file:///projects/ownership)
error[E0502]: cannot borrow `s` as mutable because it is also borrowed as immutable
--> src/main.rs:6:14
|
4 | let r1 = &s; // sans problème
| -- immutable borrow occurs here
5 | let r2 = &s; // sans problème
6 | let r3 = &mut s; // GROS PROBLEME
| ^^^^^^ mutable borrow occurs here
7 |
8 | println!("{}, {}, and {}", r1, r2, r3);
| -- immutable borrow later used here
For more information about this error, try `rustc --explain E0502`.
error: could not compile `ownership` due to previous error
Ouah ! Nous ne pouvons pas non plus avoir une référence mutable pendant que nous en avons une autre immuable vers la même valeur. Les utilisateurs d'une référence immuable ne s'attendent pas à ce que sa valeur change soudainement ! Cependant, l'utilisation de plusieurs références immuables ne pose pas de problème, car simplement lire une donnée ne va pas affecter la lecture de la donnée par les autres.
Notez bien que la portée d'une référence commence dès qu'elle est introduite et
se poursuit jusqu'au dernier endroit où cette référence est utilisée. Par
exemple, le code suivant va se compiler car la dernière utilisation de la
référence immuable, le println!
, est située avant l'introduction de la
référence mutable :
fn main() { let mut s = String::from("hello"); let r1 = &s; // sans problème let r2 = &s; // sans problème println!("{} et {}", r1, r2); //les variables r1 et r2 ne seront plus utilisés à partir d'ici let r3 = &mut s; // sans problème println!("{}", r3); }
Les portées des références immuables r1
et r2
se terminent après le
println!
où elles sont utilisées pour la dernière fois, c'est-à-dire avant que
la référence mutable r3
soit créée. Ces portées ne se chevauchent pas, donc ce
code est autorisé. La capacité du compilateur à dire si une référence n'est plus
utilisée à un endroit avant la fin de la portée s'appelle en Anglais les
Non-Lexical Lifetimes (ou NLL), et vous pouvez en apprendre plus dans le
Guide de l'édition.
Même si ces erreurs d'emprunt peuvent parfois être frustrantes, n'oubliez pas que le compilateur de Rust nous signale un bogue potentiel en avance (au moment de la compilation plutôt que l'exécution) et vous montre où se situe exactement le problème. Ainsi, vous n'avez pas à chercher pourquoi vos données ne correspondent pas à ce que vous pensiez qu'elles devraient être.
Les références pendouillantes
Avec les langages qui utilisent les pointeurs, il est facile de créer par erreur un pointeur pendouillant (dangling pointer), qui est un pointeur qui pointe vers un emplacement mémoire qui a été donné à quelqu'un d'autre, en libérant de la mémoire tout en conservant un pointeur vers cette mémoire. En revanche, avec Rust, le compilateur garantit que les références ne seront jamais des références pendouillantes : si nous avons une référence vers une donnée, le compilateur va s'assurer que cette donnée ne va pas sortir de la portée avant que la référence vers cette donnée en soit elle-même sortie.
Essayons de créer une référence pendouillante pour voir comment Rust va les empêcher via une erreur au moment de la compilation :
Fichier : src/main.rs
fn main() {
let reference_vers_rien = pendouille();
}
fn pendouille() -> &String {
let s = String::from("hello");
&s
}
Voici l'erreur :
$ cargo run
Compiling ownership v0.1.0 (file:///projects/ownership)
error[E0106]: missing lifetime specifier
--> src/main.rs:5:16
|
5 | fn pendouille() -> &String {
| ^ expected named lifetime parameter
|
= help: this function's return type contains a borrowed value, but there is no value for it to be borrowed from
help: consider using the `'static` lifetime
|
5 | fn pendouille() -> &'static String {
| ~~~~~~~~
For more information about this error, try `rustc --explain E0106`.
error: could not compile `ownership` due to previous error
Ce message d'erreur fait référence à une fonctionnalité que nous n'avons pas encore vue : les durées de vie. Nous aborderons les durées de vie dans le chapitre 10. Mais, si vous mettez de côté les parties qui parlent de durées de vie, le message explique pourquoi le code pose problème :
this function's return type contains a borrowed value, but there is no value
for it to be borrowed from
Ce qui peut se traduire par :
Le type de retour de cette fonction contient une valeur empruntée, mais il n'y a
plus aucune valeur qui peut être empruntée.
Regardons de plus près ce qui se passe exactement à chaque étape de notre code
de pendouille
:
Fichier : src/main.rs
fn main() {
let reference_vers_rien = pendouille();
}
fn pendouille() -> &String { // pendouille retourne une référence vers une String
let s = String::from("hello"); // s est une nouvelle String
&s // nous retournons une référence vers la String, s
} // Ici, s sort de la portée, et est libéré. Sa mémoire disparaît.
// Attention, danger !
Comme s
est créé dans pendouille
, lorsque le code de pendouille
est
terminé, la variable s
sera désallouée. Mais nous avons essayé de retourner
une référence vers elle. Cela veut dire que cette référence va pointer vers une
String
invalide. Ce n'est pas bon ! Rust ne nous laissera pas faire cela.
Ici la solution est de renvoyer la String
directement :
fn main() { let string = ne_pendouille_pas(); } fn ne_pendouille_pas() -> String { let s = String::from("hello"); s }
Cela fonctionne sans problème. La possession est transférée à la valeur de retour de la fonction, et rien n'est désalloué.
Les règles de référencement
Récapitulons ce que nous avons vu à propos des références :
- À un instant donné, vous pouvez avoir soit une référence mutable, soit un nombre quelconque de références immuables.
- Les références doivent toujours être en vigueur.
Ensuite, nous aborderons un autre type de référence : les slices.
Le type slice
Une slice vous permet d'obtenir une référence vers une séquence continue d'éléments d'une collection plutôt que toute la collection. Une slice est un genre de référence, donc elle ne prend pas possession.
Voici un petit problème de programmation : écrire une fonction qui prend une chaîne de caractères et retourne le premier mot qu'elle trouve dans cette chaîne. Si la fonction ne trouve pas d'espace dans la chaîne, cela veut dire que la chaîne est en un seul mot, donc la chaîne en entier doit être retournée.
Voyons comment écrire la signature de cette fonction sans utiliser les slices, afin de comprendre le problème que règlent les slices :
fn premier_mot(s: &String) -> ?
La fonction premier_mot
prend un &String
comme paramètre. Nous ne
voulons pas en prendre possession, donc c'est ce qu'il nous faut. Mais que
devons-nous retourner ? Nous n'avons aucun moyen de désigner une partie
d'une chaîne de caractères. Cependant, nous pouvons retourner l'indice de la
fin du mot, qui se produit lorsqu'il y a un espace. Essayons cela, dans
l'encart 4-7 :
Fichier : src/main.rs
fn premier_mot(s: &String) -> usize { let octets = s.as_bytes(); for (i, &element) in octets.iter().enumerate() { if element == b' ' { return i; } } s.len() } fn main() {}
Encart 4-7 : La fonction premier_mot
qui retourne
l'indice d'un octet provenant du paramètre String
Comme nous avons besoin de parcourir la String
élément par élément et de
vérifier si la valeur est une espace, nous convertissons notre String
en un
tableau d'octets en utilisant la méthode as_bytes
:
fn premier_mot(s: &String) -> usize {
let octets = s.as_bytes();
for (i, &element) in octets.iter().enumerate() {
if element == b' ' {
return i;
}
}
s.len()
}
fn main() {}
Ensuite, nous créons un itérateur sur le tableau d'octets en utilisant la
méthode iter
:
fn premier_mot(s: &String) -> usize {
let octets = s.as_bytes();
for (i, &element) in octets.iter().enumerate() {
if element == b' ' {
return i;
}
}
s.len()
}
fn main() {}
Nous aborderons plus en détail les itérateurs dans le chapitre
13. Pour le moment, sachez que iter
est une méthode qui
retourne chaque élément d'une collection, et que enumerate
transforme le
résultat de iter
pour retourner plutôt chaque élément comme un tuple. Le
premier élément du tuple retourné par enumerate
est l'indice, et le second
élément est une référence vers l'élément. C'est un peu plus pratique que de
calculer les indices par nous-mêmes.
Comme la méthode enumerate
retourne un tuple, nous pouvons utiliser des
motifs pour déstructurer ce tuple. Nous verrons les motifs au chapitre
6. Dans la boucle for
, nous précisons un motif qui
indique que nous définissons i
pour l'indice au sein du tuple et &element
pour l'octet dans le tuple. Comme nous obtenons une référence vers l'élément
avec .iter().enumerate()
, nous utilisons &
dans le motif.
Au sein de la boucle for
, nous recherchons l'octet qui représente l'espace en
utilisant la syntaxe de littéral d'octet. Si nous trouvons une espace, nous
retournons sa position. Sinon, nous retournons la taille de la chaîne en
utilisant s.len()
:
fn premier_mot(s: &String) -> usize {
let octets = s.as_bytes();
for (i, &element) in octets.iter().enumerate() {
if element == b' ' {
return i;
}
}
s.len()
}
fn main() {}
Nous avons maintenant une façon de trouver l'indice de la fin du premier mot
dans la chaîne de caractères, mais il y a un problème. Nous retournons un
usize
tout seul, mais il n'a du sens que lorsqu'il est lié au &String
.
Autrement dit, comme il a une valeur séparée de la String
, il n'y a pas de
garantie qu'il restera toujours valide dans le futur. Imaginons le programme
dans l'encart 4-8 qui utilise la fonction premier_mot
de l'encart 4-7 :
Fichier : src/main.rs
fn premier_mot(s: &String) -> usize { let octets = s.as_bytes(); for (i, &element) in octets.iter().enumerate() { if element == b' ' { return i; } } s.len() } fn main() { let mut s = String::from("hello world"); let mot = premier_mot(&s); // la variable mot aura 5 comme valeur. s.clear(); // ceci vide la String, elle vaut maintenant "". // mot a toujours la valeur 5 ici, mais il n'y a plus de chaîne qui donne // du sens à la valeur 5. mot est maintenant complètement invalide ! }
Encart 4-8 : On stocke le résultat de l'appel à la
fonction premier_mot
et ensuite on change le contenu de la String
Ce programme se compile sans aucune erreur et le ferait toujours si nous
utilisions mot
après avoir appelé s.clear()
. Comme mot
n'est pas du tout
lié à s
, mot
contient toujours la valeur 5
. Nous pourrions utiliser cette
valeur 5
avec la variable s
pour essayer d'en extraire le premier mot, mais
cela serait un bogue, car le contenu de s
a changé depuis que nous avons
enregistré 5
dans mot
.
Se préoccuper en permanence que l'indice présent dans mot
ne soit plus
synchronisé avec les données présentes dans s
est fastidieux et source
d'erreur ! La gestion de ces indices est encore plus risquée si nous écrivons
une fonction second_mot
. Sa signature ressemblerait à ceci :
fn second_mot(s: &String) -> (usize, usize) {
Maintenant, nous avons un indice de début et un indice de fin, donc nous avons encore plus de valeurs qui sont calculées à partir d'une donnée dans un état donné, mais qui ne sont pas liées du tout à l'état de cette donnée. Nous avons trois variables isolées qui ont besoin d'être maintenues à jour.
Heureusement, Rust a une solution pour ce problème : les slices de chaînes de caractères.
Les slices de chaînes de caractères
Une slice de chaîne de caractères (ou slice de chaîne) est une référence à
une partie d'une String
, et ressemble à ceci :
fn main() { let s = String::from("hello world"); let hello = &s[0..5]; let world = &s[6..11]; }
Plutôt que d'être une référence vers toute la String
, hello
est une
référence vers une partie de la String
, comme indiqué dans la partie
supplémentaire [0..5]
. Nous créons des slices en utilisant un intervalle
entre crochets en spécifiant [indice_debut..indice_fin]
, où indice_debut
est la position du premier octet de la slice et indice_fin
est la position
juste après le dernier octet de la slice. En interne, la structure de données
de la slice stocke la position de départ et la longueur de la slice, ce qui
correspond à indice_fin
moins indice_debut
. Donc dans le cas de
let world = &s[6..11];
, world
est une slice qui contient un pointeur vers
le sixième octet de s
et une longueur de 5.
L'illustration 4-6 montre ceci dans un schéma.
Illustration 4-6 : Une slice de chaîne qui pointe vers
une partie d'une String
Avec la syntaxe d'intervalle ..
de Rust, si vous voulez commencer à l'indice
zéro, vous pouvez ne rien mettre avant les deux points. Autrement dit, ces deux
cas sont identiques :
#![allow(unused)] fn main() { let s = String::from("hello"); let slice = &s[0..2]; let slice = &s[..2]; }
De la même manière, si votre slice contient le dernier octet de la String
,
vous pouvez ne rien mettre à la fin. Cela veut dire que ces deux cas sont
identiques :
#![allow(unused)] fn main() { let s = String::from("hello"); let taille = s.len(); let slice = &s[3..taille]; let slice = &s[3..]; }
Vous pouvez aussi ne mettre aucune limite pour créer une slice de toute la chaîne de caractères. Ces deux cas sont donc identiques :
#![allow(unused)] fn main() { let s = String::from("hello"); let taille = s.len(); let slice = &s[0..taille]; let slice = &s[..]; }
Remarque : Les indices de l'intervalle d'une slice de chaîne doivent toujours se trouver dans les zones acceptables de séparation des caractères encodés en UTF-8. Si vous essayez de créer une slice de chaîne qui s'arrête au milieu d'un caractère encodé sur plusieurs octets, votre programme va se fermer avec une erreur. Afin de simplifier l'explication des slices de chaînes, nous utiliserons uniquement l'ASCII dans cette section ; nous verrons la gestion d'UTF-8 dans la section “Stocker du texte encodé en UTF-8 avec les chaînes de caractères” du chapitre 8.
Maintenant que nous savons tout cela, essayons de réécrire premier_mot
pour
qu'il retourne une slice. Le type pour les slices de chaînes de caractères
s'écrit &str
:
Fichier : src/main.rs
fn premier_mot(s: &String) -> &str { let octets = s.as_bytes(); for (i, &element) in octets.iter().enumerate() { if element == b' ' { return &s[0..i]; } } &s[..] } fn main() {}
Nous récupérons l'indice de la fin du mot de la même façon que nous l'avions fait dans l'encart 4-7, en cherchant la première occurrence d'une espace. Lorsque nous trouvons une espace, nous retournons une slice de chaîne en utilisant le début de la chaîne de caractères et l'indice de l'espace comme indices de début et de fin respectivement.
Désormais, quand nous appelons premier_mot
, nous récupérons une unique valeur
qui est liée à la donnée de base. La valeur se compose d'une référence vers le
point de départ de la slice et du nombre d'éléments dans la slice.
Retourner une slice fonctionnerait aussi pour une fonction second_mot
:
fn second_mot(s: &String) -> &str {
Nous avons maintenant une API simple qui est bien plus difficile à mal utiliser,
puisque le compilateur va s'assurer que les références dans la String
seront
toujours en vigueur. Vous souvenez-vous du bogue du programme de l'encart 4-8,
lorsque nous avions un indice vers la fin du premier mot mais qu'ensuite nous
avions vidé la chaîne de caractères et que notre indice n'était plus valide ? Ce
code était logiquement incorrect, mais ne montrait pas immédiatement une erreur.
Les problèmes apparaîtront plus tard si nous essayons d'utiliser l'indice du
premier mot avec une chaîne de caractères qui a été vidée. Les slices rendent ce
bogue impossible et nous signalent bien plus tôt que nous avons un problème avec
notre code. Utiliser la version avec la slice de premier_mot
va causer une
erreur de compilation :
Fichier : src/main.rs
fn premier_mot(s: &String) -> &str {
let octets = s.as_bytes();
for (i, &element) in octets.iter().enumerate() {
if element == b' ' {
return &s[0..i];
}
}
&s[..]
}
fn main() {
let mut s = String::from("hello world");
let mot = premier_mot(&s);
s.clear(); // Erreur !
println!("Le premier mot est : {}", mot);
}
Voici l'erreur du compilateur :
$ cargo run
Compiling ownership v0.1.0 (file:///projects/ownership)
error[E0502]: cannot borrow `s` as mutable because it is also borrowed as immutable
--> src/main.rs:18:5
|
16 | let mot = premier_mot(&s);
| -- immutable borrow occurs here
17 |
18 | s.clear(); // Erreur !
| ^^^^^^^^^ mutable borrow occurs here
19 |
20 | println!("Le premier mot est : {}", mot);
| --- immutable borrow later used here
For more information about this error, try `rustc --explain E0502`.
error: could not compile `ownership` due to previous error
Rappelons-nous que d'après les règles d'emprunt, si nous avons une référence
immuable vers quelque chose, nous ne pouvons pas avoir une référence mutable
en même temps. Étant donné que clear
a besoin de modifier la String
, il a
besoin d'une référence mutable. Le println!
qui a lieu après l'appel à clear
utilise la référence à mot
, donc la référence immuable sera toujours en
vigueur à cet endroit. Rust interdit la référence mutable dans clear
et la
référence immuable pour mot
au même moment, et la compilation échoue. Non
seulement Rust a simplifié l'utilisation de notre API, mais il a
aussi éliminé une catégorie entière d'erreurs au moment de la compilation !
Les littéraux de chaîne de caractères sont aussi des slices
Rappelez-vous lorsque nous avons appris que les littéraux de chaîne de caractères étaient enregistrés dans le binaire. Maintenant que nous connaissons les slices, nous pouvons désormais comprendre les littéraux de chaîne.
#![allow(unused)] fn main() { let s = "Hello, world!"; }
Ici, le type de s
est un &str
: c'est une slice qui pointe vers un endroit
précis du binaire. C'est aussi la raison pour laquelle les littéraux de chaîne
sont immuables ; &str
est une référence immuable.
Les slices de chaînes de caractères en paramètres
Savoir que l'on peut utiliser des slices de littéraux et de String
nous incite
à apporter une petite amélioration à premier_mot
, dont voici la signature :
fn premier_mot(s: &String) -> &str {
Un Rustacé plus expérimenté écrirait plutôt la signature de l'encart 4-9, car
cela nous permet d'utiliser la même fonction sur les &String
et aussi les
&str
:
fn premier_mot(s: &str) -> &str {
let octets = s.as_bytes();
for (i, &element) in octets.iter().enumerate() {
if element == b' ' {
return &s[0..i];
}
}
&s[..]
}
fn main() {
let ma_string = String::from("hello world");
// `premier_mot` fonctionne avec les slices de `String`, que ce soit sur
// une partie ou sur sur son intégralité
let mot = premier_mot(&ma_string[0..6]);
let mot = premier_mot(&ma_string[..]);
// `premier_mot` fonctionne également sur des références vers des `String`,
// qui sont équivalentes à des slices de toute la `String`
let mot = premier_mot(&ma_string);
let mon_litteral_de_chaine = "hello world";
// `premier_mot` fonctionne avec les slices de littéraux de chaîne, qu'elles
// soient partielles ou intégrales
let mot = premier_mot(&mon_litteral_de_chaine[0..6]);
let mot = premier_mot(&mon_litteral_de_chaine[..]);
// Comme les littéraux de chaîne *sont* déjà des slices de chaînes,
// cela fonctionne aussi, sans la syntaxe de slice !
let mot = premier_mot(mon_litteral_de_chaine);
}
Encart 4-9 : Amélioration de la fonction premier_mot
en
utilisant une slice de chaîne de caractères comme type du paramètre s
Si nous avons une slice de chaîne, nous pouvons la passer en argument
directement. Si nous avons une String
, nous pouvons envoyer une référence ou
une slice de la String
. Cette flexibilité nous est offerte par
l'extrapolation de déréferencement, une fonctionnalité que nous allons
découvrir dans une section du Chapitre 15.
Définir une fonction qui prend une slice de chaîne plutôt qu'une référence à
une String
rend notre API plus générique et plus utile sans perdre aucune
fonctionnalité :
Fichier : src/main.rs
fn premier_mot(s: &str) -> &str { let octets = s.as_bytes(); for (i, &element) in octets.iter().enumerate() { if element == b' ' { return &s[0..i]; } } &s[..] } fn main() { let ma_string = String::from("hello world"); // `premier_mot` fonctionne avec les slices de `String`, que ce soit sur // une partie ou sur sur son intégralité let mot = premier_mot(&ma_string[0..6]); let mot = premier_mot(&ma_string[..]); // `premier_mot` fonctionne également sur des références vers des `String`, // qui sont équivalentes à des slices de toute la `String` let mot = premier_mot(&ma_string); let mon_litteral_de_chaine = "hello world"; // `premier_mot` fonctionne avec les slices de littéraux de chaîne, qu'elles // soient partielles ou intégrales let mot = premier_mot(&mon_litteral_de_chaine[0..6]); let mot = premier_mot(&mon_litteral_de_chaine[..]); // Comme les littéraux de chaîne *sont* déjà des slices de chaînes, // cela fonctionne aussi, sans la syntaxe de slice ! let mot = premier_mot(mon_litteral_de_chaine); }
Les autres slices
Les slices de chaînes de caractères, comme vous pouvez l'imaginer, sont spécifiques aux chaînes de caractères. Mais il existe aussi un type de slice plus générique. Imaginons ce tableau de données :
#![allow(unused)] fn main() { let a = [1, 2, 3, 4, 5]; }
Tout comme nous pouvons nous référer à une partie d'une chaîne de caractères, nous pouvons nous référer à une partie d'un tableau. Nous pouvons le faire comme ceci :
#![allow(unused)] fn main() { let a = [1, 2, 3, 4, 5]; let slice = &a[1..3]; assert_eq!(slice, &[2, 3]); }
Cette slice est de type &[i32]
. Elle fonctionne de la même manière que les
slices de chaînes de caractères, en enregistrant une référence vers le premier
élément et une longueur. Vous utiliserez ce type de slice pour tous les autres
types de collections. Nous aborderons ces collections en détail quand nous
verrons les vecteurs au chapitre 8.
Résumé
Les concepts de possession, d'emprunt et de slices garantissent la sécurité de la mémoire dans les programmes Rust au moment de la compilation. Le langage Rust vous donne le contrôle sur l'utilisation de la mémoire comme tous les autres langages de programmation système, mais le fait que celui qui possède des données nettoie automatiquement ces données quand il sort de la portée vous permet de ne pas avoir à écrire et déboguer du code en plus pour avoir cette fonctionnalité.
La possession influe sur de nombreuses autres fonctionnalités de Rust, c'est
pourquoi nous allons encore parler de ces concepts plus loin dans le livre.
Passons maintenant au chapitre 5 et découvrons comment regrouper des données
ensemble dans une struct
.
Utiliser les structures pour structurer des données apparentées
Une struct, ou structure, est un type de données personnalisé qui vous permet de rassembler plusieurs valeurs associées et les nommer pour former un groupe cohérent. Si vous êtes familier avec un langage orienté objet, une structure est en quelque sorte l'ensemble des attributs d'un objet. Dans ce chapitre, nous comparerons les tuples avec les structures afin de construire ce que vous connaissez déjà et de montrer à quels moments les structures sont plus pertinentes pour grouper des données. Nous verrons comment définir les fonctions associées, en particulier le type de fonctions associées que l'on appelle les méthodes, dans le but d'implémenter un comportement associé au type d'une structure. Les structures et les énumérations (traitées au chapitre 6) sont les fondements de la création de nouveaux types au sein de votre programme pour tirer pleinement parti des vérifications de types effectuées par Rust à la compilation.
Définir et instancier des structures
Les structures sont similaires aux tuples, qu'on a vus dans une section du chapitre 3, car tous les deux portent plusieurs valeurs associées. Comme pour les tuples, les éléments d'une structure peuvent être de différents types. Contrairement aux tuples, dans une structure on doit nommer chaque élément des données afin de clarifier le rôle de chaque valeur. L'ajout de ces noms font que les structures sont plus flexibles que les tuples : on n'a pas à utiliser l'ordre des données pour spécifier ou accéder aux valeurs d'une instance.
Pour définir une structure, on tape le mot-clé struct
et on donne un nom à
toute la structure. Le nom d'une structure devrait décrire l'utilisation des
éléments des données regroupés. Ensuite, entre des accolades, on définit le nom
et le type de chaque élément des données, qu'on appelle un champ. Par exemple,
l'encart 5-1 montre une structure qui stocke des informations à propos d'un
compte d'utilisateur.
struct Utilisateur { actif: bool, pseudo: String, email: String, nombre_de_connexions: u64, } fn main() {}
Encart 5-1 : la définition d'une structure
Utilisateur
Pour utiliser une structure après l'avoir définie, on crée une instance de
cette structure en indiquant des valeurs concrètes pour chacun des champs.
On crée une instance en indiquant le nom de la structure puis en ajoutant des
accolades qui contiennent des paires de clé: valeur
, où les clés sont les noms
des champs et les valeurs sont les données que l'on souhaite stocker dans ces
champs. Nous n'avons pas à préciser les champs dans le même ordre qu'on les a
déclarés dans la structure. En d'autres termes, la définition de la structure
décrit un gabarit pour le type, et les instances remplissent ce gabarit avec des
données précises pour créer des valeurs de ce type. Par exemple, nous pouvons
déclarer un utilisateur précis comme dans l'encart 5-2.
struct Utilisateur { actif: bool, pseudo: String, email: String, nombre_de_connexions: u64, } fn main() { let utilisateur1 = Utilisateur { email: String::from("quelquun@example.com"), pseudo: String::from("pseudoquelconque123"), actif: true, nombre_de_connexions: 1, }; }
Encart 5-2 : création d'une instance de la structure
Utilisateur
Pour obtenir une valeur spécifique depuis une structure, on utilise la notation
avec le point. Si nous voulions seulement l'adresse e-mail de cet utilisateur,
on pourrait utiliser utilisateur1.email
partout où on voudrait utiliser cette
valeur. Si l'instance est mutable, nous pourrions changer une valeur en
utilisant la notation avec le point et assigner une valeur à ce champ en
particulier. L'encart 5-3 montre comment changer la valeur du champ email
d'une instance mutable de Utilisateur
.
struct Utilisateur { actif: bool, pseudo: String, email: String, nombre_de_connexions: u64, } fn main() { let mut utilisateur1 = Utilisateur { email: String::from("quelquun@example.com"), pseudo: String::from("pseudoquelconque123"), actif: true, nombre_de_connexions: 1, }; utilisateur1.email = String::from("unautremail@example.com"); }
Encart 5-3 : changement de la valeur du champ email
d'une instance de Utilisateur
À noter que l'instance tout entière doit être mutable ; Rust ne nous permet pas de marquer seulement certains champs comme mutables. Comme pour toute expression, nous pouvons construire une nouvelle instance de la structure comme dernière expression du corps d'une fonction pour retourner implicitement cette nouvelle instance.
L'encart 5-4 montre une fonction creer_utilisateur
qui retourne une instance
de Utilisateur
avec l'adresse e-mail et le pseudo fournis. Le champ actif
prend la valeur true
et le nombre_de_connexions
prend la valeur 1
.
struct Utilisateur { actif: bool, pseudo: String, email: String, nombre_de_connexions: u64, } fn creer_utilisateur(email: String, pseudo: String) -> Utilisateur { Utilisateur { email: email, pseudo: pseudo, actif: true, nombre_de_connexions: 1, } } fn main() { let utilisateur1 = creer_utilisateur( String::from("quelquun@example.com"), String::from("pseudoquelconque123"), ); }
Encart 5-4 : une fonction creer_utilisateur
qui prend
en entrée une adresse e-mail et un pseudo et retourne une instance de
Utilisateur
Il est logique de nommer les paramètres de fonction avec le même nom que les
champs de la structure, mais devoir répéter les noms de variables et de champs
email
et pseudo
est un peu pénible. Si la structure avait plus de champs,
répéter chaque nom serait encore plus fatigant. Heureusement, il existe un
raccourci pratique !
Utiliser le raccourci d'initialisation des champs
Puisque les noms des paramètres et les noms de champs de la structure sont
exactement les mêmes dans l'encart 5-4, on peut utiliser la syntaxe de
raccourci d'initialisation des champs pour réécrire creer_utilisateur
de
sorte qu'elle se comporte exactement de la même façon sans avoir à répéter
email
et pseudo
, comme le montre l'encart 5-5.
struct Utilisateur { actif: bool, pseudo: String, email: String, nombre_de_connexions: u64, } fn creer_utilisateur(email: String, pseudo: String) -> Utilisateur { Utilisateur { email, pseudo, actif: true, nombre_de_connexions: 1, } } fn main() { let utilisateur1 = creer_utilisateur( String::from("quelquun@example.com"), String::from("pseudoquelconque123"), ); }
Encart 5-5 : une fonction creer_utilisateur
qui utilise
le raccourci d'initialisation des champs parce que les paramètres email
et
pseudo
ont le même nom que les champs de la structure
Ici, on crée une nouvelle instance de la structure Utilisateur
, qui possède
un champ nommé email
. On veut donner au champ email
la valeur du paramètre
email
de la fonction creer_utilisateur
. Comme le champ email
et le
paramètre email
ont le même nom, on a uniquement besoin d'écrire email
plutôt que email: email
.
Créer des instances à partir d'autres instances avec la syntaxe de mise à jour de structure
Il est souvent utile de créer une nouvelle instance de structure qui comporte la plupart des valeurs d'une autre instance tout en en changeant certaines. Vous pouvez utiliser pour cela la syntaxe de mise à jour de structure.
Tout d'abord, dans l'encart 5-6 nous montrons comment créer une nouvelle
instance de Utilisateur
dans utilisateur2
sans la syntaxe de mise à jour de
structure. On donne de nouvelles valeurs à email
et pseudo
mais on utilise
pour les autres champs les mêmes valeurs que dans utilisateur1
qu'on a créé à
l'encart 5-2.
struct Utilisateur { actif: bool, pseudo: String, email: String, nombre_de_connexions: u64, } fn main() { // -- partie masquée ici -- let utilisateur1 = Utilisateur { email: String::from("quelquun@example.com"), pseudo: String::from("pseudoquelconque123"), actif: true, nombre_de_connexions: 1, }; let utilisateur2 = Utilisateur { actif: utilisateur1.actif, pseudo: utilisateur1.email, email: String::from("quelquundautre@example.com"), nombre_de_connexions: utilisateur1.nombre_de_connexions, }; }
Encart 5-6 : création d'une nouvelle instance de
Utilisateur
en utilisant une des valeurs de utilisateur1
.
En utilisant la syntaxe de mise à jour de structure, on peut produire le même
résultat avec moins de code, comme le montre l'encart 5-7. La syntaxe ..
indique que les autres champs auxquels on ne donne pas explicitement de valeur
devraient avoir la même valeur que dans l'instance précisée.
struct Utilisateur { actif: bool, pseudo: String, email: String, nombre_de_connexions: u64, } fn main() { // -- partie masquée ici -- let utilisateur1 = Utilisateur { email: String::from("quelquun@example.com"), pseudo: String::from("pseudoquelconque123"), actif: true, nombre_de_connexions: 1, }; let utilisateur2 = Utilisateur { email: String::from("quelquundautre@example.com"), ..utilisateur1 }; }
Encart 5-7 : utilisation de la syntaxe de mise à jour de
structure pour assigner de nouvelles valeurs à email
d'une nouvelle instance
de Utilisateur
tout en utilisant les autres valeurs de utilisateur1
Le code dans l'encart 5-7 crée aussi une instance dans utilisateur2
qui a une
valeur différente pour email
, mais qui as les mêmes valeurs pour les champs
pseudo
, actif
et nombre_de_connexions
que utilisateur1
. Le
..utilisateur1
doit être inséré à la fin pour préciser que tous les champs
restants obtiendrons les valeurs des champs correspondants de utilisateur1
,
mais nous pouvons renseigner les valeurs des champs dans n'importe quel ordre,
peu importe leur position dans la définition de la structure.
Veuillez notez que la syntaxe de la mise à jour de structure utilise un =
comme le ferait une assignation ; car cela déplace les données, comme nous
l'avons vu dans une des sections au chapitre 4. Dans cet
exemple, nous ne pouvons plus utiliser utilisateur1
après avoir créé
utilisateur2
car la String
dans le champ pseudo
de utilisateur1
a été
déplacée dans utilisateur2
. Si nous avions donné des nouvelles valeurs pour
chacune des String
email
et pseudo
, et que par conséquent nous aurions
déplacé uniquement les valeurs de actif
et de nombre_de_connexions
à partir
de utilisateur1
, alors utilisateur1
restera en vigueur après avoir créé
utilisateur2
. Les types de actif
et de nombre_de_connexions
sont de types
qui implémentent le trait Copy
, donc le comportement décris dans la section
à propos de copy aura lieu ici.
Utilisation de structures tuples sans champ nommé pour créer des types différents
Rust prend aussi en charge des structures qui ressemblent à des tuples, appelées structures tuples. La signification d'une structure tuple est donnée par son nom. En revanche, ses champs ne sont pas nommés ; on ne précise que leurs types. Les structures tuples servent lorsqu'on veut donner un nom à un tuple pour qu'il soit d'un type différent des autres tuples, et lorsque nommer chaque champ comme dans une structure classique serait trop verbeux ou redondant.
La définition d'une structure tuple commence par le mot-clé struct
et le nom
de la structure suivis des types des champs du tuple. Par exemple ci-dessous,
nous définissons et utilisons deux structures tuples nommées Couleur
et
Point
:
struct Couleur(i32, i32, i32); struct Point(i32, i32, i32); fn main() { let noir = Couleur(0, 0, 0); let origine = Point(0, 0, 0); }
Notez que les valeurs noir
et origine
sont de types différents parce que ce
sont des instances de structures tuples différentes. Chaque structure que l'on
définit constitue son propre type, même si les champs au sein de la structure
ont les mêmes types. Par exemple, une fonction qui prend un paramètre de type
Couleur
ne peut pas prendre un argument de type Point
à la place, bien que
ces deux types soient tous les deux constitués de trois valeurs i32
. Mis à
part cela, les instances de stuctures tuples se comportent comme des tuples : on
peut les déstructurer en éléments individuels, on peut utiliser un .
suivi de
l'indice pour accéder individuellement à une valeur, et ainsi de suite.
Les structures unité sans champs
On peut aussi définir des structures qui n'ont pas de champs ! Cela s'appelle
des structures unité parce qu'elles se comportent d'une façon analogue au type
unité, ()
, que nous avons vu dans la section sur les
tuples. Les structures unité sont utiles lorsqu'on doit
implémenter un trait sur un type mais qu'on n'a aucune donnée à stocker dans le
type en lui-même. Nous aborderons les traits au chapitre 10. Voici un exemple
de déclaration et d'instanciation d'une structure unité ToujoursEgal
:
struct ToujoursEgal; fn main() { let sujet = ToujoursEgal; }
Pour définir ToujoursEgal
, nous utilisons le mot-clé struct
, puis le nom que
nous voulons lui donner, et enfin un point-virgule. Pas besoin d'accolades ou de
parenthèses ! Ensuite, nous pouvons obtenir une instance de ToujourEgal
dans
la variable sujet
de la même manière : utilisez le nom que vous avez défini,
sans aucune accolade ou parenthèse. Imaginez que plus tard nous allons
implémenter un comportement pour ce type pour que toutes les instances de
ToujourEgal
soient toujours égales à chaque instance de n'importe quel autre
type, peut-être pour avoir un résultat connu pour des besoins de tests. Nous
n'avons besoin d'aucune donnée pour implémenter ce comportement ! Vous verrez
au chapitre 10 comment définir des traits et les implémenter sur n'importe quel
type, y compris sur les structures unité.
La possession des données d'une structure
Dans la définition de la structure
Utilisateur
de l'encart 5-1, nous avions utilisé le type possédéString
plutôt que le type de slice de chaîne de caractères&str
. Il s'agit d'un choix délibéré puisque nous voulons que chacune des instances de cette structure possèdent toutes leurs données et que ces données restent valides tant que la structure tout entière est valide.Il est aussi possible pour les structures de stocker des références vers des données possédées par autre chose, mais cela nécessiterait d'utiliser des durées de vie, une fonctionnalité de Rust que nous aborderons au chapitre 10. Les durées de vie assurent que les données référencées par une structure restent valides tant que la structure l'est aussi. Disons que vous essayiez de stocker une référence dans une structure sans indiquer de durées de vie, comme ce qui suit, ce qui ne fonctionnera pas :
Fichier : src/main.rs
struct Utilisateur { actif: bool, pseudo: &str, email: &str, nombre_de_connexions: u64, } fn main() { let utilisateur1 = Utilisateur { email: "quelquun@example.com", pseudo: "pseudoquelconque123", actif: true, nombre_de_connexions: 1, }; }
Le compilateur réclamera l'ajout des durées de vie :
$ cargo run Compiling structs v0.1.0 (file:///projects/structs) error[E0106]: missing lifetime specifier --> src/main.rs:3:15 | 3 | pseudo: &str, | ^ expected named lifetime parameter | help: consider introducing a named lifetime parameter | 1 ~ struct Utilisateur<'a> { 2 | actif: bool, 3 ~ pseudo: &'a str, | error[E0106]: missing lifetime specifier --> src/main.rs:4:12 | 4 | email: &str, | ^ expected named lifetime parameter | help: consider introducing a named lifetime parameter | 1 ~ struct Utilisateur<'a> { 2 | actif: bool, 3 | pseudo: &str, 4 ~ email: &'a str, | For more information about this error, try `rustc --explain E0106`. error: could not compile `structs` due to 2 previous errors
Au chapitre 10, nous aborderons la façon de corriger ces erreurs pour qu'on puisse stocker des références dans des structures, mais pour le moment, nous résoudrons les erreurs comme celles-ci en utilisant des types possédés comme
String
plutôt que des références comme&str
.
Un exemple de programme qui utilise des structures
Pour comprendre dans quels cas nous voudrions utiliser des structures, écrivons un programme qui calcule l'aire d'un rectangle. Nous commencerons en utilisant de simples variables, puis on remaniera le code jusqu'à utiliser des structures à la place.
Créons un nouveau projet binaire avec Cargo nommé rectangles qui prendra la largeur et la hauteur en pixels d'un rectangle et qui calculera l'aire de ce rectangle. L'encart 5-8 montre un petit programme qui effectue cette tâche d'une certaine manière dans le src/main.rs de notre projet.
Fichier: src/main.rs
fn main() { let largeur1 = 30; let hauteur1 = 50; println!( "L'aire du rectangle est de {} pixels carrés.", aire(largeur1, hauteur1) ); } fn aire(largeur: u32, hauteur: u32) -> u32 { largeur * hauteur }
Encart 5-8 : calcul de l'aire d'un rectangle défini par
les variables distinctes largeur
et hauteur
Maintenant, lancez ce programme avec cargo run
:
$ cargo run
Compiling rectangles v0.1.0 (file:///projects/rectangles)
Finished dev [unoptimized + debuginfo] target(s) in 0.42s
Running `target/debug/rectangles`
L'aire du rectangle est de 1500 pixels carrés.
Ce code arrive à déterminer l'aire du rectangle en appelant la fonction aire
avec chaque dimension, mais on peut faire mieux pour clarifier ce code et le
rendre plus lisible.
Le problème de ce code se voit dans la signature de aire
:
fn main() {
let largeur1 = 30;
let hauteur1 = 50;
println!(
"L'aire du rectangle est de {} pixels carrés.",
aire(largeur1, hauteur1)
);
}
fn aire(largeur: u32, hauteur: u32) -> u32 {
largeur * hauteur
}
La fonction aire
est censée calculer l'aire d'un rectangle, mais la fonction
que nous avons écrite a deux paramètres, et il n'est pas précisé nulle part
dans notre programme à quoi sont liés les paramètres. Il serait plus lisible et
plus gérable de regrouper ensemble la largeur et la hauteur. Nous avons déjà vu
dans la section “Le type tuple” du chapitre 3
une façon qui nous permettrait de le faire : en utilisant des tuples.
Remanier le code avec des tuples
L'encart 5-9 nous montre une autre version de notre programme qui utilise des tuples.
Fichier : src/main.rs
fn main() { let rect1 = (30, 50); println!( "L'aire du rectangle est de {} pixels carrés.", aire(rect1) ); } fn aire(dimensions: (u32, u32)) -> u32 { dimensions.0 * dimensions.1 }
Encart 5-9 : Renseigner la largeur et la hauteur du rectangle dans un tuple
D'une certaine façon, ce programme est meilleur. Les tuples nous permettent de structurer un peu plus et nous ne passons plus qu'un argument. Mais d'une autre façon, cette version est moins claire : les tuples ne donnent pas de noms à leurs éléments, donc il faut accéder aux éléments du tuple via leur indice, ce qui rends plus compliqué notre calcul.
Le mélange de la largeur et la hauteur n'est pas important pour calculer l'aire,
mais si on voulait afficher le rectangle à l'écran, cela serait problématique !
Il nous faut garder à l'esprit que la largeur
est l'élément à l'indice 0
du
tuple et que la hauteur
est l'élément à l'indice 1
. Cela complexifie le
travail de quelqu'un d'autre de le comprendre et s'en souvenir pour qu'il
puisse l'utiliser. Comme on n'a pas exprimé la signification de nos données
dans notre code, il est plus facile de faire des erreurs.
Remanier avec des structures : donner plus de sens
On utilise des structures pour rendre les données plus expressives en leur donnant des noms. On peut transformer le tuple que nous avons utilisé en une structure nommée dont ses éléments sont aussi nommés, comme le montre l'encart 5-10.
Fichier : src/main.rs
struct Rectangle { largeur: u32, hauteur: u32, } fn main() { let rect1 = Rectangle { largeur: 30, hauteur: 50 }; println!( "L'aire du rectangle est de {} pixels carrés.", aire(&rect1) ); } fn aire(rectangle: &Rectangle) -> u32 { rectangle.largeur * rectangle.hauteur }
Encart 5-10 : Définition d'une structure
Rectangle
Ici, on a défini une structure et on l'a appelée Rectangle
. Entre les
accolades, on a défini les champs largeur
et hauteur
, tous deux du type
u32
. Puis dans main
, on crée une instance de Rectangle
de largeur 30 et de
hauteur 50.
Notre fonction aire
est désormais définie avec un unique paramètre, nommé
rectangle
, et dont le type est une référence immuable vers une instance de la
structure Rectangle
. Comme mentionné au chapitre 4, on préfère emprunter la
structure au lieu d'en prendre possession. Ainsi, elle reste en possession de
main
qui peut continuer à utiliser rect1
; c'est pourquoi on utilise le &
dans la signature de la fonction ainsi que dans l'appel de fonction.
La fonction aire
accède aux champs largeur
et hauteur
de l'instance de
Rectangle
. Notre signature de fonction pour aire
est enfin explicite :
calculer l'aire d'un Rectangle
en utilisant ses champs largeur
et hauteur
.
Cela explique que la largeur et la hauteur sont liées entre elles, et cela donne
des noms descriptifs aux valeurs plutôt que d'utiliser les valeurs du tuple avec
les indices 0
et 1
. On gagne en clarté.
Ajouter des fonctionnalités utiles avec les traits dérivés
Cela serait pratique de pouvoir afficher une instance de Rectangle
pendant
qu'on débogue notre programme et de voir la valeur de chacun de ses champs.
L'encart 5-11 essaye de le faire en utilisant la macro
println!
comme on l'a fait dans les chapitres
précédents. Cependant, cela ne fonctionne pas.
Fichier : src/main.rs
struct Rectangle {
largeur: u32,
hauteur: u32,
}
fn main() {
let rect1 = Rectangle {
largeur: 30,
hauteur: 50
};
println!("rect1 est {}", rect1);
}
Encart 5-11 : Tentative d'afficher une instance de
Rectangle
Lorsqu'on compile ce code, on obtient ce message d'erreur qui nous informe que
Rectangle
n'implémente pas le trait std::fmt::Display
:
error[E0277]: `Rectangle` doesn't implement `std::fmt::Display`
La macro println!
peut faire toutes sortes de formatages textuels, et par
défaut, les accolades demandent à println!
d'utiliser le formatage appelé
Display
, pour convertir en texte destiné à être vu par l'utilisateur final.
Les types primitifs qu'on a vus jusqu'ici implémentent Display
par défaut
puisqu'il n'existe qu'une seule façon d'afficher un 1
ou tout autre type
primitif à l'utilisateur. Mais pour les structures, la façon dont println!
devrait formater son résultat est moins claire car il y a plus de possibilités
d'affichage : Voulez-vous des virgules ? Voulez-vous afficher les accolades ?
Est-ce que tous les champs devraient être affichés ? À cause de ces ambiguïtés,
Rust n'essaye pas de deviner ce qu'on veut, et les structures n'implémentent pas
Display
par défaut pour l'utiliser avec println!
et les espaces réservés
{}
.
Si nous continuons de lire les erreurs, nous trouvons cette remarque utile :
= help: the trait `std::fmt::Display` is not implemented for `Rectangle`
= note: in format strings you may be able to use `{:?}` (or {:#?} for pretty-print) instead
Le compilateur nous informe que dans notre chaîne de formatage, on est peut-être
en mesure d'utiliser {:?}
(ou {:#?}
pour un affichage plus élégant).
Essayons cela ! L'appel de la macro println!
ressemble maintenant à
println!("rect1 est {:?}", rect1);
. Insérer le sélecteur :?
entre les
accolades permet d'indiquer à println!
que nous voulons utiliser le formatage
appelé Debug
. Le trait Debug
nous permet d'afficher notre structure d'une
manière utile aux développeurs pour qu'on puisse voir sa valeur pendant qu'on
débogue le code.
Compilez le code avec ce changement. Zut ! On a encore une erreur, nous
informant cette fois-ci que Rectangle
n'implémente pas std::fmt::Debug
:
error[E0277]: `Rectangle` doesn't implement `Debug`
Mais une nouvelle fois, le compilateur nous fait une remarque utile :
= help: the trait `Debug` is not implemented for `Rectangle`
= note: add `#[derive(Debug)]` to `Rectangle` or manually `impl Debug for Rectangle`
Il nous conseille d'ajouter #[derive(Debug)]
ou d'implémenter manuellement
std::fmt::Debug
.
Rust inclut bel et bien une fonctionnalité pour afficher des informations de
débogage, mais nous devons l'activer explicitement pour la rendre disponible sur
notre structure. Pour ce faire, on ajoute l'attribut externe #[derive(Debug)]
juste avant la définition de la structure, comme le montre l'encart 5-12.
Fichier : src/main.rs
#[derive(Debug)] struct Rectangle { largeur: u32, hauteur: u32, } fn main() { let rect1 = Rectangle { largeur: 30, hauteur: 50 }; println!("rect1 est {:?}", rect1); }
Encart 5-12 : ajout de l'attribut pour dériver le
trait Debug
et afficher l'instance de Rectangle
en utilisant le formatage
de débogage
Maintenant, quand on exécute le programme, nous n'avons plus d'erreurs et ce texte s'affiche à l'écran :
$ cargo run
Compiling rectangles v0.1.0 (file:///projects/rectangles)
Finished dev [unoptimized + debuginfo] target(s) in 0.48s
Running `target/debug/rectangles`
rect1 est Rectangle { largeur: 30, hauteur: 50 }
Super ! Ce n'est pas le plus beau des affichages, mais cela montre les
valeurs de tous les champs de cette instance, ce qui serait assurément utile
lors du débogage. Quand on a des structures plus grandes, il serait bien d'avoir
un affichage un peu plus lisible ; dans ces cas-là, on pourra utiliser {:#?}
au lieu de {:?}
dans la chaîne de formatage. Dans cette exemple,
l'utilisation du style {:#?}
va afficher ceci :
$ cargo run
Compiling rectangles v0.1.0 (file:///projects/rectangles)
Finished dev [unoptimized + debuginfo] target(s) in 0.48s
Running `target/debug/rectangles`
rect1 est Rectangle {
largeur: 30,
hauteur: 50,
}
Une autre façon d'afficher une valeur en utilisant le format Debug
est
d'utiliser la macro dbg!
, qui prend possession de
l'expression, affiche le nom du fichier et la ligne de votre code où se trouve
cet appel à la macro dbg!
ainsi que le résultat de cette expression, puis
rend la possession de cette valeur.
Remarque : l'appel à la macro
dbg!
écrit dans le flux d'erreur standard de la console (stderr
), contrairement àprintln!
qui écrit dans le flux de sortie standard de la console (stdout
). Nous reparlerons destderr
et destdout
dans une section du chapitre 12.
Voici un exemple dans lequel nous nous intéressons à la valeur assignée au
champ largeur
, ainsi que la valeur de toute la structure rect1
:
#[derive(Debug)] struct Rectangle { largeur: u32, hauteur: u32, } fn main() { let echelle = 2; let rect1 = Rectangle { largeur: dbg!(30 * echelle), hauteur: 50, }; dbg!(&rect1); }
Nous pouvons placer le dbg!
autour de l'expression 30 * echelle
et, comme
dbg!
retourne la possession de la valeur issue de l'expression, le champ
largeur
va avoir la même valeur que si nous n'avions pas appelé dbg!
ici.
Nous ne voulons pas que dbg!
prenne possession de rect1
, donc nous donnons
une référence à rect1
lors de son prochain appel. Voici à quoi ressemble la
sortie de cet exemple :
$ cargo run
Compiling rectangles v0.1.0 (file:///projects/rectangles)
Finished dev [unoptimized + debuginfo] target(s) in 0.61s
Running `target/debug/rectangles`
[src/main.rs:10] 30 * echelle = 60
[src/main.rs:14] &rect1 = Rectangle {
largeur: 60,
hauteur: 50,
}
Nous pouvons constater que la première sortie provient de la ligne 10
de src/main.rs, où nous déboguons l'expression 30 * echelle
, et son résultat
est 60 (le formattage de Debug
pour les entiers est d'afficher uniquement sa
valeur). L'appel à dbg!
à la ligne 14 de src/main.rs affiche la valeur de
&rect1
, qui est une structure Rectangle
. La macro dbg!
peut être très
utile lorsque vous essayez de comprendre ce que fait votre code !
En plus du trait Debug
, Rust nous offre d'autres traits pour que nous
puissions les utiliser avec l'attribut derive
pour ajouter des comportements
utiles à nos propres types. Ces traits et leurs comportements sont listés à
l'annexe C. Nous expliquerons comment implémenter ces
traits avec des comportements personnalisés et comment créer vos propres traits
au chapitre 10. Il existe aussi de nombreux attributs autres que derive
; pour
en savoir plus, consultez la section “Attributs” de la référence de
Rust.
Notre fonction aire
est très spécifique : elle ne fait que calculer l'aire
d'un rectangle. Il serait utile de lier un peu plus ce comportement à notre
structure Rectangle
, puisque cela ne fonctionnera pas avec un autre type.
Voyons comment on peut continuer de remanier ce code en transformant la fonction
aire
en méthode aire
définie sur notre type Rectangle
.
La syntaxe des méthodes
Les méthodes sont similaires aux fonctions : nous les déclarons avec le
mot-clé fn
et un nom, elles peuvent avoir des paramètres et une valeur de
retour, et elles contiennent du code qui est exécuté quand on la méthode est
appellée depuis un autre endroit. Contrairement aux fonctions, les méthodes
diffèrent des fonctions parce qu'elles sont définies dans le contexte d'une
structure (ou d'une énumération ou d'un objet de trait, que nous aborderons
respectivement aux chapitres 6 et 17) et que leur premier paramètre est
toujours self
, un mot-clé qui représente l'instance de la structure sur
laquelle on appelle la méthode.
Définir des méthodes
Remplaçons la fonction aire
qui prend une instance de Rectangle
en paramètre
par une méthode aire
définie sur la structure Rectangle
, comme dans
l'encart 5-13.
Fichier : src/main.rs
#[derive(Debug)] struct Rectangle { largeur: u32, hauteur: u32, } impl Rectangle { fn aire(&self) -> u32 { self.largeur * self.hauteur } } fn main() { let rect1 = Rectangle { largeur: 30, hauteur: 50 }; println!( "L'aire du rectangle est de {} pixels carrés.", rect1.aire() ); }
Encart 5-13 : Définition d'une méthode aire
sur la
structure Rectangle
Pour définir la fonction dans le contexte de Rectangle
, nous démarrons un bloc
impl
(implémentation) pour Rectangle
. Tout ce qui sera dans ce bloc impl
sera lié au type Rectangle
. Puis nous déplaçons la fonction aire
entre les
accolades du impl
et nous remplaçons le premier paramètre (et dans notre cas,
le seul) par self
dans la signature et dans tout le corps. Dans main
, où
nous avons appelé la fonction aire
et passé rect1
en argument, nous pouvons
utiliser à la place la syntaxe des méthodes pour appeler la méthode aire
sur
notre instance de Rectangle
. La syntaxe des méthodes se place après
l'instance : on ajoute un point suivi du nom de la méthode et des parenthèses
contenant les arguments s'il y en a.
Dans la signature de aire
, nous utilisons &self
à la place de
rectangle: &Rectangle
. Le &self
est un raccourci pour self: &Self
. Au
sein d'un bloc impl
, le type de Self
est un alias pour le type sur lequel
porte le impl
. Les méthodes doivent avoir un paramètre self
du type Self
comme premier paramètre afin que Rust puisse vous permettre d'abréger en
renseignant uniquement self
en premier paramètre. Veuillez noter qu'il nous
faut quand même utiliser le &
devant le raccourci self
, pour indiquer que
cette méthode emprunte l'instance de Self
, comme nous l'avions fait pour
rectangle: &Rectangle
. Les méthodes peuvent prendre possession de self
,
emprunter self
de façon immuable comme nous l'avons fait ici, ou emprunter
self
de façon mutable, comme pour n'importe quel autre paramètre.
Nous avons choisi &self
ici pour la même raison que nous avions utilisé
&Rectangle
quand il s'agissait d'une fonction ; nous ne voulons pas en prendre
possession, et nous voulons seulement lire les données de la structure, pas les
modifier. Si nous voulions que la méthode modifie l'instance sur laquelle on
l'appelle, on utiliserait &mut self
comme premier paramètre. Il est rare
d'avoir une méthode qui prend possession de l'instance en utilisant uniquement
self
comme premier argument ; cette technique est généralement utilisée
lorsque la méthode transforme self
en quelque chose d'autre et que vous voulez
empêcher le code appelant d'utiliser l'instance d'origine après la
transformation.
En complément de l'application de la syntaxe des méthodes et ainsi de ne pas
être obligé de répéter le type de self
dans la signature de chaque méthode,
la principale raison d'utiliser les méthodes plutôt que de fonctions est pour
l'organisation. Nous avons mis tout ce qu'on pouvait faire avec une instance de
notre type dans un bloc impl
plutôt que d'imposer aux futurs utilisateurs de
notre code à rechercher les fonctionnalités de Rectangle
à divers endroits de
la bibliothèque que nous fournissons.
Notez que nous pourions faire en sorte qu'une méthode porte le même nom qu'un
des champs de la structure. Par exemple, nous pourions définir une méthode sur
Rectangle
qui s'appelle elle aussi largeur
:
Fichier : src/main.rs
#[derive(Debug)] struct Rectangle { largeur: u32, hauteur: u32, } impl Rectangle { fn largeur(&self) -> bool { self.largeur > 0 } } fn main() { let rect1 = Rectangle { largeur: 30, hauteur: 50, }; if rect1.largeur() { println!("Le rectangle a une largeur non nulle ; elle vaut {}", rect1.largeur); } }
Ici, nous avons défini la méthode largeur
pour qu'elle retourne true
si la
valeur dans le champ largeur
est supérieur ou égal à 0, et false
si la
valeur est 0 : nous pouvons utiliser un champ à l'intérieur d'une méthode du
même nom, pour n'importe quel usage. Dans le main
, lorsque nous ajoutons des
parenthèses après rect1.largeur
, Rust comprend que nous parlons de la méthode
largeur
. Lorsque nous n'utilisons pas les parenthèses, Rust sait nous parlons
du champ largeur
.
Souvent, mais pas toujours, lorsque nous appellons une méthode avec le même nom qu'un champ, nous voulons qu'elle renvoie uniquement la valeur de ce champ et ne fasse rien d'autre. Ces méthodes sont appelées des accesseurs, et Rust ne les implémente pas automatiquement pour les champs des structures comme le font certains langages. Les accesseurs sont utiles pour rendre le champ privé mais rendre la méthode publique et ainsi donner un accès en lecture seule à ce champ dans l'API publique de ce type. Nous développerons les notions de publique et privé et comment définir un champ ou une méthode publique ou privée au chapitre 7.
Où est l'opérateur
->
?En C et en C++, deux opérateurs différents sont utilisés pour appeler les méthodes : on utilise
.
si on appelle une méthode directement sur l'objet et->
si on appelle la méthode sur un pointeur vers l'objet et qu'il faut d'abord déréférencer le pointeur. En d'autres termes, siobjet
est un pointeur,objet->methode()
est similaire à(*objet).methode()
.Rust n'a pas d'équivalent à l'opérateur
->
; à la place, Rust a une fonctionnalité appelée référencement et déréférencement automatiques. L'appel de méthodes est l'un des rares endroits de Rust où on retrouve ce comportement.Voilà comment cela fonctionne : quand on appelle une méthode avec
objet.methode()
, Rust ajoute automatiquement le&
,&mut
ou*
pour queobjet
corresponde à la signature de la méthode. Autrement dit, ces deux lignes sont identiques :#![allow(unused)] fn main() { #[derive(Debug,Copy,Clone)] struct Point { x: f64, y: f64, } impl Point { fn distance(&self, autre: &Point) -> f64 { let x_carre = f64::powi(autre.x - self.x, 2); let y_carre = f64::powi(autre.y - self.y, 2); f64::sqrt(x_carre + y_carre) } } let p1 = Point { x: 0.0, y: 0.0 }; let p2 = Point { x: 5.0, y: 6.5 }; p1.distance(&p2); (&p1).distance(&p2); }
La première ligne semble bien plus propre. Ce comportement du (dé)référencement automatique fonctionne parce que les méthodes ont une cible claire : le type de
self
. Compte tenu du nom de la méthode et de l'instance sur laquelle elle s'applique, Rust peut déterminer de manière irréfutable si la méthode lit (&self
), modifie (&mut self
) ou consomme (self
) l'instance. Le fait que Rust rend implicite l'emprunt pour les instances sur lesquelles on appelle les méthodes améliore significativement l'ergonomie de la possession.
Les méthodes avec davantage de paramètres
Entraînons-nous à utiliser des méthodes en implémentant une seconde méthode sur
la structure Rectangle
. Cette fois-ci, nous voulons qu'une instance de
Rectangle
prenne une autre instance de Rectangle
et qu'on retourne true
si
le second Rectangle
peut se dessiner intégralement à l'intérieur de self
(le premier Rectangle
) ; sinon, on renverra false
. En d'autres termes, une
fois qu'on aura défini la méthode peut_contenir
, on veut pouvoir écrire le
programme de l'encart 5-14.
Fichier : src/main.rs
fn main() {
let rect1 = Rectangle {
largeur: 30,
hauteur: 50
};
let rect2 = Rectangle {
largeur: 10,
hauteur: 40
};
let rect3 = Rectangle {
largeur: 60,
hauteur: 45
};
println!("rect1 peut-il contenir rect2 ? {}", rect1.peut_contenir(&rect2));
println!("rect1 peut-il contenir rect3 ? {}", rect1.peut_contenir(&rect3));
}
Encart 5-14 : Utilisation de la méthode peut_contenir
qui reste à écrire
Et on s'attend à ce que le texte suivant s'affiche, puisque les deux dimensions
de rect2
sont plus petites que les dimensions de rect1
, mais rect3
est
plus large que rect1
:
rect1 peut-il contenir rect2 ? true
rect1 peut-il contenir rect3 ? false
Nous voulons définir une méthode, donc elle doit se trouver dans le bloc
impl Rectangle
. Le nom de la méthode sera peut_contenir
et elle prendra une
référence immuable vers un autre Rectangle
en paramètre. On peut déterminer le
type du paramètre en regardant le code qui appelle la méthode :
rect1.peut_contenir(&rect2)
prend en argument &rect2
, une référence immuable
vers rect2
, une instance de Rectangle
. Cela est logique puisque nous voulons
uniquement lire rect2
(plutôt que de la modifier, ce qui aurait nécessité une
référence mutable) et nous souhaitons que main
garde possession de rect2
pour qu'on puisse le réutiliser après avoir appelé la méthode peut_contenir
.
La valeur de retour de peut_contenir
sera un booléen et l'implémentation de la
méthode vérifiera si la largeur et la hauteur de self
sont respectivement plus
grandes que la largeur et la hauteur de l'autre Rectangle
. Ajoutons la
nouvelle méthode peut_contenir
dans le bloc impl
de l'encart 5-13, comme le
montre l'encart 5-15.
Fichier : src/main.rs
#[derive(Debug)] struct Rectangle { largeur: u32, hauteur: u32, } impl Rectangle { fn aire(&self) -> u32 { self.largeur * self.hauteur } fn peut_contenir(&self, autre: &Rectangle) -> bool { self.largeur > autre.largeur && self.hauteur > autre.hauteur } } fn main() { let rect1 = Rectangle { largeur: 30, hauteur: 50 }; let rect2 = Rectangle { largeur: 10, hauteur: 40 }; let rect3 = Rectangle { largeur: 60, hauteur: 45 }; println!("rect1 peut-il contenir rect2 ? {}", rect1.peut_contenir(&rect2)); println!("rect1 peut-il contenir rect3 ? {}", rect1.peut_contenir(&rect3)); }
Encart 5-15 : Implémentation de la méthode peut_contenir
sur Rectangle
qui prend une autre instance de Rectangle
en paramètre
Lorsque nous exécutons ce code avec la fonction main
de l'encart 5-14, nous
obtenons l'affichage attendu. Les méthodes peuvent prendre plusieurs paramètres
qu'on peut ajouter à la signature après le paramètre self
, et ces paramètres
fonctionnent de la même manière que les paramètres des fonctions.
Les fonctions associées
Toutes les fonctions définies dans un bloc impl
s'appellent des fonctions
associées car elles sont associées au type renseigné après le impl
. Nous
pouvons aussi y définir des fonctions associées qui n'ont pas de self
en
premier paramètre (et donc ce ne sont pas des méthodes) car elles n'ont pas
besoin d'une instance du type sur lequel elles travaillent. Nous avons déjà
utilisé une fonction comme celle-ci : la fonction String::from
qui est
définie sur le type String
.
Les fonctions associées qui ne ne sont pas des méthodes sont souvent utilisées
comme constructeurs qui vont retourner une nouvelle instance de la structure.
Par exemple, on pourrait écrire une fonction associée qui prend une unique
dimension en paramètre et l'utilise à la fois pour la largeur et pour la
hauteur, ce qui rend plus aisé la création d'un Rectangle
carré plutôt que
d'avoir à indiquer la même valeur deux fois :
Fichier : src/main.rs
#[derive(Debug)] struct Rectangle { largeur: u32, hauteur: u32, } impl Rectangle { fn carre(cote: u32) -> Rectangle { Rectangle { largeur: cote, hauteur: cote } } } fn main() { let mon_carre = Rectangle::carre(3); }
Pour appeler cette fonction associée, on utilise la syntaxe ::
avec le nom de
la structure ; let mon_carre = Rectangle::carre(3);
en est un exemple. Cette
fonction est cloisonnée dans l'espace de noms de la structure : la syntaxe ::
s'utilise aussi bien pour les fonctions associées que pour les espaces de noms
créés par des modules. Nous aborderons les modules au chapitre 7.
Plusieurs blocs impl
Chaque structure peut avoir plusieurs blocs impl
. Par exemple, l'encart 5-15
est équivalent au code de l'encart 5-16, où chaque méthode est dans son propre
bloc impl
.
#[derive(Debug)] struct Rectangle { largeur: u32, hauteur: u32, } impl Rectangle { fn aire(&self) -> u32 { self.largeur * self.hauteur } } impl Rectangle { fn peut_contenir(&self, autre: &Rectangle) -> bool { self.largeur > autre.largeur && self.hauteur > autre.hauteur } } fn main() { let rect1 = Rectangle { largeur: 30, hauteur: 50 }; let rect2 = Rectangle { largeur: 10, hauteur: 40 }; let rect3 = Rectangle { largeur: 60, hauteur: 45 }; println!("rect1 peut-il contenir rect2 ? {}", rect1.peut_contenir(&rect2)); println!("rect1 peut-il contenir rect3 ? {}", rect1.peut_contenir(&rect3)); }
Encart 5-16 : Réécriture de l'encart 5-15 en utilisant
plusieurs blocs impl
Il n'y a aucune raison de séparer ces méthodes dans plusieurs blocs impl
dans
notre exemple, mais c'est une syntaxe valide. Nous verrons un exemple de
l'utilité d'avoir plusieurs blocs impl
au chapitre 10, où nous aborderons les
types génériques et les traits.
Résumé
Les structures vous permettent de créer des types personnalisés significatifs
pour votre domaine. En utilisant des structures, on peut relier entre elles
des données associées et nommer chaque donnée pour rendre le code plus clair.
Dans des blocs impl
, vous pouvez définir des fonctions qui sont associées à
votre type, et les méthodes sont un genre de fonction associée qui vous permet
de renseigner le comportement que doivent suivre les instances de votre
structure.
Mais les structures ne sont pas le seul moyen de créer des types personnalisés : nous allons maintenant voir les énumérations de Rust, une fonctionnalité que vous pourrez bientôt ajouter à votre boîte à outils.
Les énumérations et le filtrage par motif
Dans ce chapitre, nous allons aborder les énumérations, aussi appelées
enums. Les énumérations vous permettent de définir un type en énumérant ses
variantes possibles. Pour commencer, nous allons définir et utiliser une
énumération pour voir comment une énumération peut donner du sens aux données.
Ensuite, nous examinerons une énumération particulièrement utile qui s'appelle
Option
et qui permet de décrire des situations où la valeur peut être soit
quelque chose, soit rien. Ensuite, nous regarderons comment le filtrage par
motif avec l'expression match
peut faciliter l'exécution de codes différents
pour chaque valeur d'une énumération. Enfin, nous analyserons pourquoi la
construction if let
est un autre outil commode et concis à disposition pour
traiter les énumérations dans votre code.
Les énumérations sont des fonctionnalités présentes dans de nombreux langages, mais leurs aptitudes varient d'un langage à l'autre. Les énumérations de Rust sont plus proches des types de données algébriques des langages fonctionnels, comme F#, OCaml et Haskell.
Définir une énumération
Les énumérations permettent de définir des types de données personnalisés de manière différente que vous l'avez fait avec les structures. Imaginons une situation que nous voudrions exprimer avec du code et regardons pourquoi les énumérations sont utiles et plus appropriées que les structures dans ce cas. Disons que nous avons besoin de travailler avec des adresses IP. Pour le moment, il existe deux normes principales pour les adresses IP : la version quatre et la version six. Comme ce seront les seules possibilités d'adresse IP que notre programme va rencontrer, nous pouvons énumérer toutes les variantes possibles, d'où vient le nom de l'énumération.
N'importe quelle adresse IP peut être soit une adresse en version quatre, soit en version six, mais pas les deux en même temps. Cette propriété des adresses IP est appropriée à la structure de données d'énumérations, car une valeur de l'énumération ne peut être qu'une de ses variantes. Les adresses en version quatre et six sont toujours fondamentalement des adresses IP, donc elles doivent être traitées comme étant du même type lorsque le code travaille avec des situations qui s'appliquent à n'importe quelle sorte d'adresse IP.
Nous pouvons exprimer ce concept dans le code en définissant une énumération
SorteAdresseIp
et en listant les différentes sortes possibles d'adresses IP
qu'elle peut avoir, V4
et V6
. Ce sont les variantes de l'énumération :
enum SorteAdresseIp { V4, V6, } fn main() { let quatre = SorteAdresseIp::V4; let six = SorteAdresseIp::V6; router(SorteAdresseIp::V4); router(SorteAdresseIp::V6); } fn router(sorte_ip: SorteAdresseIp) { }
SorteAdresseIp
est maintenant un type de données personnalisé que nous pouvons
utiliser n'importe où dans notre code.
Les valeurs d'énumérations
Nous pouvons créer des instances de chacune des deux variantes de
SorteAdresseIp
de cette manière :
enum SorteAdresseIp { V4, V6, } fn main() { let quatre = SorteAdresseIp::V4; let six = SorteAdresseIp::V6; router(SorteAdresseIp::V4); router(SorteAdresseIp::V6); } fn router(sorte_ip: SorteAdresseIp) { }
Remarquez que les variantes de l'énumération sont dans un espace de nom qui se
situe avant leur nom, et nous utilisons un double deux-points pour les séparer
tous les deux. C'est utile car maintenant les deux valeurs SorteAdresseIp::V4
et SorteAdresseIp::V6
sont du même type : SorteAdresseIp
. Ensuite, nous
pouvons, par exemple, définir une fonction qui accepte n'importe quelle
SorteAdresseIp
:
enum SorteAdresseIp { V4, V6, } fn main() { let quatre = SorteAdresseIp::V4; let six = SorteAdresseIp::V6; router(SorteAdresseIp::V4); router(SorteAdresseIp::V6); } fn router(sorte_ip: SorteAdresseIp) { }
Et nous pouvons appeler cette fonction avec chacune des variantes :
enum SorteAdresseIp { V4, V6, } fn main() { let quatre = SorteAdresseIp::V4; let six = SorteAdresseIp::V6; router(SorteAdresseIp::V4); router(SorteAdresseIp::V6); } fn router(sorte_ip: SorteAdresseIp) { }
L'utilisation des énumérations a encore plus d'avantages. En étudiant un peu plus notre type d'adresse IP, nous constatons que pour le moment, nous ne pouvons pas stocker la donnée de l'adresse IP ; nous savons seulement de quelle sorte elle est. Avec ce que vous avez appris au chapitre 5, vous pourriez être tenté de résoudre ce problème avec des structures comme dans l'encart 6-1.
fn main() { enum SorteAdresseIp { V4, V6, } struct AdresseIp { sorte: SorteAdresseIp, adresse: String, } let local = AdresseIp { sorte: SorteAdresseIp::V4, adresse: String::from("127.0.0.1"), }; let rebouclage = AdresseIp { sorte: SorteAdresseIp::V6, adresse: String::from("::1"), }; }
Encart 6-1 : Stockage de la donnée et de la variante de
SorteAdresseIp
d'une adresse IP en utilisant une struct
Ainsi, nous avons défini une structure AdresseIp
qui a deux champs : un champ
sorte
qui est du type SorteAdresseIp
(l'énumération que nous avons définie
précédemment) et un champ adresse
qui est du type String
. Nous avons deux
instances de cette structure. La première est local
, et a la valeur
SorteAdresseIp::V4
pour son champ sorte
, associé à la donnée d'adresse qui
est 127.0.0.1
. La seconde instance est rebouclage
. Elle a comme valeur de
champ sorte
l'autre variante de SorteAdresseIp
, V6
, et a l'adresse::1
qui lui est associée. Nous avons utilisé une structure pour relier ensemble la
sorte
et l'adresse
, donc maintenant la variante est liée à la valeur.
Cependant, suivre le même principe en utilisant uniquement une énumération est
plus concis : plutôt que d'utiliser une énumération dans une structure, nous
pouvons insérer directement la donnée dans chaque variante de l'énumération.
Cette nouvelle définition de l'énumération AdresseIp
indique que chacune des
variantes V4
et V6
auront des valeurs associées de type String
:
fn main() { enum AdresseIp { V4(String), V6(String), } let local = AdresseIp::V4(String::from("127.0.0.1")); let rebouclage = AdresseIp::V6(String::from("::1")); }
Nous relions les données de chaque variante directement à l'énumération, donc il
n'est pas nécessaire d'avoir une structure en plus. Ceci nous permet de voir
plus facilement un détail de fonctionnement des énumérations : le nom de chaque
variante d'énumération que nous définissons devient aussi une fonction qui
construit une instance de l'énumération. Ainsi, AdresseIp::V4()
est un appel
de fonction qui prend une String
en argument et qui retourne une instance du
type AdresseIp
. Nous obtenons automatiquement cette fonction de constructeur
qui est définie lorsque nous définissons l'énumération.
Il y a un autre avantage à utiliser une énumération plutôt qu'une structure :
chaque variante peut stocker des types différents, et aussi avoir une quantité
différente de données associées. Les adresses IP version quatre vont toujours
avoir quatre composantes numériques qui auront une valeur entre 0 et 255. Si
nous voulions stocker les adresses V4
avec quatre valeurs de type u8
mais
continuer à stocker les adresses V6
dans une String
, nous ne pourrions pas
le faire avec une structure. Les énumérations permettent de faire cela
facilement :
fn main() { enum AdresseIp { V4(u8, u8, u8, u8), V6(String), } let local = AdresseIp::V4(127, 0, 0, 1); let rebouclage = AdresseIp::V6(String::from("::1")); }
Nous avons vu différentes manières de définir des structures de données pour
enregistrer des adresses IP en version quatre et version six. Cependant, il
s'avère que vouloir stocker des adresses IP et identifier de quelle sorte elles
sont est si fréquent que la bibliothèque standard a une définition que nous
pouvons utiliser ! Analysons comment la bibliothèque
standard a défini IpAddr
(l'équivalent de notre AdresseIp
) : nous retrouvons
la même énumération et les variantes que nous avons définies et utilisées, mais
stocke les données d'adresse dans des variantes dans deux structures
différentes, qui sont définies chacune pour chaque variante :
#![allow(unused)] fn main() { struct Ipv4Addr { // -- code masqué ici -- } struct Ipv6Addr { // -- code masqué ici -- } enum IpAddr { V4(Ipv4Addr), V6(Ipv6Addr), } }
Ce code montre comment vous pouvez insérer n'importe quel type de données dans une variante d'énumération : des chaînes de caractères, des nombres ou des structures, par exemple. Vous pouvez même y intégrer d'autres énumérations ! Par ailleurs, les types de la bibliothèque standard ne sont parfois pas plus compliqués que ce que vous pourriez inventer.
Notez aussi que même si la bibliothèque standard embarque une définition de
IpAddr
, nous pouvons quand même créer et utiliser notre propre définition de
ce type sans avoir de conflit de nom car nous n'avons pas importé cette
définition de la bibliothèque standard dans la portée. Nous verrons plus en
détail comment importer les types dans la portée au chapitre 7.
Analysons un autre exemple d'une énumération dans l'encart 6-2 : celle-ci a une grande diversité de types dans ses variantes.
enum Message { Quitter, Deplacer { x: i32, y: i32 }, Ecrire(String), ChangerCouleur(i32, i32, i32), } fn main() {}
Encart 6-2 : Une énumération Message
dont chaque
variante stocke des valeurs de différents types et en différentes
quantités
Cette énumération a quatre variantes avec des types différents :
Quitter
n'a pas du tout de donnée associée.Deplacer
intègre une structure anonyme en son sein.Ecrire
intègre une seuleString
.ChangerCouleur
intègre trois valeurs de typei32
.
Définir une énumération avec des variantes comme celles dans l'encart 6-2
ressemble à la définition de différentes sortes de structures, sauf que
l'énumération n'utilise pas le mot-clé struct
et que toutes les variantes sont
regroupées ensemble sous le type Message
. Les structures suivantes peuvent
stocker les mêmes données que celles stockées par les variantes précédentes :
struct MessageQuitter; // une structure unité struct MessageDeplacer { x: i32, y: i32, } struct MessageEcrire(String); // une structure tuple struct MessageChangerCouleur(i32, i32, i32); // une structure tuple fn main() {}
Mais si nous utilisions les différentes structures, qui ont chacune leur propre
type, nous ne pourrions pas définir facilement une fonction qui prend en
paramètre toutes les sortes de messages, tel que nous pourrions le faire avec
l'énumération Message
que nous avons définie dans l'encart 6-2, qui est un
seul type.
Il y a un autre point commun entre les énumérations et les structures : tout
comme on peut définir des méthodes sur les structures en utilisant impl
, on
peut aussi définir des méthodes sur des énumérations. Voici une méthode appelée
appeler
que nous pouvons définir sur notre énumération Message
:
fn main() { enum Message { Quitter, Deplacer { x: i32, y: i32 }, Ecrire(String), ChangerCouleur(i32, i32, i32), } impl Message { fn appeler(&self) { // le corps de la méthode sera défini ici } } let m = Message::Ecrire(String::from("hello")); m.appeler(); }
Le corps de la méthode va utiliser self
pour obtenir la valeur sur laquelle
nous avons utilisé la méthode. Dans cet exemple, nous avons créé une variable
m
qui a la valeur Message::Ecrire(String::from("hello"))
, et cela sera ce
que self
aura comme valeur dans le corps de la méthode appeler
quand nous
lancerons m.appeler()
.
Regardons maintenant une autre énumération de la bibliothèque standard qui est
très utilisée et utile : Option
.
L'énumération Option
et ses avantages par rapport à la valeur null
Cette section étudie le cas de Option
, qui est une autre énumération définie
dans la bibliothèque standard. Le type Option
décrit un scénario très courant
où une valeur peut être soit quelque chose, soit rien du tout. Par exemple, si
vous demandez le premier élément dans une liste non vide, vous devriez obtenir
une valeur. Si vous demandez le premier élément d'une liste vide, vous ne
devriez rien obtenir. Exprimer ce concept avec le système de types implique que
le compilateur peut vérifier si vous avez géré tous les cas que vous pourriez
rencontrer ; cette fonctionnalité peut éviter des bogues qui sont très courants
dans d'autres langages de programmation.
La conception d'un langage de programmation est souvent pensée en fonction des fonctionnalités qu'on inclut, mais les fonctionnalités qu'on refuse sont elles aussi importantes. Rust n'a pas de fonctionnalité null qu'ont de nombreux langages. Null est une valeur qui signifie qu'il n'y a pas de valeur à cet endroit. Avec les langages qui utilisent null, les variables peuvent toujours être dans deux états : null ou non null.
Dans sa thèse de 2009 “Null References: The Billion Dollar Mistake” (les références nulles : l'erreur à un milliard de dollars), Tony Hoare, l'inventeur de null, a écrit ceci :
Je l'appelle mon erreur à un milliard de dollars. À cette époque, je concevais le premier système de type complet pour des références dans un langage orienté objet. Mon objectif était de garantir que toutes les utilisations des références soient totalement sûres, et soient vérifiées automatiquement par le compilateur. Mais je n'ai pas pu résister à la tentation d'inclure la référence nulle, simplement parce que c'était si simple à implémenter. Cela a conduit à d'innombrables erreurs, vulnérabilités, et pannes systèmes, qui ont probablement causé un milliard de dollars de dommages au cours des quarante dernières années.
Le problème avec les valeurs nulles, c'est que si vous essayez d'utiliser une valeur nulle comme si elle n'était pas nulle, vous obtiendrez une erreur d'une façon ou d'une autre. Comme cette propriété nulle ou non nulle est omniprésente, il est très facile de faire cette erreur.
Cependant, le concept que null essaye d'exprimer reste utile : une valeur nulle est une valeur qui est actuellement invalide ou absente pour une raison ou une autre.
Le problème ne vient pas vraiment du concept, mais de son implémentation. C'est
pourquoi Rust n'a pas de valeurs nulles, mais il a une énumération qui décrit le
concept d'une valeur qui peut être soit présente, soit absente. Cette
énumération est Option<T>
, et elle est définie dans la bibliothèque
standard comme ci-dessous :
#![allow(unused)] fn main() { enum Option<T> { None, Some(T), } }
L'énumération Option<T>
est tellement utile qu'elle est intégrée dans l'étape
préliminaire ; vous n'avez pas besoin de l'importer explicitement dans la
portée. Ses variantes sont aussi intégrées dans l'étape préliminaire : vous
pouvez utiliser directement Some
(quelque chose) et None
(rien) sans
les préfixer par Option::
. L'énumération Option<T>
reste une énumération
normale, et Some(T)
ainsi que None
sont toujours des variantes de type
Option<T>
.
La syntaxe <T>
est une fonctionnalité de Rust que nous n'avons pas encore
abordée. Il s'agit d'un paramètre de type générique, et nous verrons la
généricité plus en détail au chapitre 10. Pour le moment, dites-vous que ce
<T>
signifie que la variante Some
de l'énumération Option
peut stocker un
élément de donnée de n'importe quel type, et que chaque type concret qui est
utilisé à la place du T
transforme tout le type Option<T>
en un type
différent. Voici quelques exemples d'utilisation de valeurs de Option
pour
stocker des types de nombres et des types de chaînes de caractères :
fn main() { let un_nombre = Some(5); let une_chaine = Some("une chaîne"); let nombre_absent: Option<i32> = None; }
La variable un_nombre
est du type Option<i32>
. Mais la variable une_chaine
est du type Option<&str>
, qui est un tout autre type. Rust peut déduire ces
types car nous avons renseigné une valeur dans la variante Some
. Pour
nombre_absent
, Rust nécessite que nous annotions le type de tout le Option
:
le compilateur ne peut pas déduire le type qui devrait être stocké dans la
variante Some
à partir de la valeur None
. Ici, nous avons renseigné à Rust
que nous voulions que nombre_absent
soit du type Option<i32>
.
Lorsque nous avons une valeur Some
, nous savons que la valeur est présente et
que la valeur est stockée dans le Some
. Lorsque nous avons une valeur None
,
en quelque sorte, cela veut dire la même chose que null : nous n'avons pas une
valeur valide. Donc pourquoi obtenir Option<T>
est meilleur que d'avoir null ?
En bref, comme Option<T>
et T
(où T
représente n'importe quel type) sont
de types différents, le compilateur ne va pas nous autoriser à utiliser une
valeur Option<T>
comme si cela était bien une valeur valide. Par exemple, le
code suivant ne se compile pas car il essaye d'additionner un i8
et une
Option<i8>
:
fn main() {
let x: i8 = 5;
let y: Option<i8> = Some(5);
let somme = x + y;
}
Si nous lançons ce code, nous aurons un message d'erreur comme celui-ci :
$ cargo run
Compiling enums v0.1.0 (file:///projects/enums)
error[E0277]: cannot add `Option<i8>` to `i8`
--> src/main.rs:5:17
|
5 | let somme = x + y;
| ^ no implementation for `i8 + Option<i8>`
|
= help: the trait `Add<Option<i8>>` is not implemented for `i8`
For more information about this error, try `rustc --explain E0277`.
error: could not compile `enums` due to previous error
Intense ! Effectivement, ce message d'erreur signifie que Rust ne comprend pas
comment additionner un i8
et une Option<i8>
, car ils sont de types
différents. Quand nous avons une valeur d'un type comme i8
avec Rust, le
compilateur va s'assurer que nous avons toujours une valeur valide. Nous pouvons
continuer en toute confiance sans avoir à vérifier que cette valeur n'est pas
nulle avant de l'utiliser. Ce n'est que lorsque nous avons une Option<i8>
(ou
tout autre type de valeur avec lequel nous travaillons) que nous devons nous
inquiéter de ne pas avoir de valeur, et le compilateur va s'assurer que nous
gérons ce cas avant d'utiliser la valeur.
Autrement dit, vous devez convertir une Option<T>
en T
pour pouvoir faire
avec elle des opérations du type T
. Généralement, cela permet de résoudre l'un
des problèmes les plus courants avec null : supposer qu'une valeur n'est pas
nulle alors qu'en réalité, elle l'est.
Eliminer le risque que des valeurs nulles puissent être mal gérées vous aide à
être plus confiant en votre code. Pour avoir une valeur qui peut
potentiellement être nulle, vous devez l'indiquer explicitement en déclarant
que le type de cette valeur est Option<T>
. Ensuite, quand vous utiliserez
cette valeur, il vous faudra gérer explicitement le cas où cette valeur est
nulle. Si vous utilisez une valeur qui n'est pas une Option<T>
, alors vous
pouvez considérer que cette valeur ne sera jamais nulle sans prendre de
risques. Il s'agit d'un choix de conception délibéré de Rust pour limiter
l'omniprésence de null et augmenter la sécurité du code en Rust.
Donc, comment récupérer la valeur de type T
d'une variante Some
quand vous
avez une valeur de type Option<T>
afin de l'utiliser ? L'énumération
Option<T>
a un large choix de méthodes qui sont plus ou moins utiles selon les
cas ; vous pouvez les découvrir dans sa documentation. Se
familiariser avec les méthodes de Option<T>
peut être très utile dans votre
aventure avec Rust.
De manière générale, pour pouvoir utiliser une valeur de Option<T>
, votre code
doit gérer chaque variante. On veut que du code soit exécuté uniquement quand on
a une valeur Some(T)
, et que ce code soit autorisé à utiliser la valeur de
type T
à l'intérieur. On veut aussi qu'un autre code soit exécuté si on a une
valeur None
, et ce code n'aura pas de valeur de type T
de disponible.
L'expression match
est une structure de contrôle qui fait bien ceci
lorsqu'elle est utilisée avec les énumérations : elle va exécuter du code
différent en fonction de quelle variante de l'énumération elle obtient, et ce
code pourra utiliser la donnée présente dans la valeur correspondante.
La structure de contrôle de flux match
Rust a une structure de contrôle de flux très puissant appelé match
qui vous
permet de comparer une valeur avec une série de motifs et d'exécuter du code en
fonction du motif qui correspond. Les motifs peuvent être constitués de valeurs
littérales, de noms de variables, de jokers, parmi tant d'autres ; le
chapitre 18 va couvrir tous les différents types de motifs et ce qu'ils font. Ce
qui fait la puissance de match
est l'expressivité des motifs et le fait que le
compilateur vérifie que tous les cas possibles sont bien gérés.
Considérez l'expression match
comme une machine à trier les pièces de
monnaie : les pièces descendent le long d'une piste avec des trous de tailles
différentes, et chaque pièce tombe dans le premier trou à sa taille qu'elle
rencontre. De manière similaire, les valeurs parcourent tous les motifs dans un
match
, et au premier motif auquel la valeur “correspond”, la valeur va
descendre dans le bloc de code correspondant afin d'être utilisée pendant son
exécution. En parlant des pièces, utilisons-les avec un exemple qui utilise
match
! Nous pouvons écrire une fonction qui prend en paramètre une pièce
inconnue des États-Unis d'Amérique et qui peut, de la même manière qu'une
machine à trier, déterminer quelle pièce c'est et retourner sa valeur en
centimes, comme ci-dessous dans l'encart 6-3.
enum PieceUs { Penny, Nickel, Dime, Quarter, } fn valeur_en_centimes(piece: PieceUs) -> u8 { match piece { PieceUs::Penny => 1, PieceUs::Nickel => 5, PieceUs::Dime => 10, PieceUs::Quarter => 25, } } fn main() {}
Encart 6-3 : Une énumération et une expression match
qui
trie les variantes de l'énumération dans ses motifs
Décomposons le match
dans la fonction valeur_en_centimes
. En premier lieu,
nous utilisons le mot-clé match
suivi par une expression, qui dans notre cas
est la valeur de piece
. Cela ressemble beaucoup à une expression utilisée avec
if
, mais il y a une grosse différence : avec if
, l'expression doit retourner
une valeur booléenne, mais ici, elle retourne n'importe quel type. Dans cet
exemple, piece
est de type PieceUs
, qui est l'énumération que nous avons
définie à la première ligne.
Ensuite, nous avons les branches du match
. Une branche a deux parties : un
motif et du code. La première branche a ici pour motif la valeur
PieceUs::Penny
et ensuite l'opérateur =>
qui sépare le motif et le code à
exécuter. Le code dans ce cas est uniquement la valeur 1
. Chaque branche est
séparée de la suivante par une virgule.
Lorsqu'une expression match
est exécutée, elle compare la valeur de piece
avec le motif de chaque branche, dans l'ordre. Si un motif correspond à la
valeur, le code correspondant à ce motif est alors exécuté. Si ce motif ne
correspond pas à la valeur, l'exécution passe à la prochaine branche, un peu
comme dans une machine de tri de pièces. Nous pouvons avoir autant de branches
que nécessaire : dans l'encart 6-3, notre match
a quatre branches.
Le code correspondant à chaque branche est une expression, et la valeur qui
résulte de l'expression dans la branche correspondante est la valeur qui sera
retournée par l'expression match
.
Habituellement, nous n'utilisons pas les accolades si le code de la branche
correspondante est court, comme c'est le cas dans l'encart 6-3 où chaque
branche retourne simplement une valeur. Si vous voulez exécuter plusieurs
lignes de code dans une branche d'un match
, vous devez utiliser les
accolades. Par exemple, le code suivant va afficher “Un centime
porte-bonheur !” à chaque fois que la méthode est appelée avec une valeur
PieceUs::Penny
, mais va continuer à retourner la dernière valeur du
bloc, 1
:
enum PieceUs { Penny, Nickel, Dime, Quarter, } fn valeur_en_centimes(piece: PieceUs) -> u8 { match piece { PieceUs::Penny => { println!("Un centime porte-bonheur !"); 1 } PieceUs::Nickel => 5, PieceUs::Dime => 10, PieceUs::Quarter => 25, } } fn main() {}
Des motifs reliés à des valeurs
Une autre fonctionnalité intéressante des branches de match
est qu'elles
peuvent se lier aux valeurs qui correspondent au motif. C'est ainsi que nous
pouvons extraire des valeurs d'une variante d'énumération.
En guise d'exemple, changeons une de nos variantes d'énumération pour stocker
une donnée à l'intérieur. Entre 1999 et 2008, les États-Unis d'Amérique ont
frappé un côté des quarters (pièces de 25 centimes) avec des dessins
différents pour chacun des 50 États. Les autres pièces n'ont pas eu de dessins
d'États, donc seul le quarter a cette valeur en plus. Nous pouvons ajouter
cette information à notre enum
en changeant la variante Quarter
pour y
ajouter une valeur EtatUs
qui y sera stockée à l'intérieur, comme nous
l'avons fait dans l'encart 6-4.
#[derive(Debug)] // pour pouvoir afficher l'État enum EtatUs { Alabama, Alaska, // -- partie masquée ici -- } enum PieceUs { Penny, Nickel, Dime, Quarter(EtatUs), } fn main() {}
Encart 6-4 : Une énumération PieceUs
dans laquelle la
variante Quarter
stocke en plus une valeur de type EtatUs
Imaginons qu'un de vos amis essaye de collectionner tous les quarters des 50 États. Pendant que nous trions notre monnaie en vrac par type de pièce, nous mentionnerons aussi le nom de l'État correspondant à chaque quarter de sorte que si notre ami ne l'a pas, il puisse l'ajouter à sa collection.
Dans l'expression match
de ce code, nous avons ajouté une variable etat
au
motif qui correspond à la variante PieceUs::Quarter
. Quand on aura une
correspondance PieceUs::Quarter
, la variable etat
sera liée à la valeur de
l'État de cette pièce. Ensuite, nous pourrons utiliser etat
dans le code de
cette branche, comme ceci :
#[derive(Debug)] enum EtatUs { Alabama, Alaska, // -- partie masquée ici -- } enum PieceUs { Penny, Nickel, Dime, Quarter(EtatUs), } fn valeur_en_centimes(piece: PieceUs) -> u8 { match piece { PieceUs::Penny => 1, PieceUs::Nickel => 5, PieceUs::Dime => 10, PieceUs::Quarter(etat) => { println!("Il s'agit d'un quarter de l'État de {:?} !", etat); 25 }, } } fn main() { valeur_en_centimes(PieceUs::Quarter(EtatUs::Alaska)); }
Si nous appelons valeur_en_centimes(PieceUs::Quarter(EtatUs::Alaska))
, piece
vaudra PieceUs::Quarter(EtatUs::Alaska)
. Quand nous comparons cette valeur
avec toutes les branches du match
, aucune d'entre elles ne correspondra
jusqu'à ce qu'on arrive à PieceUs::Quarter(etat)
. À partir de ce moment, la
variable etat
aura la valeur EtatUs::Alaska
. Nous pouvons alors utiliser
cette variable dans l'expression println!
, ce qui nous permet d'afficher la
valeur de l'État à l'intérieur de la variante Quarter
de l'énumération
PieceUs
.
Utiliser match
avec Option<T>
Dans la section précédente, nous voulions obtenir la valeur interne T
dans le
cas de Some
lorsqu'on utilisait Option<T>
; nous pouvons aussi gérer les
Option<T>
en utilisant match
comme nous l'avons fait avec l'énumération
PieceUs
! Au lieu de comparer des pièces, nous allons comparer les variantes
de Option<T>
, mais la façon d'utiliser l'expression match
reste la même.
Disons que nous voulons écrire une fonction qui prend une Option<i32>
et qui,
s'il y a une valeur à l'intérieur, ajoute 1 à cette valeur. S'il n'y a pas de
valeur à l'intérieur, la fonction retournera la valeur None
et ne va rien
faire de plus.
Cette fonction est très facile à écrire, grâce à match
, et ressemblera à
l'encart 6-5.
fn main() { fn plus_un(x: Option<i32>) -> Option<i32> { match x { None => None, Some(i) => Some(i + 1), } } let cinq = Some(5); let six = plus_un(cinq); let none = plus_un(None); }
Encart 6-5 : Une fonction qui utilise une expression
match
sur une Option<i32>
Examinons la première exécution de plus_un
en détail. Lorsque nous appelons
plus_un(cinq)
, la variable x
dans le corps de plus_un
aura la valeur
Some(5)
. Ensuite, nous comparons cela à chaque branche du match
.
fn main() {
fn plus_un(x: Option<i32>) -> Option<i32> {
match x {
None => None,
Some(i) => Some(i + 1),
}
}
let cinq = Some(5);
let six = plus_un(cinq);
let none = plus_un(None);
}
La valeur Some(5)
ne correspond pas au motif None
, donc nous continuons à la
branche suivante.
fn main() {
fn plus_un(x: Option<i32>) -> Option<i32> {
match x {
None => None,
Some(i) => Some(i + 1),
}
}
let cinq = Some(5);
let six = plus_un(cinq);
let none = plus_un(None);
}
Est-ce que Some(5)
correspond au motif Some(i)
? Bien sûr ! Nous avons la
même variante. Le i
va prendre la valeur contenue dans le Some
, donc i
prend la valeur 5
. Le code dans la branche du match
est exécuté, donc nous
ajoutons 1 à la valeur de i
et nous créons une nouvelle valeur Some
avec
notre résultat 6
à l'intérieur.
Maintenant, regardons le second appel à plus_un
dans l'encart 6-5, où x
vaut
None
. Nous entrons dans le match
et nous le comparons à la première branche.
fn main() {
fn plus_un(x: Option<i32>) -> Option<i32> {
match x {
None => None,
Some(i) => Some(i + 1),
}
}
let cinq = Some(5);
let six = plus_un(cinq);
let none = plus_un(None);
}
Cela correspond ! Il n'y a pas de valeur à additionner, donc le programme
s'arrête et retourne la valeur None
qui est dans le côté droit du =>
. Comme
la première branche correspond, les autres branches ne sont pas comparées.
La combinaison de match
et des énumérations est utile dans de nombreuses
situations. Vous allez revoir de nombreuses fois ce schéma dans du code Rust :
utiliser match
sur une énumération, récupérer la valeur qu'elle renferme, et
exécuter du code en fonction de sa valeur. C'est un peu délicat au début, mais
une fois que vous vous y êtes habitué, vous regretterez de ne pas l'avoir dans
les autres langages. Cela devient toujours l'outil préféré de ses utilisateurs.
Les match
sont toujours exhaustifs
Il y a un autre point de match
que nous devons aborder. Examinez cette version
de notre fonction plus_un
qui a un bogue et ne va pas se compiler :
fn main() {
fn plus_un(x: Option<i32>) -> Option<i32> {
match x {
Some(i) => Some(i + 1),
}
}
let cinq = Some(5);
let six = plus_un(cinq);
let none = plus_un(None);
}
Nous n'avons pas géré le cas du None
, donc ce code va générer un bogue.
Heureusement, c'est un bogue que Rust sait gérer. Si nous essayons de compiler
ce code, nous allons obtenir cette erreur :
$ cargo run
Compiling enums v0.1.0 (file:///projects/enums)
error[E0004]: non-exhaustive patterns: `None` not covered
--> src/main.rs:3:15
|
3 | match x {
| ^ pattern `None` not covered
|
= help: ensure that all possible cases are being handled, possibly by adding wildcards or more match arms
= note: the matched value is of type `Option<i32>`
For more information about this error, try `rustc --explain E0004`.
error: could not compile `enums` due to previous error
Rust sait que nous n'avons pas couvert toutes les possibilités et sait même quel
motif nous avons oublié ! Les match
de Rust sont exhaustifs : nous devons
traiter toutes les possibilités afin que le code soit valide. C'est notamment le
cas avec Option<T>
: quand Rust nous empêche d'oublier de gérer explicitement
le cas de None
, il nous protège d'une situation où nous supposons que nous
avons une valeur alors que nous pourrions avoir null, ce qui rend impossible
l'erreur à un milliard de dollars que nous avons vue précédemment.
Les motifs génériques et le motif _
En utilisant les énumérations, nous pouvons aussi appliquer des actions
spéciales pour certaines valeurs précises, mais une action par défaut pour
toutes les autres valeurs. Imaginons que nous implémentons un jeu dans lequel,
si vous obtenez une valeur de 3 sur un lancé de dé, votre joueur ne se déplace
pas, mais à la place il obtient un nouveau chapeau fataisie. Si vous obtenez
un 7, votre joueur perd son chapeau fantaisie. Pour toutes les autres valeurs,
votre joueur se déplace de ce nombre de cases sur le plateau du jeu. Voici un
match
qui implémente cette logique, avec le résultat du lancé de dé codé en
dur plutôt qu'issu d'une génération aléatoire, et toute la logique des autres
fonctions sont des corps vides car leur implémentation n'est pas le sujet de
cet exemple :
fn main() { let jete_de_de = 9; match jete_de_de { 3 => ajouter_chapeau_fantaisie(), 7 => enleve_chapeau_fantaisie(), autre => deplace_joueur(autre), } fn ajouter_chapeau_fantaisie() {} fn enleve_chapeau_fantaisie() {} fn deplace_joueur(nombre_cases: u8) {} }
Dans les deux premières branches, les motifs sont les valeurs litérales 3 et 7.
La dernière branche couvre toutes les autres valeurs possibles, le motif est la
variable autre
. Le code qui s'exécute pour la branche autre
utilise la
variable en la passant dans la fonction deplacer_joueur
.
Ce code se compile, même si nous n'avons pas listé toutes les valeurs possibles
qu'un u8
puisse avoir, car le dernier motif va correspondre à toutes les
valeurs qui ne sont pas spécifiquement listés. Ce motif générique répond à la
condition qu'un match
doive être exhaustif. Notez que nous devons placer la
branche avec le motif générique en tout dernier, car les motifs sont évalués
dans l'ordre. Rust va nous prévenir si nous ajoutons des branches après un motif
générique car toutes ces autres branches ne seront jamais vérifiées !
Rust a aussi un motif que nous pouvons utiliser lorsque nous n'avons pas besoin
d'utiliser la valeur dans le motif générique : _
, qui est un motif spécial
qui vérifie n'importe quelle valeur et ne récupère pas cette valeur. Ceci
indique à Rust que nous n'allons pas utiliser la valeur, donc Rust ne va pas
nous prévenir qu'il y a une variable non utilisée.
Changeons les règles du jeu pour que si nous obtenions autre chose qu'un 3 ou
un 7, nous jetions à nouveau le dé. Nous n'avons pas besoin d'utiliser la valeur
dans ce cas, donc nous pouvons changer notre code pour utiliser _
au lieu de
la variable autre
:
fn main() { let jete_de_de = 9; match jete_de_de { 3 => ajouter_chapeau_fantaisie(), 7 => enleve_chapeau_fantaisie(), _ => relancer(), } fn ajouter_chapeau_fantaisie() {} fn enleve_chapeau_fantaisie() {} fn relancer() {} }
Cet exemple répond bien aux critères d'exhaustivité car nous ignorons explicitement toutes les autres valeurs dans la dernière branche ; nous n'avons rien oublié.
Si nous changeons à nouveau les règles du jeu, afin que rien se passe si vous
obtenez autre chose qu'un 3 ou un 7, nous pouvons exprimer cela en utilisant la
valeur unité (le type tuple vide que nous avons cité dans une section
précédente) dans le code de la branche _
:
fn main() { let jete_de_de = 9; match jete_de_de { 3 => ajouter_chapeau_fantaisie(), 7 => enleve_chapeau_fantaisie(), _ => (), } fn ajouter_chapeau_fantaisie() {} fn enleve_chapeau_fantaisie() {} }
Ici, nous indiquons explicitement à Rust que nous n'allons pas utiliser d'autres valeurs qui ne correspondent pas à un motif des branches antérieures, et nous ne voulons lancer aucun code dans ce cas.
Il existe aussi d'autres motifs que nous allons voir dans le
chapitre 18. Pour l'instant, nous allons voir
l'autre syntaxe if let
, qui peut se rendre utile dans des cas où l'expression
match
est trop verbeuse.
Une structure de contrôle concise : if let
La syntaxe if let
vous permet de combiner if
et let
afin de gérer les
valeurs qui correspondent à un motif donné, tout en ignorant les autres.
Imaginons le programme dans l'encart 6-6 qui fait un match
sur la valeur
Option<u8>
de la variable config_max
mais n'a besoin d'exécuter du code que
si la valeur est la variante Some
.
fn main() { let une_valeur_u8 = Some(3u8); match une_valeur_u8 { Some(max) => println!("Le maximum est réglé sur {}", max), _ => (), } }
Encart 6-6 : Un match
qui n'exécute du code que si la
valeur est Some
Si la valeur est un Some
, nous affichons la valeur dans la variante Some
en
associant la valeur à la variable max
dans le motif. Nous ne voulons rien
faire avec la valeur None
. Pour satisfaire l'expression match
, nous devons
ajouter _ => ()
après avoir géré une seule variante, ce qui est du code
inutile.
À la place, nous pourrions écrire le même programme de manière plus concise en
utilisant if let
. Le code suivant se comporte comme le match
de l'encart
6-6 :
fn main() { let une_valeur_u8 = Some(3u8); if let Some(max) = une_valeur_u8 { println!("Le maximum est réglé sur {}", max); } }
La syntaxe if let
prend un motif et une expression séparés par un signe égal.
Elle fonctionne de la même manière qu'un match
où l'expression est donnée au
match
et où le motif est sa première branche. Dans ce cas, le motif est
Some(max)
, et le max
est associé à la valeur dans le Some
. Nous pouvons
ensuite utiliser max
dans le corps du bloc if let
de la même manière que
nous avons utilisé max
dans la branche correspondante au match
. Le code dans
le bloc if let
n'est pas exécuté si la valeur ne correspond pas au motif.
Utiliser if let
permet d'écrire moins de code, et de moins l'indenter.
Cependant, vous perdez la vérification de l'exhaustivité qu'assure le match
.
Choisir entre match
et if let
dépend de la situation : à vous de choisir
s'il vaut mieux être concis ou appliquer une vérification exhaustive.
Autrement dit, vous pouvez considérer le if let
comme du sucre syntaxique pour
un match
qui exécute du code uniquement quand la valeur correspond à un motif
donné et ignore toutes les autres valeurs.
Nous pouvons joindre un else
à un if let
. Le bloc de code qui va dans le
else
est le même que le bloc de code qui va dans le cas _
avec l'expression
match
. Souvenez-vous de la définition de l'énumération PieceUs
de l'encart
6-4, où la variante Quarter
stockait aussi une valeur EtatUs
. Si nous
voulions compter toutes les pièces qui ne sont pas des quarters que nous
voyons passer, tout en affichant l'État des quarters, nous pourrions le faire
avec une expression match
comme ceci :
#[derive(Debug)] enum EtatUs { Alabama, Alaska, // -- partie masquée ici -- } enum PieceUs { Penny, Nickel, Dime, Quarter(EtatUs), } fn main() { let piece = PieceUs::Penny; let mut compteur = 0; match piece { PieceUs::Quarter(etat) => println!("Il s'agit d'un quarter de l'État de {:?} !", etat), _ => compteur += 1, } }
Ou nous pourrions utiliser une expression if let
/else
comme ceci :
#[derive(Debug)] enum EtatUs { Alabama, Alaska, // -- partie masquée ici -- } enum PieceUs { Penny, Nickel, Dime, Quarter(EtatUs), } fn main() { let piece = PieceUs::Penny; let mut compteur = 0; if let PieceUs::Quarter(etat) = piece { println!("Il s'agit d'un quarter de l'État de {:?} !", etat); } else { compteur += 1; } }
Si vous trouvez que votre programme est alourdi par l'utilisation d'un match
,
souvenez-vous que if let
est aussi présent dans votre boite à outils Rust.
Résumé
Nous avons désormais appris comment utiliser les énumérations pour créer des
types personnalisés qui peuvent faire partie d'un jeu de valeurs recensées. Nous
avons montré comment le type Option<T>
de la bibliothèque standard vous aide
à utiliser le système de types pour éviter les erreurs. Lorsque les valeurs
d'énumération contiennent des données, vous pouvez utiliser match
ou if let
pour extraire et utiliser ces valeurs, à choisir en fonction du nombre de cas
que vous voulez gérer.
Vos programmes Rust peuvent maintenant décrire des concepts métier à l'aide de structures et d'énumérations. Créer des types personnalisés à utiliser dans votre API assure la sécurité des types : le compilateur s'assurera que vos fonctions ne reçoivent que des valeurs du type attendu.
Afin de fournir une API bien organisée, simple à utiliser et qui n'expose que ce dont vos utilisateurs auront besoin, découvrons maintenant les modules de Rust.
Gérer des projets grandissants avec les paquets, crates et modules
Lorsque vous commencerez à écrire des gros programmes, organiser votre code va devenir important car vous ne pourrez plus garder en tête l'intégralité de votre programme. En regroupant des fonctionnalités qui ont des points communs et en les séparant des autres fonctionnalités, vous clarifiez l'endroit où trouver le code qui implémente une fonctionnalité spécifique afin de pouvoir le relire ou le modifier.
Les programmes que nous avons écrits jusqu'à présent étaient dans un module au sein d'un seul fichier. À mesure que le projet grandit, vous pouvez organiser votre code en le découpant en plusieurs modules et ensuite en plusieurs fichiers. Un paquet peut contenir plusieurs crates binaires et accessoirement une crate de bibliothèque. À mesure qu'un paquet grandit, vous pouvez en extraire des parties dans des crates séparées qui deviennent des dépendances externes. Ce chapitre va aborder toutes ces techniques. Pour un projet de très grande envergure qui a des paquets interconnectés qui évoluent ensemble, Cargo propose les espaces de travail, que nous découvrirons dans une section du chapitre 14.
En plus de regrouper des fonctionnalités, les modules vous permettent d'encapsuler les détails de l'implémentation d'une opération : vous pouvez écrire du code puis l'utiliser comme une abstraction à travers l'interface de programmation publique (API) du code sans se soucier de connaître les détails de son implémentation. La façon dont vous écrivez votre code définit quelles parties sont publiques et donc utilisables par un autre code, et quelles parties sont des détails d'implémentation privés dont vous vous réservez le droit de modifier. C'est un autre moyen de limiter le nombre d'éléments de l'API pour celui qui l'utilise.
Un concept qui lui est associé est la portée : le contexte dans lequel le code est écrit a un jeu de noms qui sont définis comme “dans la portée”. Quand ils lisent, écrivent et compilent du code, les développeurs et les compilateurs ont besoin de savoir ce que tel nom désigne à tel endroit, et s'il s'agit d'une variable, d'une fonction, d'une structure, d'une énumération, d'un module, d'une constante, etc. Vous pouvez créer des portées et décider quels noms sont dans la portée ou non. Vous ne pouvez pas avoir deux entités avec le même nom dans la même portée ; cependant, des outils existent pour résoudre les conflits de nom.
Rust a de nombreuses fonctionnalités qui vous permettent de gérer l'organisation de votre code, grâce à ce que la communauté Rust appelle le système de modules. Ce système définit quels sont les éléments qui sont accessibles depuis l'extérieur de la bibliothèque (notion de privé ou public), ainsi que leur portée. Ces fonctionnalités comprennent :
- les paquets : une fonctionnalité de Cargo qui vous permet de compiler, tester, et partager des crates ;
- les crates : une arborescence de modules qui fournit une bibliothèque ou un exécutable ;
- les modules : utilisés avec le mot-clé
use
, ils vous permettent de contrôler l'organisation, la portée et la visibilité des chemins ; - les chemins : une façon de nommer un élément, comme une structure, une fonction ou un module.
Dans ce chapitre, nous allons découvrir ces fonctionnalités, voir comment elles interagissent, et expliquer comment les utiliser pour gérer les portées. À l'issue de ce chapitre, vous aurez de solides connaissances sur le système de modules et vous pourrez travailler avec les portées comme un pro !
Les paquets et les crates
La première partie du système de modules que nous allons aborder concerne les paquets et les crates. Une crate est un binaire ou une bibliothèque. Pour la compiler, le compilateur Rust part d'un fichier source, la racine de la crate, à partir duquel est alors créé le module racine de votre crate (nous verrons les modules plus en détail dans la section suivante). Un paquet se compose d'une ou plusieurs crates qui fournissent un ensemble de fonctionnalités. Un paquet contient un fichier Cargo.toml qui décrit comment construire ces crates.
Il y a plusieurs règles qui déterminent ce qu'un paquet peut contenir. Il doit contenir au maximum une seule crate de bibliothèque. Il peut contenir autant de crates binaires que vous le souhaitez, mais il doit contenir au moins une crate (que ce soit une bibliothèque ou un binaire).
Découvrons ce qui se passe quand nous créons un paquet. D'abord, nous utilisons
la commande cargo new
:
$ cargo new mon-projet
Created binary (application) `mon-projet` package
$ ls mon-projet
Cargo.toml
src
$ ls mon-projet/src
main.rs
Lorsque nous avons saisi la commande, Cargo a créé un fichier Cargo.toml, qui
définit un paquet. Si on regarde le contenu de Cargo.toml, le fichier
src/main.rs n'est pas mentionné car Cargo obéit à une convention selon
laquelle src/main.rs est la racine de la crate binaire portant le même
nom que le paquet. De la même façon, Cargo sait que si le dossier du paquet
contient src/lib.rs, alors le paquet contient une crate de bibliothèque qui a
le même nom que le paquet, et que src/lib.rs est sa racine. Cargo transmet les
fichiers de la crate racine à rustc
pour compiler la bibliothèque ou le
binaire.
Dans notre cas, nous avons un paquet qui contient uniquement src/main.rs, ce
qui veut dire qu'il contient uniquement une crate binaire qui s'appelle
mon-projet
. Si un paquet contient src/main.rs et src/lib.rs, il a deux
crates : une binaire et une bibliothèque, chacune avec le même nom que le
paquet. Un paquet peut avoir plusieurs crates binaires en ajoutant des fichiers
dans le répertoire src/bin : chaque fichier sera une crate séparée.
Une crate regroupe plusieurs fonctionnalités associées ensemble dans une portée
afin que les fonctionnalités soient faciles à partager entre plusieurs projets.
Par exemple, la crate rand
que nous avons utilisée dans
le chapitre 2 nous permet de générer des nombres
aléatoires. Nous pouvons utiliser cette fonctionnalité dans notre propre projet
en important la crate rand
dans la portée de notre projet. Toutes les
fonctionnalités fournies par la crate rand
sont accessibles via le nom de la
crate, rand
.
Ranger une fonctionnalité d'une crate dans sa propre portée clarifie si une
fonctionnalité précise est définie dans notre crate ou dans la crate rand
et
évite ainsi de potentiels conflits. Par exemple, la crate rand
fournit un
trait qui s'appelle Rng
. Nous pouvons nous aussi définir une structure qui
s'appelle Rng
dans notre propre crate. Comme les fonctionnalités des crates
sont dans la portée de leur propre espace de nom, quand nous ajoutons rand
en
dépendance, il n'y a pas d'ambiguïté pour le compilateur sur le nom Rng
. Dans
notre crate, il se réfère au struct Rng
que nous avons défini. Nous accédons
au trait Rng
de la crate rand
via rand::Rng
.
Poursuivons et parlons maintenant du système de modules !
Définir des modules pour gérer la portée et la visibilité
Dans cette section, nous allons aborder les modules et les autres outils du
système de modules, à savoir les chemins qui nous permettent de nommer les
éléments ; l'utilisation du mot-clé use
qui importe un chemin dans la portée ;
et le mot-clé pub
qui rend publics les éléments. Nous verrons aussi le mot-clé
as
, les paquets externes, et l'opérateur glob. Pour commencer, penchons-nous
sur les modules !
Les modules nous permettent de regrouper le code d'une crate pour une meilleure lisibilité et pour la facilité de réutilisation. Les modules permettent aussi de gérer la visibilité des éléments, qui précise si un élément peut être utilisé à l'extérieur du module (c'est public) ou s'il est un constituant interne et n'est pas disponible pour une utilisation externe (c'est privé).
Voici un exemple : écrivons une crate de bibliothèque qui permet de simuler un restaurant. Nous allons définir les signatures des fonctions mais nous allons laisser leurs corps vides pour nous concentrer sur l'organisation du code, plutôt que de coder pour de vrai un restaurant.
Dans le secteur de la restauration, certaines parties d'un restaurant sont assimilées à la salle à manger et d'autres aux cuisines. La partie salle à manger est l'endroit où se trouvent les clients ; c'est l'endroit où les hôtes installent les clients, où les serveurs prennent les commandes et encaissent les clients, et où les barmans préparent des boissons. Dans la partie cuisines, nous retrouvons les chefs et les cuisiniers qui travaillent dans la cuisine, mais aussi les plongeurs qui nettoient la vaisselle et les gestionnaires qui s'occupent des tâches administratives.
Pour organiser notre crate de la même manière qu'un vrai restaurant, nous
pouvons organiser les fonctions avec des modules imbriqués. Créez une nouvelle
bibliothèque qui s'appelle restaurant
en utilisant
cargo new --lib restaurant
; puis écrivez le code de l'encart 7-1 dans
src/lib.rs afin de définir quelques modules et quelques signatures de
fonctions.
Fichier : src/lib.rs
mod salle_a_manger {
mod accueil {
fn ajouter_a_la_liste_attente() {}
fn installer_a_une_table() {}
}
mod service {
fn prendre_commande() {}
fn servir_commande() {}
fn encaisser() {}
}
}
Encart 7-1 : Un module salle_a_manger
qui contient
d'autres modules qui contiennent eux-mêmes des fonctions
Nous définissons un module en commençant avec le mot-clé mod
et nous précisons
ensuite le nom du module (dans notre cas, salle_a_manger
) et nous ajoutons des
accolades autour du corps du module. Dans les modules, nous pouvons avoir
d'autres modules, comme dans notre cas avec les modules accueil
et service
.
Les modules peuvent aussi contenir des définitions pour d'autres éléments, comme
des structures, des énumérations, des constantes, des traits, ou des fonctions
(comme c'est le cas dans l'encart 7-1).
Grâce aux modules, nous pouvons regrouper ensemble des définitions qui sont liées et donner un nom à ce lien. Les développeurs qui utiliseront ce code pourront plus facilement trouver les définitions dont ils ont besoin car ils peuvent parcourir le code en fonction des groupes plutôt que d'avoir à lire toutes les définitions. Les développeurs qui veulent rajouter des nouvelles fonctionnalités à ce code sauront maintenant où placer le code tout en gardant le programme organisé.
Précédemment, nous avons dit que src/main.rs et src/lib.rs étaient des
racines de crates. Nous les appelons ainsi car le contenu de chacun de ces
deux fichiers constituent un module qui s'appelle crate
à la racine de
l'arborescence du module.
L'encart 7-2 présente l'arborescence du module pour la structure de l'encart 7-1.
crate
└── salle_a_manger
├── accueil
│ ├── ajouter_a_la_liste_attente
│ └── installer_a_une_table
└── service
├── prendre_commande
├── servir_commande
└── encaisser
Encart 7-2 : L'arborescence des modules pour le code de l'encart 7-1
Cette arborescence montre comment les modules sont imbriqués entre eux (par
exemple, accueil
est imbriqué dans salle_a_manger
). L'arborescence montre
aussi que certains modules sont les frères d'autres modules, ce qui veut dire
qu'ils sont définis dans le même module (accueil
et service
sont définis
dans salle_a_manger
). Pour prolonger la métaphore familiale, si le module A
est contenu dans le module B, on dit que le module A est l'enfant du module B
et que ce module B est le parent du module A. Notez aussi que le module
implicite nommé crate
est le parent de toute cette arborescence.
L'arborescence des modules peut rappeler les dossiers du système de fichiers de votre ordinateur ; et c'est une excellente comparaison ! Comme les dossiers dans un système de fichiers, vous utilisez les modules pour organiser votre code. Et comme pour les fichiers dans un dossier, nous avons besoin d'un moyen de trouver nos modules.
Désigner un élément dans l'arborescence de modules
Pour indiquer à Rust où trouver un élément dans l'arborescence de modules, nous utilisons un chemin à l'instar des chemins que nous utilisons lorsque nous naviguons dans un système de fichiers. Si nous voulons appeler une fonction, nous avons besoin de connaître son chemin.
Il existe deux types de chemins :
- Un chemin absolu qui commence à partir de la racine de la crate en utilisant
le nom d'une crate, ou le mot
crate
. - Un chemin relatif qui commence à partir du module courant et qui utilise
self
,super
, ou un identificateur à l'intérieur du module.
Les chemins absolus et relatifs sont suivis par un ou plusieurs identificateurs
séparés par ::
.
Reprenons notre exemple de l'encart 7-1. Comment pouvons-nous appeler la
fonction ajouter_a_la_liste_attente
? Cela revient à se demander : quel est le
chemin de la fonction ajouter_a_la_liste_attente
? Dans l'encart 7-3, nous
avons un peu simplifié notre code en enlevant quelques modules et quelques
fonctions. Nous allons voir deux façons d'appeler la fonction
ajouter_a_la_liste_attente
à partir d'une nouvelle fonction
manger_au_restaurant
définie à la racine de la crate. La fonction
manger_au_restaurant
fait partie de l'API publique de notre crate de
bibliothèque, donc nous la marquons avec le mot-clé pub
. Dans la section
”Exposer les chemins avec le mot-clé pub
”, nous en
apprendrons plus sur pub
. Notez que cet exemple ne se compile pas pour le
moment ; nous allons l'expliquer un peu plus tard.
Fichier : src/lib.rs
mod salle_a_manger {
mod accueil {
fn ajouter_a_la_liste_attente() {}
}
}
pub fn manger_au_restaurant() {
// Chemin absolu
crate::salle_a_manger::accueil::ajouter_a_la_liste_attente();
// Chemin relatif
salle_a_manger::accueil::ajouter_a_la_liste_attente();
}
Encart 7-3 : appel à la fonction
ajouter_a_la_liste_attente
en utilisant un chemin absolu et relatif
Au premier appel de la fonction ajouter_a_la_liste_attente
dans
manger_au_restaurant
, nous utilisons un chemin absolu. La fonction
ajouter_a_la_liste_attente
est définie dans la même crate que
manger_au_restaurant
, ce qui veut dire que nous pouvons utiliser le mot-clé
crate
pour démarrer un chemin absolu.
Après crate
, nous ajoutons chacun des modules successifs jusqu'à
ajouter_a_la_liste_attente
. Nous pouvons faire l'analogie avec un système de
fichiers qui aurait la même structure, où nous pourrions utiliser le chemin
/salle_a_manger/accueil/ajouter_a_la_liste_attente
pour lancer le programme
ajouter_a_la_liste_attente
; utiliser le nom crate
pour partir de la racine
de la crate revient à utiliser /
pour partir de la racine de votre système de
fichiers dans votre invite de commande.
Lors du second appel à ajouter_a_la_liste_attente
dans manger_au_restaurant
,
nous utilisons un chemin relatif. Le chemin commence par salle_a_manger
, le
nom du module qui est défini au même niveau que manger_au_restaurant
dans
l'arborescence de modules. Ici, l'équivalent en terme de système de fichier
serait le chemin salle_a_manger/accueil/ajouter_a_la_liste_attente
. Commencer
par un nom signifie que le chemin est relatif.
Choisir entre utiliser un chemin relatif ou absolu sera une décision que vous
ferez en fonction de votre projet. Le choix se fera en fonction de si vous êtes
susceptible de déplacer la définition de l'élément souhaité séparément ou en
même temps que le code qui l'utilise. Par exemple, si nous déplaçons le module
salle_a_manger
ainsi que la fonction manger_au_restaurant
dans un module qui
s'appelle experience_client
, nous aurons besoin de mettre à jour le chemin
absolu vers ajouter_a_la_liste_attente
, mais le chemin relatif restera valide.
Cependant, si nous avions déplacé uniquement la fonction manger_au_restaurant
dans un module repas
séparé, le chemin absolu de l'appel à
ajouter_a_la_liste_attente
restera le même, mais le chemin relatif aura besoin
d'être mis à jour. Notre préférence est d'utiliser un chemin absolu car il est
plus facile de déplacer les définitions de code et les appels aux éléments
indépendamment les uns des autres.
Essayons de compiler l'encart 7-3 et essayons de comprendre pourquoi il ne se compile pas pour le moment ! L'erreur que nous obtenons est affichée dans l'encart 7-4.
$ cargo build
Compiling restaurant v0.1.0 (file:///projects/restaurant)
error[E0603]: module `accueil` is private
--> src/lib.rs:9:28
|
9 | crate::salle_a_manger::accueil::ajouter_a_la_liste_attente();
| ^^^^^^^ private module
|
note: the module `accueil` is defined here
--> src/lib.rs:2:5
|
2 | mod accueil {
| ^^^^^^^^^^^
error[E0603]: module `accueil` is private
--> src/lib.rs:12:21
|
12 | salle_a_manger::accueil::ajouter_a_la_liste_attente();
| ^^^^^^^ private module
|
note: the module `accueil` is defined here
--> src/lib.rs:2:5
|
2 | mod accueil {
| ^^^^^^^^^^^
For more information about this error, try `rustc --explain E0603`.
error: could not compile `restaurant` due to 2 previous errors
Encart 7-4 : les erreurs de compilation du code de l'encart 7-3
Le message d'erreur nous rappelle que ce module accueil
est privé. Autrement
dit, nous avons des chemins corrects pour le module accueil
et pour la
fonction ajouter_a_la_liste_attente
, mais Rust ne nous laisse pas les utiliser
car il n'a pas accès aux sections privées.
Les modules ne servent pas uniquement à organiser votre code. Ils définissent aussi les limites de visibilité de Rust : le code externe n'est pas autorisé à connaître, à appeler ou à se fier à des éléments internes au module. Donc, si vous voulez rendre un élément privé comme une fonction ou une structure, vous devez le placer dans un module.
La visibilité en Rust fait en sorte que tous les éléments (fonctions, méthodes, structures, énumérations, modules et constantes) sont privés par défaut. Les éléments dans un module parent ne peuvent pas utiliser les éléments privés dans les modules enfants, mais les éléments dans les modules enfants peuvent utiliser les éléments dans les modules parents. C'est parce que les modules enfants englobent et cachent les détails de leur implémentation, mais les modules enfants peuvent voir dans quel contexte ils sont définis. Pour continuer la métaphore du restaurant, considérez que les règles de visibilité de Rust fonctionnent comme les cuisines d'un restaurant : ce qui s'y passe n'est pas connu des clients, mais les gestionnaires peuvent tout voir et tout faire dans le restaurant dans lequel ils travaillent.
Rust a décidé de faire fonctionner le système de modules de façon à ce que les
détails d'implémentation interne sont cachés par défaut. Ainsi, vous savez
quelles parties du code interne vous pouvez changer sans casser le code externe.
Mais vous pouvez exposer aux parents des parties internes des modules enfants en
utilisant le mot-clé pub
afin de les rendre publiques.
Exposer des chemins avec le mot-clé pub
Retournons à l'erreur de l'encart 7-4 qui nous informe que le module accueil
est privé. Nous voulons que la fonction manger_au_restaurant
du module parent
ait accès à la fonction ajouter_a_la_liste_attente
du module enfant, donc nous
utilisons le mot-clé pub
sur le module accueil
, comme dans l'encart 7-5.
Fichier : src/lib.rs
mod salle_a_manger {
pub mod accueil {
fn ajouter_a_la_liste_attente() {}
}
}
pub fn manger_au_restaurant() {
// Chemin absolu
crate::salle_a_manger::accueil::ajouter_a_la_liste_attente();
// Chemin relatif
salle_a_manger::accueil::ajouter_a_la_liste_attente();
}
Encart 7-5 : utiliser pub
sur le module accueil
permet
de l'utiliser dans manger_au_restaurant
Malheureusement, il reste une erreur dans le code de l'encart 7-5, la voici dans l'encart 7-6.
$ cargo build
Compiling restaurant v0.1.0 (file:///projects/restaurant)
error[E0603]: function `ajouter_a_la_liste_attente` is private
--> src/lib.rs:9:37
|
9 | crate::salle_a_manger::accueil::ajouter_a_la_liste_attente();
| ^^^^^^^^^^^^^^^^^^^^^^^^^^ private function
|
note: the function `ajouter_a_la_liste_attente` is defined here
--> src/lib.rs:3:9
|
3 | fn ajouter_a_la_liste_attente() {}
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
error[E0603]: function `ajouter_a_la_liste_attente` is private
--> src/lib.rs:12:30
|
12 | salle_a_manger::accueil::ajouter_a_la_liste_attente();
| ^^^^^^^^^^^^^^^^^^^^^^^^^^ private function
|
note: the function `ajouter_a_la_liste_attente` is defined here
--> src/lib.rs:3:9
|
3 | fn ajouter_a_la_liste_attente() {}
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
For more information about this error, try `rustc --explain E0603`.
error: could not compile `restaurant` due to 2 previous errors
Encart 7-6 : erreurs de compilation du code de l'encart 7-5
Que s'est-il passé ? Ajouter le mot-clé pub
devant mod accueil
rend public
le module. Avec cette modification, si nous pouvons accéder à salle_a_manger
,
alors nous pouvons accéder à accueil
. Mais le contenu de accueil
reste
privé ; rendre le module public ne rend pas son contenu public. Le mot-clé pub
sur un module permet uniquement au code de ses parents d'y faire référence.
Les erreurs dans l'encart 7-6 nous informent que la fonction
ajouter_a_la_liste_attente
est privée. Les règles de visibilité s'appliquent
aussi bien aux modules qu'aux structures, énumérations, fonctions et méthodes.
Rendons publique la fonction ajouter_a_la_liste_attente
, en ajoutant le
mot-clé pub
devant sa définition, comme dans l'encart 7-7.
Fichier : src/lib.rs
mod salle_a_manger {
pub mod accueil {
pub fn ajouter_a_la_liste_attente() {}
}
}
pub fn manger_au_restaurant() {
// Chemin absolu
crate::salle_a_manger::accueil::ajouter_a_la_liste_attente();
// Chemin relatif
salle_a_manger::accueil::ajouter_a_la_liste_attente();
}
Encart 7-7 : ajout du mot-clé pub
devant mod accueil
et fn ajouter_a_la_liste_attente
pour nous permettre d'appeler la fonction à
partir de manger_au_restaurant
Maintenant, le code va compiler ! Analysons les chemins relatif et absolu et
vérifions pourquoi l'ajout du mot-clé pub
nous permet d'utiliser ces chemins
dans ajouter_a_la_liste_attente
tout en respectant les règles de visibilité.
Dans le chemin absolu, nous commençons avec crate
, la racine de l'arborescence
de modules de notre crate. Ensuite, le module salle_a_manger
est défini à la
racine de la crate. Le module salle_a_manger
n'est pas public, mais comme la
fonction manger_au_restaurant
est définie dans le même module que
salle_a_manger
(car manger_au_restaurant
et salle_a_manger
sont frères),
nous pouvons utiliser salle_a_manger
à partir de manger_au_restaurant
.
Ensuite, nous avons le module accueil
, défini avec pub
. Nous pouvons accéder
au module parent de accueil
, donc nous pouvons accéder à accueil
. Enfin, la
fonction ajouter_a_la_liste_attente
est elle aussi définie avec pub
et nous
pouvons accéder à son module parent, donc au final cet appel à la fonction
fonctionne bien !
Dans le chemin relatif, le fonctionnement est le même que le chemin absolu sauf
pour la première étape : plutôt que de démarrer de la racine de la crate, le
chemin commence à partir de salle_a_manger
. Le module salle_a_manger
est
défini dans le même module que manger_au_restaurant
, donc le chemin relatif
qui commence à partir du module où est défini manger_au_restaurant
fonctionne
bien. Ensuite, comme accueil
et ajouter_a_la_liste_attente
sont définis avec
pub
, le reste du chemin fonctionne, et cet appel à la fonction est donc
valide !
Commencer les chemins relatifs avec super
Nous pouvons aussi créer des chemins relatifs qui commencent à partir du module
parent en utilisant super
au début du chemin. C'est comme débuter un chemin
dans un système de fichiers avec la syntaxe ..
. Mais pourquoi voudrions-nous
faire cela ?
Imaginons le code dans l'encart 7-8 qui représente le cas où le chef corrige une
commande erronée et l'apporte personnellement au client pour s'excuser. La
fonction corriger_commande_erronee
appelle la fonction servir_commande
en
commençant le chemin de servir_commande
avec super
:
Fichier : src/lib.rs
fn servir_commande() {}
mod cuisines {
fn corriger_commande_erronee() {
cuisiner_commande();
super::servir_commande();
}
fn cuisiner_commande() {}
}
Encart 7-8 : appel d'une fonction en utilisant un chemin
relatif qui commence par super
La fonction corriger_commande_erronee
est dans le module cuisines
, donc nous
pouvons utiliser super
pour nous rendre au module parent de cuisines
, qui
dans notre cas est crate
, la racine. De là, nous cherchons servir_commande
et nous la trouvons. Avec succès ! Nous pensons que le module cuisines
et la
fonction servir_commande
vont toujours garder la même relation et devrons être
déplacés ensemble si nous réorganisons l'arborescence de modules de la crate.
Ainsi, nous avons utilisé super
pour avoir moins de code à mettre à jour à
l'avenir si ce code est déplacé dans un module différent.
Rendre publiques des structures et des énumérations
Nous pouvons aussi utiliser pub
pour déclarer des structures et des
énumérations publiquement, mais il y a d'autres points à prendre en compte. Si
nous utilisons pub
avant la définition d'une structure, nous rendons la
structure publique, mais les champs de la structure restent privés. Nous pouvons
rendre chaque champ public ou non au cas par cas. Dans l'encart 7-9, nous avons
défini une structure publique cuisines::PetitDejeuner
avec un champ public
tartine_grillee
mais avec un champ privé fruit_de_saison
. Cela simule un
restaurant où le client peut choisir le type de pain qui accompagne le repas,
mais le chef décide des fruits qui accompagnent le repas en fonction de la
saison et ce qu'il y a en stock. Les fruits disponibles changent rapidement,
donc les clients ne peuvent pas choisir le fruit ou même voir quel fruit ils
obtiendront.
Fichier : src/lib.rs
mod cuisines {
pub struct PetitDejeuner {
pub tartine_grillee: String,
fruit_de_saison: String,
}
impl PetitDejeuner {
pub fn en_ete(tartine_grillee: &str) -> PetitDejeuner {
PetitDejeuner {
tartine_grillee: String::from(tartine_grillee),
fruit_de_saison: String::from("pêches"),
}
}
}
}
pub fn manger_au_restaurant() {
// On commande un petit-déjeuner en été avec tartine grillée au seigle
let mut repas = cuisines::PetitDejeuner::en_ete("seigle");
// On change d'avis sur le pain que nous souhaitons
repas.tartine_grillee = String::from("blé");
println!( "Je voudrais une tartine grillée au {}, s'il vous plaît.",
repas.tartine_grillee);
// La prochaine ligne ne va pas se compiler si nous ne la commentons pas,
// car nous ne sommes pas autorisés à voir ou modifier le fruit de saison
// qui accompagne le repas.
// repas.fruit_de_saison = String::from("myrtilles");
}
Encart 7-9 : une structure avec certains champs publics et d'autres privés
Comme le champ tartine_grillee
est public dans la structure
cuisines::PetitDejeuner
, nous pouvons lire et écrire dans le champ
tartine_grillee
à partir de manger_au_restaurant
en utilisant .
. Notez
aussi que nous ne pouvons pas utiliser le champ fruit_de_saison
dans
manger_au_restaurant
car fruit_de_saison
est privé. Essayez de dé-commenter
la ligne qui tente de modifier la valeur du champ fruit_de_saison
et voyez
l'erreur que vous obtenez !
Aussi, remarquez que comme cuisines::PetitDejeuner
a un champ privé, la
structure a besoin de fournir une fonction associée publique qui construit une
instance de PetitDejeuner
(que nous avons nommée en_ete
ici). Si
PetitDejeuner
n'avait pas une fonction comme celle-ci, nous ne pourrions pas
créer une instance de PetitDejeuner
dans manger_au_restaurant
car nous ne
pourrions pas donner une valeur au champ privé fruit_de_saison
dans
manger_au_restaurant
.
Par contre, si nous rendons publique une énumération, toutes ses variantes
seront publiques. Nous avons simplement besoin d'un pub
devant le mot-clé
enum
, comme dans l'encart 7-10.
Fichier : src/lib.rs
mod cuisines {
pub enum AmuseBouche {
Soupe,
Salade,
}
}
pub fn manger_au_restaurant() {
let commande1 = cuisines::AmuseBouche::Soupe;
let commande2 = cuisines::AmuseBouche::Salade;
}
Encart 7-10 : on rend publique une énumération et cela rend aussi toutes ses variantes publiques
Comme nous rendons l'énumération AmuseBouche
publique, nous pouvons utiliser
les variantes Soupe
et Salade
dans manger_au_restaurant
. Les énumérations
ne sont pas très utiles si elles n'ont pas leurs variantes publiques ; et cela
serait pénible d'avoir à marquer toutes les variantes de l'énumération avec
pub
, donc par défaut les variantes d'énumérations sont publiques. Les
structures sont souvent utiles sans avoir de champs publics, donc les champs des
structures sont tous privés par défaut, sauf si ces éléments sont marqués d'un
pub
.
Il y a encore une chose que nous n'avons pas abordée concernant pub
, et c'est
la dernière fonctionnalité du système de modules : le mot-clé use
. Nous
commencerons par parler de l'utilisation de use
de manière générale, puis nous
verrons comment combiner pub
et use
.
Importer des chemins dans la portée via le mot-clé use
Les chemins que nous avons écrits jusqu'ici peuvent paraître pénibles car trop
longs et répétitifs. Par exemple, dans l'encart 7-7, que nous ayons choisi
d'utiliser le chemin absolu ou relatif pour la fonction
ajouter_a_la_liste_attente
, nous aurions dû aussi écrire salle_a_manger
et
accueil
à chaque fois que nous voulions appeler ajouter_a_la_liste_attente
.
Heureusement, il existe une solution pour simplifier ce cheminement.
Nous pouvons importer un chemin dans la portée et appeler ensuite les éléments
de ce chemin comme s'ils étaient locaux grâce au mot-clé use
.
Dans l'encart 7-11, nous importons le module crate::salle_a_manger::accueil
dans la portée de la fonction manger_au_restaurant
afin que nous n'ayons plus
qu'à utiliser accueil::ajouter_a_la_liste_attente
pour appeler la fonction
ajouter_a_la_liste_attente
dans manger_au_restaurant
.
Fichier : src/lib.rs
mod salle_a_manger {
pub mod accueil {
pub fn ajouter_a_la_liste_attente() {}
}
}
use crate::salle_a_manger::accueil;
pub fn manger_au_restaurant() {
accueil::ajouter_a_la_liste_attente();
accueil::ajouter_a_la_liste_attente();
accueil::ajouter_a_la_liste_attente();
}
Encart 7-11 : importer un module dans la portée via use
Dans une portée, utiliser un use
et un chemin s'apparente à créer un lien
symbolique dans le système de fichier. Grâce à l'ajout de
use crate::salle_a_manger::accueil
à la racine de la crate, accueil
est
maintenant un nom valide dans cette portée, comme si le module accueil
avait
été défini à la racine de la crate. Les chemins importés dans la portée via
use
doivent respecter les règles de visibilité, tout comme les autres chemins.
Vous pouvez aussi importer un élément dans la portée avec use
et un chemin
relatif. L'encart 7-12 nous montre comment utiliser un chemin relatif pour
obtenir le même résultat que l'encart 7-11.
Fichier : src/lib.rs
mod salle_a_manger {
pub mod accueil {
pub fn ajouter_a_la_liste_attente() {}
}
}
use salle_a_manger::accueil;
pub fn manger_au_restaurant() {
accueil::ajouter_a_la_liste_attente();
accueil::ajouter_a_la_liste_attente();
accueil::ajouter_a_la_liste_attente();
}
Encart 7-12 : importer un module dans la portée avec use
et un chemin relatif
Créer des chemins idéaux pour use
Dans l'encart 7-11, vous vous êtes peut-être demandé pourquoi nous avions
utilisé use crate::salle_a_manger::accueil
et appelé ensuite
accueil::ajouter_a_la_liste_attente
dans manger_au_restaurant
plutôt que
d'écrire le chemin du use
jusqu'à la fonction ajouter_a_la_liste_attente
pour avoir le même résultat, comme dans l'encart 7-13.
Fichier : src/lib.rs
mod salle_a_manger {
pub mod accueil {
pub fn ajouter_a_la_liste_attente() {}
}
}
use crate::salle_a_manger::accueil::ajouter_a_la_liste_attente;
pub fn manger_au_restaurant() {
ajouter_a_la_liste_attente();
ajouter_a_la_liste_attente();
ajouter_a_la_liste_attente();
}
Encart 7-13 : importer la fonction
ajouter_a_la_liste_attente
dans la portée avec use
, ce qui n'est pas idéal
Bien que l'encart 7-11 et 7-13 accomplissent la même tâche, l'encart 7-11 est la
façon idéale d'importer une fonction dans la portée via use
. L'import du
module parent de la fonction dans notre portée avec use
nécessite que nous
ayons à préciser le module parent quand nous appelons la fonction. Renseigner le
module parent lorsque nous appelons la fonction précise clairement que la
fonction n'est pas définie localement, tout en minimisant la répétition du
chemin complet. Nous ne pouvons pas repérer facilement là où est défini
ajouter_a_la_liste_attente
dans l'encart 7-13.
Cela dit, lorsque nous importons des structures, des énumérations, et d'autres
éléments avec use
, il est idéal de préciser le chemin complet. L'encart 7-14
montre la manière idéale d'importer la structure HashMap
de la bibliothèque
standard dans la portée d'une crate binaire.
Fichier : src/main.rs
use std::collections::HashMap; fn main() { let mut map = HashMap::new(); map.insert(1, 2); }
Encart 7-14 : import de HashMap
dans la portée de
manière idéale
Il n'y a pas de forte justification à cette pratique : c'est simplement une convention qui a germé, et les gens se sont habitués à lire et écrire du code Rust de cette façon.
Il y a une exception à cette pratique : nous ne pouvons pas utiliser
l'instruction use
pour importer deux éléments avec le même nom dans la portée,
car Rust ne l'autorise pas. L'encart 7-15 nous montre comment importer puis
utiliser deux types Result
ayant le même nom mais dont les modules parents
sont distincts.
Fichier : src/lib.rs
use std::fmt;
use std::io;
fn fonction1() -> fmt::Result {
// -- partie masquée ici --
Ok(())
}
fn fonction2() -> io::Result<()> {
// -- partie masquée ici --
Ok(())
}
Encart 7-15 : l'import de deux types ayant le même nom dans la même portée nécessite d'utiliser leurs modules parents.
Comme vous pouvez le constater, l'utilisation des modules parents permet de
distinguer les deux types Result
. Si nous avions utilisé
use std::fmt::Result
et use std::io::Result
, nous aurions deux types nommés
Result
dans la même portée et donc Rust ne pourrait pas comprendre lequel nous
voudrions utiliser en demandant Result
.
Renommer des éléments avec le mot-clé as
Il y a une autre solution au fait d'avoir deux types du même nom dans la même
portée à cause de use
: après le chemin, nous pouvons rajouter as
suivi d'un
nouveau nom local, ou alias, sur le type. L'encart 7-16 nous montre une autre
façon d'écrire le code de l'encart 7-15 en utilisant as
pour renommer un des
deux types Result
.
Fichier : src/lib.rs
use std::fmt::Result;
use std::io::Result as IoResult;
fn fonction1() -> Result {
// -- partie masquée ici --
Ok(())
}
fn fonction2() -> IoResult<()> {
// -- partie masquée ici --
Ok(())
}
Encart 7-16 : renommer un type lorsqu'il est importé dans
la portée, avec le mot-clé as
Dans la seconde instruction use
, nous avons choisi IoResult
comme nouveau
nom du type std::io::Result
, qui n'est plus en conflit avec le Result
de
std::fmt
que nous avons aussi importé dans la portée. Les encarts 7-15 et 7-16
sont idéaux, donc le choix vous revient !
Réexporter des éléments avec pub use
Lorsque nous importons un élément dans la portée avec le mot-clé use
, son nom
dans la nouvelle portée est privé. Pour permettre au code appelant d'utiliser ce
nom comme s'il était défini dans cette portée, nous pouvons associer pub
et
use
. Cette technique est appelée réexporter car nous importons un élément
dans la portée, mais nous rendons aussi cet élément disponible aux portées des
autres.
L'encart 7-17 nous montre le code de l'encart 7-11 où le use
du module racine
a été remplacé par pub use
.
Fichier : src/lib.rs
mod salle_a_manger {
pub mod accueil {
pub fn ajouter_a_la_liste_attente() {}
}
}
pub use crate::salle_a_manger::accueil;
pub fn manger_au_restaurant() {
accueil::ajouter_a_la_liste_attente();
accueil::ajouter_a_la_liste_attente();
accueil::ajouter_a_la_liste_attente();
}
Encart 7-17 : rendre un élément disponible pour n'importe
quel code qui l'importera dans sa portée, avec pub use
Grâce à pub use
, le code externe peut maintenant appeler la fonction
ajouter_a_la_liste_attente
en utilisant accueil::ajouter_a_la_liste_attente
.
Si nous n'avions pas utilisé pub use
, la fonction manger_au_restaurant
aurait pu appeler accueil::ajouter_a_la_liste_attente
dans sa portée, mais le
code externe n'aurait pas pu profiter de ce nouveau chemin.
Réexporter est utile quand la structure interne de votre code est différente de
la façon dont les développeurs qui utilisent votre code se la représentent. Par
exemple, dans cette métaphore du restaurant, les personnes qui font fonctionner
le restaurant se structurent en fonction de la “salle à manger” et des
“cuisines”. Mais les clients qui utilisent le restaurant ne vont probablement
pas voir les choses ainsi. Avec pub use
, nous pouvons écrire notre code selon
une certaine organisation, mais l'exposer avec une organisation différente. En
faisant ainsi, la bibliothèque est bien organisée autant pour les développeurs
qui travaillent sur la bibliothèque que pour les développeurs qui utilisent la
bibliothèque.
Utiliser des paquets externes
Dans le chapitre 2, nous avions développé un projet de jeu du plus ou du moins
qui utilisait le paquet externe rand
afin d'obtenir des nombres aléatoires.
Pour pouvoir utiliser rand
dans notre projet, nous avions ajouté cette ligne
dans Cargo.toml :
Fichier : Cargo.toml
rand = "0.8.3"
L'ajout de rand
comme dépendance dans Cargo.toml demande à Cargo de
télécharger le paquet rand
et toutes ses dépendances à partir de
crates.io et rend disponible rand
pour notre projet.
Ensuite, pour importer les définitions de rand
dans la portée de notre paquet,
nous avions ajouté une ligne use
qui commence avec le nom de la crate, rand
,
et nous avions listé les éléments que nous voulions importer dans notre portée.
Dans la section “Générer le nombre secret” du chapitre 2,
nous avions importé le trait Rng
dans la portée, puis nous avions appelé la
fonction rand::thread_rng
:
use std::io;
use rand::Rng;
fn main() {
println!("Devinez le nombre !");
let nombre_secret = rand::thread_rng().gen_range(1..101);
println!("Le nombre secret est : {}", nombre_secret);
println!("Veuillez entrer un nombre.");
let mut supposition = String::new();
io::stdin()
.read_line(&mut supposition)
.expect("Échec de la lecture de l'entrée utilisateur");
println!("Votre nombre : {}", supposition);
}
Les membres de la communauté Rust ont mis à disposition de nombreux paquets sur
crates.io, et utiliser l'un d'entre eux dans votre paquet
implique toujours ces mêmes étapes : les lister dans le fichier Cargo.toml de
votre paquet et utiliser use
pour importer certains éléments de ces crates
dans la portée.
Notez que la bibliothèque standard (std
) est aussi une crate qui est externe à
notre paquet. Comme la bibliothèque standard est livrée avec le langage Rust,
nous n'avons pas à modifier le Cargo.toml pour y inclure std
. Mais nous
devons utiliser use
pour importer les éléments qui se trouvent dans la portée
de notre paquet. Par exemple, pour HashMap
, nous pourrions utiliser cette
ligne :
#![allow(unused)] fn main() { use std::collections::HashMap; }
C'est un chemin absolu qui commence par std
, le nom de la crate de la
bibliothèque standard.
Utiliser des chemins imbriqués pour simplifier les grandes listes de use
Si vous utilisez de nombreux éléments définis dans une même crate ou dans un
même module, lister chaque élément sur sa propre ligne prendra beaucoup d'espace
vertical dans vos fichiers. Par exemple, ces deux instructions use
, que nous
avions dans le jeu du plus ou du moins dans l'encart 2-4, importaient des
éléments de std
dans la portée :
Fichier : src/main.rs
use rand::Rng;
// -- partie masquée ici --
use std::cmp::Ordering;
use std::io;
// -- partie masquée ici --
fn main() {
println!("Devinez le nombre !");
let nombre_secret = rand::thread_rng().gen_range(1..101);
println!("Le nombre secret est : {}", nombre_secret);
println!("Veuillez entrer un nombre.");
let mut supposition = String::new();
io::stdin()
.read_line(&mut supposition)
.expect("Échec de la lecture de l'entrée utilisateur");
println!("Votre nombre : {}", supposition);
match supposition.cmp(&nombre_secret) {
Ordering::Less => println!("C'est plus !"),
Ordering::Greater => println!("C'est moins !"),
Ordering::Equal => println!("Vous avez gagné !"),
}
}
À la place, nous pouvons utiliser des chemins imbriqués afin d'importer ces mêmes éléments dans la portée en une seule ligne. Nous pouvons faire cela en indiquant la partie commune du chemin, suivi d'un double deux-points, puis d'accolades autour d'une liste des éléments qui diffèrent entre les chemins, comme dans l'encart 7-18 :
Fichier : src/main.rs
use rand::Rng;
// -- partie masquée ici --
use std::{cmp::Ordering, io};
// -- partie masquée ici --
fn main() {
println!("Devinez le nombre !");
let nombre_secret = rand::thread_rng().gen_range(1..101);
println!("Le nombre secret est : {}", nombre_secret);
println!("Veuillez entrer un nombre.");
let mut supposition = String::new();
io::stdin()
.read_line(&mut supposition)
.expect("Échec de la lecture de l'entrée utilisateur");
let supposition: u32 = supposition.trim().parse().expect("Veuillez saisir un nombre !");
println!("Votre nombre : {}", supposition);
match supposition.cmp(&nombre_secret) {
Ordering::Less => println!("C'est plus !"),
Ordering::Greater => println!("C'est moins !"),
Ordering::Equal => println!("Vous avez gagné !"),
}
}
Encart 7-18 : utiliser un chemin imbriqué pour importer plusieurs éléments avec le même préfixe dans la portée
Pour des programmes plus gros, importer plusieurs éléments dans la portée depuis
la même crate ou module en utilisant des chemins imbriqués peut réduire
considérablement le nombre de use
utilisés !
Nous pouvons utiliser un chemin imbriqué à tous les niveaux d'un chemin, ce qui
peut être utile lorsqu'on utilise deux instructions use
qui partagent un
sous-chemin. Par exemple, l'encart 7-19 nous montre deux instructions use
:
une qui importe std::io
dans la portée et une autre qui importe
std::io::Write
dans la portée.
Fichier : src/lib.rs
use std::io;
use std::io::Write;
Encart 7-19 : deux instructions use
où l'une est un
sous-chemin de l'autre
La partie commune entre ces deux chemins est std::io
, et c'est le premier
chemin complet. Pour imbriquer ces deux chemins en une seule instruction use
,
nous pouvons utiliser self
dans le chemin imbriqué, comme dans l'encart 7-20.
Fichier : src/lib.rs
use std::io::{self, Write};
Encart 7-20 : imbrication des chemins de l'encart 7-19
dans une seule instruction use
Cette ligne importe std::io
et std::io::Write
dans la portée.
L'opérateur global
Si nous voulons importer, dans la portée, tous les éléments publics définis
dans un chemin, nous pouvons indiquer ce chemin suivi par *
, l'opérateur
global :
#![allow(unused)] fn main() { use std::collections::*; }
Cette instruction use
va importer tous les éléments publics définis dans
std::collections
dans la portée courante. Mais soyez prudent quand vous
utilisez l'opérateur global ! L'opérateur global rend difficile à dire quels
éléments sont dans la portée et là où un élément utilisé dans notre programme a
été défini.
L'opérateur global est souvent utilisé lorsque nous écrivons des tests, pour
importer tout ce qu'il y a à tester dans le module tests
; nous verrons cela
dans une section du chapitre 11. L'opérateur global est parfois
aussi utilisé pour l'étape préliminaire : rendez-vous dans la documentation de
la bibliothèque
standard pour plus d'informations sur cela.
Séparer les modules dans différents fichiers
Jusqu'à présent, tous les exemples de ce chapitre ont défini plusieurs modules dans un seul fichier. Quand les modules vont grossir, vous allez probablement vouloir déplacer leurs définitions dans un fichier séparé pour faciliter le parcours de votre code.
Prenons par exemple le code de l'encart 7-17 et déplaçons le module
salle_a_manger
dans son propre fichier src/salle_a_manger.rs en changeant le
fichier à la racine de la crate afin qu'il corresponde au code de l'encart 7-21.
Dans notre cas, le fichier à la racine de la crate est src/lib.rs, mais cette
procédure fonctionne aussi avec les crates binaires dans lesquelles le fichier à
la racine de la crate est src/main.rs.
Fichier : src/lib.rs
mod salle_a_manger;
pub use crate::salle_a_manger::accueil;
pub fn manger_au_restaurant() {
accueil::ajouter_a_la_liste_attente();
accueil::ajouter_a_la_liste_attente();
accueil::ajouter_a_la_liste_attente();
}
Encart 7-21 : Déclaration du module salle_a_manger
dont
le corps sera dans src/salle_a_manger.rs
Et src/salle_a_manger.rs contiendra la définition du corps du module
salle_a_manger
, comme dans l'encart 7-22.
Fichier : src/salle_a_manger.rs
pub mod accueil {
pub fn ajouter_a_la_liste_attente() {}
}
Encart 7-22 : Les définitions à l'intérieur du module
salle_a_manger
dans src/salle_a_manger.rs
Utiliser un point-virgule après mod salle_a_manger
plutôt que de créer un bloc
indique à Rust de charger le contenu du module à partir d'un autre fichier qui
porte le même nom que le module. Pour continuer avec notre exemple et déplacer
également le module accueil
dans son propre fichier, nous modifions
src/salle_a_manger.rs pour avoir uniquement la déclaration du module
accueil
:
Fichier : src/salle_a_manger.rs
pub mod accueil;
Ensuite, nous créons un dossier src/salle_a_manger et un fichier
src/salle_a_manger/accueil.rs qui contiendra les définitions du module
accueil
:
Fichier : src/salle_a_manger/accueil.rs
#![allow(unused)] fn main() { pub fn ajouter_a_la_liste_attente() {} }
L'arborescence des modules reste identique, et les appels aux fonctions de
manger_au_restaurant
vont continuer à fonctionner sans aucune modification,
même si les définitions se retrouvent dans des fichiers différents. Cette
technique vous permet de déplacer des modules dans de nouveaux fichiers au fur
et à mesure qu'ils s'agrandissent.
Remarquez que l'instruction pub use crate::salle_a_manger::accueil
dans
src/lib.rs n'a pas changé, et que use
n'a aucun impact sur quels fichiers
sont compilés pour constituer la crate. Le mot-clé mod
déclare un module, et
Rust recherche un fichier de code qui porte le nom dudit module.
Résumé
Rust vous permet de découper un paquet en plusieurs crates et une crate en
modules afin que vous puissiez réutiliser vos éléments d'un module à un autre.
Vous pouvez faire cela en utilisant des chemins absolus ou relatifs. Ces chemins
peuvent être importés dans la portée avec l'instruction use
pour pouvoir
utiliser l'élément plusieurs fois dans la portée avec un chemin plus court. Le
code du module est privé par défaut, mais vous pouvez rendre publiques des
définitions en ajoutant le mot-clé pub
.
Au prochain chapitre, nous allons nous intéresser à quelques collections de structures de données de la bibliothèque standard que vous pourrez utiliser dans votre code soigneusement organisé.
Les collections standard
La bibliothèque standard de Rust apporte quelques structures de données très utiles appelées collections. La plupart des autres types de données représentent une seule valeur précise, mais les collections peuvent contenir plusieurs valeurs. Contrairement aux tableaux et aux tuples, les données que ces collections contiennent sont stockées sur le tas, ce qui veut dire que la quantité de données n'a pas à être connue au moment de la compilation et peut augmenter ou diminuer pendant l'exécution du programme. Chaque type de collection a ses avantages et ses inconvénients, et en choisir un qui répond à votre besoin sur le moment est une aptitude que vous allez développer avec le temps. Dans ce chapitre, nous allons découvrir trois collections qui sont très utilisées dans les programmes Rust :
- Le vecteur qui vous permet de stocker un nombre variable de valeurs les unes à côté des autres.
- La String, qui est une collection de caractères. Nous avons déjà aperçu le
type
String
précédemment, mais dans ce chapitre, nous allons l'étudier en détail. - La table de hachage qui vous permet d'associer une valeur à une clé précise. C'est une implémentation spécifique d'une structure de données plus générique : le tableau associatif.
Pour en savoir plus sur les autres types de collections fournies par la bibliothèque standard, allez voir la documentation.
Nous allons voir comment créer et modifier les vecteurs, les Strings et les tables de hachage, et étudier leurs différences.
Stocker des listes de valeurs avec des vecteurs
Le premier type de collection que nous allons voir est Vec<T>
, aussi appelé
vecteur. Les vecteurs vous permettent de stocker plus d'une valeur dans une
seule structure de données qui stocke les valeurs les unes à côté des autres
dans la mémoire. Les vecteurs peuvent stocker uniquement des valeurs du même
type. Ils sont utiles lorsque vous avez une liste d'éléments, tels que les
lignes de texte provenant d'un fichier ou les prix des articles d'un panier
d'achat.
Créer un nouveau vecteur
Pour créer un nouveau vecteur vide, nous appelons la fonction Vec::new
, comme
dans l'encart 8-1.
fn main() { let v: Vec<i32> = Vec::new(); }
Encart 8-1 : création d'un nouveau vecteur vide pour y
stocker des valeurs de type i32
Remarquez que nous avons ajouté ici une annotation de type. Comme nous
n'ajoutons pas de valeurs dans ce vecteur, Rust ne sait pas quel type d'éléments
nous souhaitons stocker. C'est une information importante. Les vecteurs sont
implémentés avec la généricité ; nous verrons comment utiliser la généricité sur
vos propres types au chapitre 10. Pour l'instant, sachez que le type Vec<T>
qui est fourni par la bibliothèque standard peut stocker n'importe quel type.
Lorsque nous créons un vecteur pour stocker un type précis, nous pouvons
renseigner ce type entre des chevrons. Dans l'encart 8-1, nous précisons à Rust
que le Vec<T>
dans v
va stocker des éléments de type i32
.
Le plus souvent, vous allez créer un Vec<T>
avec des valeurs initiales et
Rust va deviner le type de la valeur que vous souhaitez stocker, donc vous
n'aurez pas souvent besoin de faire cette annotation de type. Rust propose la
macro très pratique vec!
, qui va créer un nouveau vecteur qui stockera les
valeurs que vous lui donnerez. L'encart 8-2 crée un nouveau Vec<i32>
qui
stocke les valeurs 1
, 2
et 3
. Le type d'entier est i32
car c'est le
type d'entier par défaut, comme nous l'avons évoqué dans la section “Les types
de données” du chapitre 3.
fn main() { let v = vec![1, 2, 3]; }
Encart 8-2 : création d'un nouveau vecteur qui contient des valeurs
Comme nous avons donné des valeurs initiales i32
, Rust peut en déduire que le
type de v
est Vec<i32>
, et l'annotation de type n'est plus nécessaire.
Maintenant, nous allons voir comment modifier un vecteur.
Modifier un vecteur
Pour créer un vecteur et ensuite lui ajouter des éléments, nous pouvons utiliser
la méthode push
, comme dans l'encart 8-3.
fn main() { let mut v = Vec::new(); v.push(5); v.push(6); v.push(7); v.push(8); }
Encart 8-3 : utilisation de la méthode push
pour ajouter
des valeurs à un vecteur
Comme pour toute variable, si nous voulons pouvoir modifier sa valeur, nous
devons la rendre mutable en utilisant le mot-clé mut
, comme nous l'avons vu
au chapitre 3. Les nombres que nous ajoutons dedans sont tous du type i32
, et
Rust le devine à partir des données, donc nous n'avons pas besoin de
l'annotation Vec<i32>
.
Libérer un vecteur libère aussi ses éléments
Comme toutes les autres structures, un vecteur est libéré quand il sort de la portée, comme précisé dans l'encart 8-4.
fn main() { { let v = vec![1, 2, 3, 4]; // on fait des choses avec v } // <- v sort de la portée et est libéré ici }
Encart 8-4 : mise en évidence de là où le vecteur et ses éléments sont libérés
Lorsque le vecteur est libéré, tout son contenu est aussi libéré, ce qui veut dire que les nombres entiers qu'il stocke vont être effacés de la mémoire. Cela semble très simple mais cela peut devenir plus compliqué quand vous commencez à utiliser des références vers les éléments du vecteur. Voyons ceci dès à présent !
Lire les éléments des vecteurs
Il existe deux façons de désigner une valeur enregistrée dans un vecteur : via
les indices ou en utilisant la méthode get
. Dans les exemples suivants, nous
avons précisé les types des valeurs qui sont retournées par ces fonctions pour
plus de clarté.
L'encart 8-5 nous montre les deux façons d'accéder à une valeur d'un vecteur,
via la syntaxe d'indexation et avec la méthode get
.
fn main() { let v = vec![1, 2, 3, 4, 5]; let troisieme: &i32 = &v[2]; println!("Le troisième élément est {}", troisieme); match v.get(2) { Some(troisieme) => println!("Le troisième élément est {}", troisieme), None => println!("Il n'y a pas de troisième élément."), } }
Encart 8-5 : utilisation de la syntaxe d'indexation ainsi
que la méthode get
pour accéder à un élément d'un vecteur
Il y a deux détails à remarquer ici. Premièrement, nous avons utilisé l'indice
2
pour obtenir le troisième élément car les vecteurs sont indexés par des
nombres, qui commencent à partir de zéro. Deuxièmement, nous obtenons le
troisième élément soit en utilisant &
et []
, ce qui nous donne une
référence, soit en utilisant la méthode get
avec l'indice en argument, ce qui
nous fournit une Option<&T>
.
La raison pour laquelle Rust offre ces deux manières d'obtenir une référence vers un élement est de vous permettre de choisir le comportement du programme lorsque vous essayez d'utiliser une valeur dont l'indice est à l'extérieur de la plage des éléments existants. Par exemple, voyons dans l'encart 8-6 ce qui se passe lorsque nous avons un vecteur de cinq éléments et qu'ensuite nous essayons d'accéder à un élément à l'indice 100 avec chaque technique.
fn main() { let v = vec![1, 2, 3, 4, 5]; let existe_pas = &v[100]; let existe_pas = v.get(100); }
Encart 8-6 : tentative d'accès à l'élément à l'indice 100 dans un vecteur qui contient cinq éléments
Lorsque nous exécutons ce code, la première méthode []
va faire paniquer le
programme car il demande un élément non existant. Cette méthode doit être
favorisée lorsque vous souhaitez que votre programme plante s'il y a une
tentative d'accéder à un élément après la fin du vecteur.
Lorsque nous passons un indice en dehors de l'intervalle du vecteur à la
méthode get
, elle retourne None
sans paniquer. Vous devriez utiliser cette
méthode s'il peut arriver occasionnellement de vouloir accéder à un élément en
dehors de l'intervalle du vecteur en temps normal. Votre code va ensuite devoir
gérer les deux valeurs Some(&element)
ou None
, comme nous l'avons vu au
chapitre 6. Par exemple, l'indice peut provenir d'une saisie utilisateur. Si
par accident il saisit un nombre qui est trop grand et que le programme obtient
une valeur None
, vous pouvez alors dire à l'utilisateur combien il y a
d'éléments dans le vecteur courant et lui donner une nouvelle chance de saisir
une valeur valide. Cela sera plus convivial que de faire planter le programme à
cause d'une faute de frappe !
Lorsque le programme obtient une référence valide, le vérificateur d'emprunt va faire appliquer les règles de possession et d'emprunt (que nous avons vues au chapitre 4) pour s'assurer que cette référence ainsi que toutes les autres références au contenu de ce vecteur restent valides. Souvenez-vous de la règle qui dit que vous ne pouvez pas avoir des références mutables et immuables dans la même portée. Cette règle s'applique à l'encart 8-7, où nous obtenons une référence immuable vers le premier élément d'un vecteur et nous essayons d'ajouter un élément à la fin. Ce programme ne fonctionnera pas si nous essayons aussi d'utiliser cet élément plus tard dans la fonction :
fn main() {
let mut v = vec![1, 2, 3, 4, 5];
let premier = &v[0];
v.push(6);
println!("Le premier élément est : {}", premier);
}
Encart 8-7 : tentative d'ajout d'un élément à un vecteur alors que nous utilisons une référence à un élément
Compiler ce code va nous mener à cette erreur :
$ cargo run
Compiling collections v0.1.0 (file:///projects/collections)
error[E0502]: cannot borrow `v` as mutable because it is also borrowed as immutable
--> src/main.rs:6:5
|
4 | let premier = &v[0];
| - immutable borrow occurs here
5 |
6 | v.push(6);
| ^^^^^^^^^ mutable borrow occurs here
7 |
8 | println!("Le premier élément est : {}", premier);
| ------- immutable borrow later used here
For more information about this error, try `rustc --explain E0502`.
error: could not compile `collections` due to previous error
Le code dans l'encart 8-7 semble pourtant marcher : pourquoi une référence au premier élément devrait se soucier de ce qui se passe à la fin du vecteur ? Cette erreur s'explique par la façon dont les vecteurs fonctionnent : comme les vecteurs ajoutent les valeurs les unes à côté des autres dans la mémoire, l'ajout d'un nouvel élément à la fin du vecteur peut nécessiter d'allouer un nouvel espace mémoire et copier tous les anciens éléments dans ce nouvel espace, s'il n'y a pas assez de place pour placer tous les éléments les uns à côté des autres dans la mémoire là où est actuellement stocké le vecteur. Dans ce cas, la référence au premier élément pointerait vers de la mémoire désallouée. Les règles d'emprunt évitent aux programmes de se retrouver dans cette situation.
Remarque : pour plus de détails sur l'implémentation du type
Vec<T>
, consultez le “Rustonomicon”.
Itérer sur les valeurs d'un vecteur
Pour accéder à chaque élément d'un vecteur chacun son tour, nous devrions
itérer sur tous les éléments plutôt que d'utiliser individuellement les
indices. L'encart 8-8 nous montre comment utiliser une boucle for
pour
obtenir des références immuables pour chacun des éléments dans un vecteur de
i32
, et les afficher.
fn main() { let v = vec![100, 32, 57]; for i in &v { println!("{}", i); } }
Encart 8-8 : affichage de chaque élément d'un vecteur en
itérant sur les éléments en utilisant une boucle for
Nous pouvons aussi itérer avec des références mutables pour chacun des éléments
d'un vecteur mutable afin de modifier tous les éléments. La boucle for
de
l'encart 8-9 va ajouter 50
à chacun des éléments.
fn main() { let mut v = vec![100, 32, 57]; for i in &mut v { *i += 50; } }
Encart 8-9 : itérations sur des références mutables vers des éléments d'un vecteur
Afin de changer la valeur vers laquelle pointe la référence mutable, nous devons
utiliser l'opérateur de déréférencement *
pour obtenir la valeur dans i
avant que nous puissions utiliser l'opérateur +=
. Nous verrons plus en détail
l'opérateur de déréférencement dans une section du
chapitre 15.
Utiliser une énumération pour stocker différents types
Les vecteurs ne peuvent stocker que des valeurs du même type. Cela peut être un problème ; il y a forcément des cas où on a besoin de stocker une liste d'éléments de types différents. Heureusement, les variantes d'une énumération sont définies sous le même type d'énumération, donc lorsque nous avons besoin d'un type pour représenter les éléments de types différents, nous pouvons définir et utiliser une énumération !
Par exemple, imaginons que nous voulions obtenir les valeurs d'une ligne d'une feuille de calcul dans laquelle quelques colonnes sont des entiers, d'autres des nombres à virgule flottante, et quelques chaînes de caractères. Nous pouvons définir une énumération dont les variantes vont avoir les différents types, et toutes les variantes de l'énumération seront du même type : celui de l'énumération. Ensuite, nous pouvons créer un vecteur pour stocker cette énumération et ainsi, au final, qui stocke différents types. La démonstration de cette technique est dans l'encart 8-10.
fn main() { enum Cellule { Int(i32), Float(f64), Text(String), } let ligne = vec![ Cellule::Int(3), Cellule::Text(String::from("bleu")), Cellule::Float(10.12), ]; }
Encart 8-10 : définition d'une enum
pour stocker des
valeurs de différents types dans un seul vecteur
Rust a besoin de savoir quel type de donnée sera stocké dans le vecteur au
moment de la compilation afin de connaître la quantité de mémoire nécessaire
pour stocker chaque élément sur le tas. Nous devons être précis sur les types
autorisés dans ce vecteur. Si Rust avait permis qu'un vecteur stocke n'importe
quel type, il y aurait pu avoir un risque qu'un ou plusieurs des types
provoquent une erreur avec les manipulations effectuées sur les éléments du
vecteur. L'utilisation d'une énumération ainsi qu'une expression match
permet
à Rust de garantir au moment de la compilation que tous les cas possibles sont
traités, comme nous l'avons appris au chapitre 6.
Si vous n'avez pas une liste exhaustive des types que votre programme va stocker dans un vecteur, la technique de l'énumération ne va pas fonctionner. À la place, vous pouvez utiliser un objet trait, que nous verrons au chapitre 17.
Maintenant que nous avons vu les manières les plus courantes d'utiliser les
vecteurs, prenez le temps de consulter la documentation de
l'API pour découvrir toutes les méthodes très utiles
définies dans la bibliothèque standard pour Vec<T>
. Par exemple, en plus de
push
, nous avons une méthode pop
qui retire et retourne le dernier élément.
Intéressons-nous maintenant au prochain type de collection : la String
!
Stocker du texte encodé en UTF-8 avec les Strings
Nous avons déjà parlé des chaînes de caractères dans le chapitre 4, mais nous allons à présent les analyser plus en détail. Les nouveaux Rustacés bloquent souvent avec les chaînes de caractères pour trois raisons : la tendance de Rust à prévenir les erreurs, le fait que les chaînes de caractères sont des structures de données plus compliquées que ne le pensent la plupart des développeurs, et l'UTF-8. Ces raisons cumulées rendent les choses compliquées lorsque vous venez d'un autre langage de programmation.
Nous avons présenté les chaînes de caractères comme des collections car les
chaînes de caractères sont en réalité des suites d'octets, avec quelques
méthodes supplémentaires qui sont utiles lorsque ces octets sont considérés
comme du texte. Dans cette section, nous allons voir les points communs entre
le fonctionnement des String
et celui des autres collections, comme la
création, la modification et la lecture. Nous verrons les raisons pour
lesquelles les String
sont différentes des autres collections, en particulier
pourquoi l'indexation d'une String
est compliquée à cause des différences
entre la façon dont les gens et les ordinateurs interprètent les données d'une
String
.
Qu'est-ce qu'une chaîne de caractères ?
Nous allons d'abord définir ce que nous entendons par le terme chaîne de
caractères. Rust a un seul type de chaînes de caractères dans le noyau du
langage, qui est la slice de chaîne de caractères str
qui est habituellement
utilisée sous sa forme empruntée, &str
. Dans le chapitre 4, nous avons abordé
les slices de chaînes de caractères, qui sont des références à des données
d'une chaîne de caractères encodée en UTF-8 qui sont stockées autre part. Les
littéraux de chaînes de caractères, par exemple, sont stockés dans le binaire du
programme et sont des slices de chaînes de caractères.
Le type String
, qui est fourni par la bibliothèque standard de Rust plutôt que
d'être intégré au noyau du langage, est un type de chaîne de caractères encodé
en UTF-8 qui peut s'agrandir, être mutable, et être possédé. Lorsque les
Rustacés parlent de “chaînes de caractères” en Rust, ils entendent soit le type
String
, soit le type de slice de chaînes de caractères &str
, et non pas un
seul de ces types. Bien que cette section traite essentiellement de String
,
ces deux types sont utilisés massivement dans la bibliothèque standard de Rust,
et tous les deux sont encodés en UTF-8.
La bibliothèque standard de Rust apporte aussi un certain nombre d'autres types
de chaînes de caractères, comme OsString
, OsStr
, CString
, et CStr
. Les
crates de bibliothèque peuvent fournir encore plus de solutions pour stocker des
chaînes de caractères. Avez-vous remarqué que ces noms finissent tous par
String
ou Str
? Cela fait référence aux variantes possédées et empruntées,
comme les types String
et str
que nous avons vus précédemment. Ces types de
chaînes de caractères peuvent stocker leur texte dans de différents encodages,
ou le stocker en mémoire de manière différente, par exemple. Nous n'allons pas
traiter de ces autres types de chaînes de caractères dans ce chapitre ;
référez-vous à la documentation de leur API pour en savoir plus sur leur
utilisation et leur utilité.
Créer une nouvelle String
De nombreuses opérations disponibles avec Vec<T>
sont aussi disponibles avec
String
, en commençant par la fonction new
pour créer une String
, utilisée
dans l'encart 8-11.
fn main() { let mut s = String::new(); }
Encart 8-11 : Création d'une nouvelle String
vide
Cette ligne crée une nouvelle String
vide qui s'appelle s
, dans laquelle
nous pouvons ensuite charger des données. Souvent, nous aurons quelques données
initiales que nous voudrions ajouter dans la String
. Pour cela, nous utilisons
la méthode to_string
, qui est disponible sur tous les types qui implémentent
le trait Display
, comme le font les littéraux de chaînes de caractères.
L'encart 8-12 nous montre deux exemples.
fn main() { let donnee = "contenu initial"; let s = donnee.to_string(); // cette méthode fonctionne aussi directement sur un // littéral de chaîne de caractères : let s = "contenu initial".to_string(); }
Encart 8-12 : Utilisation de la méthode to_string
pour
créer une String
à partir d'un littéral de chaîne
Ce code crée une String
qui contient contenu initial
.
Nous pouvons aussi utiliser la fonction String::from
pour créer une String
à partir d'un littéral de chaîne. Le code dans l'encart 8-13 est équivalent au
code dans l'encart 8-12 qui utilisait to_string
.
fn main() { let s = String::from("contenu initial"); }
Encart 8-13 : Utilisation de la fonction String::from
afin de créer une String
à partir d'un littéral de chaîne
Comme les chaînes de caractères sont utilisées pour de nombreuses choses, nous
pouvons utiliser beaucoup d'API génériques pour les chaînes de caractères.
Certaines d'entre elles peuvent paraître redondantes, mais elles ont toutes
leur place ! Dans notre cas, String::from
et to_string
font la même chose,
donc votre choix est une question de goût et de lisibilité.
Souvenez-vous que les chaînes de caractères sont encodées en UTF-8, donc nous pouvons y intégrer n'importe quelle donnée valide, comme nous le voyons dans l'encart 8-14.
fn main() { let bonjour = String::from("السلام عليكم"); let bonjour = String::from("Dobrý den"); let bonjour = String::from("Hello"); let bonjour = String::from("שָׁלוֹם"); let bonjour = String::from("नमस्ते"); let bonjour = String::from("こんにちは"); let bonjour = String::from("안녕하세요"); let bonjour = String::from("你好"); let bonjour = String::from("Olá"); let bonjour = String::from("Здравствуйте"); let bonjour = String::from("Hola"); }
Encart 8-14 : Stockage de salutations dans différentes langues dans des chaînes de caractères
Toutes ces chaînes sont des valeurs String
valides.
Modifier une String
Une String
peut s'agrandir et son contenu peut changer, exactement comme le
contenu d'un Vec<T>
, si on y ajoute des données. De plus, vous pouvez aisément
utiliser l'opérateur +
ou la macro format!
pour concaténer des valeurs
String
.
Ajouter du texte à une chaîne avec push_str
et push
Nous pouvons agrandir une String
en utilisant la méthode push_str
pour
ajouter une slice de chaîne de caractères, comme dans l'encart 8-15.
fn main() { let mut s = String::from("foo"); s.push_str("bar"); }
Encart 8-15 : Ajout d'une slice de chaîne de caractères
dans une String
en utilisant la méthode push_str
À l'issue de ces deux lignes, s
va contenir foobar
. La méthode push_str
prend une slice de chaîne de caractères car nous ne souhaitons pas forcément
prendre possession du paramètre. Par exemple, dans le code de l'encart 8-16,
nous voulons pouvoir utiliser s2
après avoir ajouté son contenu dans s1
.
fn main() { let mut s1 = String::from("foo"); let s2 = "bar"; s1.push_str(s2); println!("s2 est {}", s2); }
Encart 8-16 : Utilisation d'une slice de chaîne de
caractères après avoir ajouté son contenu dans une String
Si la méthode push_str
prenait possession de s2
, à la dernière ligne, nous
ne pourrions pas afficher sa valeur. Cependant, ce code fonctionne comme nous
l'espérions !
La méthode push
prend un seul caractère en paramètre et l'ajoute à la
String
. L'encart 8-17 ajoute la lettre “l” à une String
en utilisant la
méthode push
.
fn main() { let mut s = String::from("lo"); s.push('l'); }
Encart 8-17 : Ajout d'un unique caractère à la valeur
d'une String
en utilisant push
Après l'exécution, s
contiendra lol
.
Concaténation avec l'opérateur +
ou la macro format!
Souvent, vous aurez besoin de combiner deux chaînes de caractères existantes.
Une façon de faire cela est d'utiliser l'opérateur +
, comme dans l'encart
8-18.
fn main() { let s1 = String::from("Hello, "); let s2 = String::from("world!"); let s3 = s1 + &s2; // notez que s1 a été déplacé ici // et ne pourra plus être utilisé }
Encart 8-18 : Utilisation de l'opérateur +
pour combiner
deux valeurs de String
La chaîne de caractères s3
va contenir Hello, world!
. La raison pour
laquelle s1
n'est plus utilisable après avoir été ajouté, et pour laquelle
nous utilisons une référence vers s2
, est la signature de la méthode qui est
appelée lorsque nous utilisons l'opérateur +
. L'opérateur +
utilise la
méthode add
, dont la signature ressemble à ceci :
fn add(self, s: &str) -> String {
Dans la bibliothèque standard, vous pouvez constater que add
est défini en
utilisant des génériques. Ici, nous avons remplacé par des types concrets à la
place des génériques, ce qui se passe lorsque nous utilisons cette méthode avec
des valeurs de type String
. Nous verrons la généricité au chapitre 10. Cette
signature nous donne les éléments dont nous avons besoin pour comprendre les
subtilités de l'opérateur +
.
Premièrement, s2
a un &
, ce qui veut dire que nous ajoutons une référence
vers la seconde chaîne de caractères à la première chaîne. C'est à cause du
paramètre s
de la fonction add
: nous pouvons seulement ajouter un &str
à
une String
; nous ne pouvons pas ajouter deux valeurs de type String
ensemble. Mais attendez — le type de &s2
est &String
, et non pas &str
,
comme c'est écrit dans le second paramètre de add
. Alors pourquoi est-ce que
le code de l'encart 8-18 se compile ?
La raison pour laquelle nous pouvons utiliser &s2
dans l'appel à add
est que
le compilateur peut extrapoler l'argument &String
en un &str
. Lorsque nous
appelons la méthode add
, Rust va utiliser une extrapolation de
déréférencement, qui transforme ici &s2
en &s2[..]
. Nous verrons plus en
détail l'extrapolation de déréférencement au chapitre 15. Comme add
ne prend
pas possession du paramètre s
, s2
sera toujours une String
valide après
cette opération.
Ensuite, nous pouvons constater que la signature de add
prend possession de
self
, car self
n'a pas de &
. Cela signifie que s1
dans l'encart 8-18
va être déplacé dans l'appel à add
et ne sera plus en vigueur après cela. Donc
bien que let s3 = s1 + &s2
semble copier les deux chaînes de caractères pour
en créer une nouvelle, cette instruction va en réalité prendre possession de
s1
, y ajouter une copie du contenu de s2
et nous redonner la possession du
résultat. Autrement dit, cela semble faire beaucoup de copies mais en réalité
non ; son implémentation est plus efficace que la copie.
Si nous avons besoin de concaténer plusieurs chaînes de caractères, le
comportement de l'opérateur +
devient difficile à utiliser :
fn main() { let s1 = String::from("tic"); let s2 = String::from("tac"); let s3 = String::from("toe"); let s = s1 + "-" + &s2 + "-" + &s3; }
Au final, s
vaudra tic-tac-toe
. Avec tous les caractères +
et "
, il est
difficile de visualiser ce qui se passe. Pour une combinaison de chaînes de
caractères plus complexe, nous pouvons utiliser à la place la macro format!
:
fn main() { let s1 = String::from("tic"); let s2 = String::from("tac"); let s3 = String::from("toe"); let s = format!("{}-{}-{}", s1, s2, s3); }
Ce code assigne lui aussi à s
la valeur tic-tac-toe
. La macro format!
fonctionne comme println!
, mais au lieu d'afficher son résultat à l'écran,
elle retourne une String
avec son contenu. La version du code qui utilise
format!
est plus facile à lire, et le code généré par la macro format!
utilise des références afin qu'il ne prenne pas possession de ses paramètres.
L'indexation des Strings
Dans de nombreux autres langages de programmation, l'accès individuel aux
caractères d'une chaîne de caractères en utilisant leur indice est une opération
valide et courante. Cependant, si vous essayez d'accéder à des éléments d'une
String
en utilisant la syntaxe d'indexation avec Rust, vous allez avoir une
erreur. Nous tentons cela dans le code invalide de l'encart 8-19.
fn main() {
let s1 = String::from("hello");
let h = s1[0];
}
Encart 8-19 : Tentative d'utilisation de la syntaxe
d'indexation avec une String
Ce code va produire l'erreur suivante :
$ cargo run
Compiling collections v0.1.0 (file:///projects/collections)
error[E0277]: the type `String` cannot be indexed by `{integer}`
--> src/main.rs:3:13
|
3 | let h = s1[0];
| ^^^^^ `String` cannot be indexed by `{integer}`
|
= help: the trait `Index<{integer}>` is not implemented for `String`
For more information about this error, try `rustc --explain E0277`.
error: could not compile `collections` due to previous error
L'erreur et la remarque nous expliquent le problème : les String
de Rust
n'acceptent pas l'utilisation des indices. Mais pourquoi ? Pour répondre à cette
question, nous avons besoin de savoir comment Rust enregistre les chaînes de
caractères dans la mémoire.
Représentation interne
Une String
est une surcouche de Vec<u8>
. Revenons sur certains exemples de
chaînes de caractères correctement encodées en UTF-8 que nous avions dans
l'encart 8-14. Premièrement, celle-ci :
fn main() { let bonjour = String::from("السلام عليكم"); let bonjour = String::from("Dobrý den"); let bonjour = String::from("Hello"); let bonjour = String::from("שָׁלוֹם"); let bonjour = String::from("नमस्ते"); let bonjour = String::from("こんにちは"); let bonjour = String::from("안녕하세요"); let bonjour = String::from("你好"); let bonjour = String::from("Olá"); let bonjour = String::from("Здравствуйте"); let bonjour = String::from("Hola"); }
Dans ce cas-ci, len
vaudra 4, ce qui veut dire que le vecteur qui stocke la
chaîne “Hola” a une taille de 4 octets. Chacune des lettres prend 1 octet
lorsqu'elles sont encodées en UTF-8. Cependant, la ligne suivante peut
surprendre. (Notez que cette chaîne de caractères commence avec la lettre
majuscule cyrillique Zé, et non pas le chiffre arabe 3.)
fn main() { let bonjour = String::from("السلام عليكم"); let bonjour = String::from("Dobrý den"); let bonjour = String::from("Hello"); let bonjour = String::from("שָׁלוֹם"); let bonjour = String::from("नमस्ते"); let bonjour = String::from("こんにちは"); let bonjour = String::from("안녕하세요"); let bonjour = String::from("你好"); let bonjour = String::from("Olá"); let bonjour = String::from("Здравствуйте"); let bonjour = String::from("Hola"); }
Si on vous demandait la longueur de la chaîne de caractères, vous répondriez probablement 12. En réalité, la réponse de Rust sera 24 : c'est le nombre d'octets nécessaires pour encoder “Здравствуйте” en UTF-8, car chaque valeur scalaire Unicode dans cette chaîne de caractères prend 2 octets en mémoire. Par conséquent, un indice dans les octets de la chaîne de caractères ne correspondra pas forcément à une valeur scalaire Unicode valide. Pour démontrer cela, utilisons ce code Rust invalide :
let bonjour = "Здравствуйте";
let reponse = &bonjour[0];
Vous savez déjà que reponse
ne vaudra pas З
, la première lettre. Lorsqu'il
est encodé en UTF-8, le premier octet de З
est 208
et le second est 151
,
donc on dirait que reponse
vaudrait 208
, mais 208
n'est pas un caractère
valide à lui seul. Retourner 208
n'est pas ce qu'un utilisateur attend s'il
demande la première lettre de cette chaîne de caractères ; cependant, c'est la
seule valeur que Rust a à l'indice 0 des octets. Les utilisateurs ne souhaitent
généralement pas obtenir la valeur d'un octet, même si la chaîne de caractères
contient uniquement des lettres latines : si &"hello"[0]
était un code valide
qui retournait la valeur de l'octet, il retournerait 104
et non pas h
.
La solution est donc, pour éviter de retourner une valeur inattendue et générer des bogues qui ne seraient pas découverts immédiatement, que Rust ne va pas compiler ce code et va ainsi éviter des erreurs dès le début du processus de développement.
Des octets, des valeurs scalaires et des groupes de graphèmes !? Oh mon Dieu !
Un autre problème avec l'UTF-8 est qu'il a en fait trois manières pertinentes de considérer les chaînes de caractères avec Rust : comme des octets, comme des valeurs scalaires ou comme des groupes de graphèmes (ce qui se rapproche le plus de ce que nous pourrions appeler des lettres).
Si l'on considère le mot hindi “नमस्ते” écrit en écriture devanagari, il est
stocké comme un vecteur de valeurs u8
qui sont les suivantes :
[224, 164, 168, 224, 164, 174, 224, 164, 184, 224, 165, 141, 224, 164, 164,
224, 165, 135]
Cela fait 18 octets et c'est ainsi que les ordinateurs stockeront cette donnée.
Si nous les voyons comme des valeurs scalaires Unicode, ce qu'est le type char
de Rust, ces octets seront les suivants :
['न', 'म', 'स', '्', 'त', 'े']
Nous avons six valeurs char
ici, mais les quatrième et sixième valeurs ne sont
pas des lettres : ce sont des signes diacritiques qui n'ont pas de sens employés
seuls. Enfin, si nous les voyons comme des groupes de graphèmes, on obtient ce
qu'on pourrait appeler les quatre lettres qui constituent le mot hindi :
["न", "म", "स्", "ते"]
Rust fournit différentes manières d'interpréter les données brutes des chaînes de caractères que les ordinateurs stockent afin que chaque programme puisse choisir l'interprétation dont il a besoin, peu importe la langue dans laquelle sont les données.
Une dernière raison pour laquelle Rust ne nous autorise pas à indexer une
String
pour récupérer un caractère est que les opérations d'indexation sont
censées prendre un temps constant (O(1)). Mais il n'est pas possible de garantir
cette performance avec une String
, car Rust devrait parcourir le contenu
depuis le début jusqu'à l'indice pour déterminer combien il y a de caractères
valides.
Les slices de chaînes de caractères
L'indexation sur une chaîne de caractères est souvent une mauvaise idée car le type de retour de l'opération n'est pas toujours évident : un octet, un caractère, un groupe de graphèmes ou une slice de chaîne de caractères ? Si vous avez vraiment besoin d'utiliser des indices pour créer des slices de chaînes, Rust vous demande plus de précisions.
Plutôt que d'utiliser []
avec un nombre seul, vous pouvez utiliser []
avec
un intervalle d'indices pour créer une slice de chaîne contenant des octets
bien précis, plutôt que d'utiliser []
avec un seul nombre :
#![allow(unused)] fn main() { let bonjour = "Здравствуйте"; let s = &bonjour[0..4]; }
Ici, s
sera un &str
qui contiendra les 4 premiers octets de la chaîne de
caractères. Précédemment, nous avions mentionné que chacun de ces caractères
était encodé sur 2 octets, ce qui veut dire que s
vaudra Зд
.
Si vous essayons de produire une slice d'une partie des octets d'un caractère
avec quelquechose comme &bonjour[0..1]
, Rust va paniquer au moment de
l'exécution de la même façon que si nous utilisions un indice invalide pour
accéder à un élément d'un vecteur :
$ cargo run
Compiling collections v0.1.0 (file:///projects/collections)
Finished dev [unoptimized + debuginfo] target(s) in 0.43s
Running `target/debug/collections`
thread 'main' panicked at 'byte index 1 is not a char boundary; it is inside 'З' (bytes 0..2) of `Здравствуйте`', src/main.rs:4:14
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
Vous devriez utiliser les intervalles pour créer des slices avec prudence, car cela peut provoquer un plantage de votre programme.
Les méthodes pour parcourir les chaînes de caractères
La meilleure manière de travailler sur des parties de chaînes de caractères est
d'exprimer clairement si vous voulez travailler avec des caractères ou des
octets. Pour les valeurs scalaires Unicode une par une, utilisez la méthode
chars
. Appeler chars
sur “नमस्ते” sépare et retourne six valeurs de type
char
, et vous pouvez itérer sur le résultat pour accéder à chaque élément :
#![allow(unused)] fn main() { for c in "नमस्ते".chars() { println!("{}", c); } }
Ce code va afficher ceci :
न
म
स
्
त
े
Aussi, la méthode bytes
va retourner chaque octet brut, ce qui sera peut-être
plus utile selon ce que vous voulez faire :
#![allow(unused)] fn main() { for b in "नमस्ते".bytes() { println!("{}", b); } }
Ce code va afficher les 18 octets qui constituent cette String
:
224
164
// -- éléments masqués ici --
165
135
Rappelez-vous bien que des valeurs scalaires Unicode peuvent être constituées de plus d'un octet.
L'obtention des groupes de graphèmes à partir de chaînes de caractères est complexe, donc cette fonctionnalité n'est pas fournie par la bibliothèque standard. Des crates sont disponibles sur crates.io si c'est la fonctionnalité dont vous avez besoin.
Les chaînes de caractères ne sont pas si simples
Pour résumer, les chaînes de caractères sont complexes. Les différents langages
de programmation ont fait différents choix sur la façon de présenter cette
complexité aux développeurs. Rust a choisi d'appliquer par défaut la gestion
rigoureuse des données de String
pour tous les programmes Rust, ce qui veut
dire que les développeurs doivent réfléchir davantage à la gestion des données
UTF-8. Ce compromis révèle davantage la complexité des chaînes de caractères par
rapport à ce que les autres langages de programmation laissent paraître, mais
vous évite d'avoir à gérer plus tard dans votre cycle de développement des
erreurs à cause de caractères non ASCII.
Passons maintenant à quelque chose de moins complexe : les tables de hachage !
Stocker des clés associées à des valeurs dans des tables de hachage
La dernière des collections les plus courantes est la table de hachage (hash
map). Le type HashMap<K, V>
stocke une association de clés de type K
à des
valeurs de type V
en utilisant une fonction de hachage, qui détermine
comment elle va ranger ces clés et valeurs dans la mémoire. De nombreux
langages de programmation prennent en charge ce genre de structure de données,
mais elles ont souvent un nom différent, tel que hash, map, objet, table
d'association, dictionnaire ou tableau associatif, pour n'en nommer que
quelques-uns.
Les tables de hachage sont utiles lorsque vous voulez rechercher des données non pas en utilisant des indices, comme vous pouvez le faire avec les vecteurs, mais en utilisant une clé qui peut être de n'importe quel type. Par exemple, dans un jeu, vous pouvez consigner le score de chaque équipe dans une table de hachage dans laquelle chaque clé est le nom d'une équipe et la valeur est le score de l'équipe. Si vous avez le nom d'une équipe, vous pouvez récupérer son score.
Nous allons passer en revue l'API de base des tables de hachage dans cette
section, mais bien d'autres fonctionnalités se cachent dans les fonctions
définies sur HashMap<K, V>
par la bibliothèque standard. Comme d'habitude,
consultez la documentation de la bibliothèque standard pour plus d'informations.
Créer une nouvelle table de hachage
Une façon de créer une table de hachage vide est d'utiliser new
et d'ajouter
des éléments avec insert
. Dans l'encart 8-20, nous consignons les scores de
deux équipes qui s'appellent Bleu et Jaune. L'équipe Bleu commence avec 10
points, et l'équipe Jaune commence avec 50.
fn main() { use std::collections::HashMap; let mut scores = HashMap::new(); scores.insert(String::from("Bleu"), 10); scores.insert(String::from("Jaune"), 50); }
Encart 8-20 : création d'une nouvelle table de hachage et insertion de quelques clés et valeurs
Notez que nous devons d'abord importer HashMap
via use
depuis la partie des
collections de la bibliothèque standard. De nos trois collections courantes,
cette dernière est la moins utilisée, donc elle n'est pas présente dans les
fonctionnalités importées automatiquement dans la portée par l'étape
préliminaire. Les tables de hachage sont aussi moins gérées par la bibliothèque
standard ; il n'y a pas de macro intégrée pour les construire, par exemple.
Exactement comme les vecteurs, les tables de hachage stockent leurs données sur
le tas. Cette HashMap
a des clés de type String
et des valeurs de type
i32
. Et comme les vecteurs, les tables de hachage sont homogènes : toutes les
clés doivent être du même type, et toutes les valeurs doivent aussi être du
même type.
Une autre façon de construire une table de hachage est d'utiliser les itérateurs
et la méthode collect
sur un vecteur de tuples, où chaque tuple représente une
clé et sa valeur. Nous aborderons en détail les itérateurs et leurs méthodes
associées dans une section du chapitre 13. La
méthode collect
regroupe les données dans quelques types de collections, dont
HashMap
. Par exemple, si nous avions les noms des équipes et les scores
initiaux dans deux vecteurs séparés, nous pourrions utiliser la méthode zip
pour créer un itérateur de tuples où “Bleu” est associé à 10, et ainsi de suite.
Ensuite, nous pourrions utiliser la méthode collect
pour transformer cet
itérateur de tuples en table de hachage, comme dans l'encart 8-21.
fn main() { use std::collections::HashMap; let equipes = vec![String::from("Bleu"), String::from("Jaune")]; let scores_initiaux = vec![10, 50]; let mut scores: HashMap<_, _> = equipes.into_iter().zip(scores_initiaux.into_iter()).collect(); }
Encart 8-21 : création d'une table de hachage à partir d'une liste d'équipes et d'une liste de scores
L'annotation de type HashMap<_, _>
est nécessaire ici car collect
peut
générer plusieurs types de structures de données et Rust ne sait pas laquelle
vous souhaitez si vous ne le précisez pas. Mais pour les paramètres qui
correspondent aux types de clé et de valeur, nous utilisons des tirets bas, et
Rust peut déduire les types que la table de hachage contient en fonction des
types des données présentes dans les vecteurs. Dans l'encart 8-21, le type des
clés sera String
et le type des valeurs sera i32
, comme dans l'encart 8-20.
Les tables de hachage et la possession
Pour les types qui implémentent le trait Copy
, comme i32
, les valeurs sont
copiées dans la table de hachage. Pour les valeurs qui sont possédées comme
String
, les valeurs seront déplacées et la table de hachage sera la
propriétaire de ces valeurs, comme démontré dans l'encart 8-22.
fn main() { use std::collections::HashMap; let nom_champ = String::from("Couleur favorite"); let valeur_champ = String::from("Bleu"); let mut table = HashMap::new(); table.insert(nom_champ, valeur_champ); // nom_champ et valeur_champ ne sont plus en vigueur à partir d'ici, // essayez de les utiliser et vous verrez l'erreur du compilateur que // vous obtiendrez ! }
Encart 8-22 : démonstration que les clés et les valeurs sont possédées par la table de hachage une fois qu'elles sont insérées
Nous ne pouvons plus utiliser les variables nom_champ
et valeur_champ
après
qu'elles ont été déplacées dans la table de hachage lors de l'appel à insert
.
Si nous insérons dans la table de hachage des références vers des valeurs, ces valeurs ne seront pas déplacées dans la table de hachage. Les valeurs vers lesquelles les références pointent doivent rester en vigueur au moins aussi longtemps que la table de hachage. Nous verrons ces problématiques dans une section du chapitre 10.
Accéder aux valeurs dans une table de hachage
Nous pouvons obtenir une valeur d'une table de hachage en passant sa clé à la
méthode get
, comme dans l'encart 8-23.
fn main() { use std::collections::HashMap; let mut scores = HashMap::new(); scores.insert(String::from("Bleu"), 10); scores.insert(String::from("Jaune"), 50); let nom_equipe = String::from("Bleu"); let score = scores.get(&nom_equipe); }
Encart 8-23 : récupération du score de l'équipe Bleu
,
stocké dans la table de hachage
Dans notre cas, score
aura la valeur qui est associée à l'équipe Bleu
, et le
résultat sera Some(&10)
. Le résultat est encapsulé dans un Some
car get
retourne une Option<&V>
: s'il n'y a pas de valeur pour cette clé dans la
table de hachage, get
va retourner None
. Le programme doit gérer cette
Option
d'une des manières dont nous avons parlé au chapitre 6.
Nous pouvons itérer sur chaque paire de clé/valeur dans une table de hachage de
la même manière que nous le faisons avec les vecteurs, en utilisant une boucle
for
:
fn main() { use std::collections::HashMap; let mut scores = HashMap::new(); scores.insert(String::from("Bleu"), 10); scores.insert(String::from("Jaune"), 50); for (cle, valeur) in &scores { println!("{} : {}", cle, valeur); } }
Ce code va afficher chaque paire dans un ordre arbitraire :
Jaune : 50
Bleu : 10
Modifier une table de hachage
Bien que le nombre de paires de clé-valeur puisse augmenter, chaque clé ne peut être associée qu'à une seule valeur à la fois. Lorsque vous souhaitez modifier les données d'une table de hachage, vous devez choisir comment gérer le cas où une clé a déjà une valeur qui lui est associée. Vous pouvez remplacer l'ancienne valeur avec la nouvelle valeur, en ignorant complètement l'ancienne valeur. Vous pouvez garder l'ancienne valeur et ignorer la nouvelle valeur, en insérant la nouvelle valeur uniquement si la clé n'a pas déjà une valeur. Ou vous pouvez fusionner l'ancienne valeur et la nouvelle. Découvrons dès maintenant comment faire chacune de ces actions !
Réécrire une valeur
Si nous ajoutons une clé et une valeur dans une table de hachage et que nous
ajoutons à nouveau la même clé avec une valeur différente, la valeur associée
à cette clé sera remplacée. Même si le code dans l'encart 8-24 appelle deux
fois insert
, la table de hachage contiendra une seule paire de clé/valeur car
nous ajoutons la valeur pour l'équipe Bleu
à deux reprises.
fn main() { use std::collections::HashMap; let mut scores = HashMap::new(); scores.insert(String::from("Bleu"), 10); scores.insert(String::from("Bleu"), 25); println!("{:?}", scores); }
Encart 8-24 : remplacement d'une valeur stockée sous une clé spécifique
Ce code va afficher {"Bleu": 25}
. La valeur initiale 10
a été remplacée.
Ajouter une valeur seulement si la clé n'a pas déjà de valeur
Il est courant de vérifier si une clé spécifique a déjà une valeur, et si ce
n'est pas le cas, de lui associer une valeur. Les tables de hachage ont une API
spécifique pour ce cas-là qui s'appelle entry
et qui prend en paramètre la
clé que vous voulez vérifier. La valeur de retour de la méthode entry
est une
énumération qui s'appelle Entry
qui représente une valeur qui existe ou non.
Imaginons que nous souhaitons vérifier si la clé pour l'équipe Jaune
a une
valeur qui lui est associée. Si ce n'est pas le cas, nous voulons lui associer
la valeur 50, et faire de même pour l'équipe Bleu
. En utilisant l'API entry
,
ce code va ressembler à l'encart 8-25.
fn main() { use std::collections::HashMap; let mut scores = HashMap::new(); scores.insert(String::from("Bleu"), 10); scores.entry(String::from("Jaune")).or_insert(50); scores.entry(String::from("Bleu")).or_insert(50); println!("{:?}", scores); }
Encart 8-25 : utilisation de la méthode entry
pour
ajouter la clé uniquement si elle n'a pas déjà de valeur associée
La méthode or_insert
sur Entry
est conçue pour retourner une référence
mutable vers la valeur correspondant à la clé du Entry
si cette clé existe,
et sinon, d'ajouter son paramètre comme nouvelle valeur pour cette clé et
retourner une référence mutable vers la nouvelle valeur. Cette technique est
plus propre que d'écrire la logique nous-mêmes et, de plus, elle fonctionne
mieux avec le vérificateur d'emprunt.
L'exécution du code de l'encart 8-25 va afficher {"Jaune": 50, "Bleu": 10}
.
Le premier appel à entry
va ajouter la clé pour l'équipe Jaune
avec la
valeur 50
car l'équipe Jaune
n'a pas encore de valeur. Le second appel à
entry
ne va pas changer la table de hachage car l'équipe Bleu
a déjà la
valeur 10
.
Modifier une valeur en fonction de l'ancienne valeur
Une autre utilisation courante avec les tables de hachage est de regarder la
valeur d'une clé et ensuite la modifier en fonction de l'ancienne valeur. Par
exemple, l'encart 8-26 contient du code qui compte combien de fois chaque mot
apparaît dans du texte. Nous utilisons une table de hachage avec les mots comme
clés et nous incrémentons la valeur pour compter combien de fois nous avons vu
ce mot. Si c'est la première fois que nous voyons un mot, nous allons d'abord
insérer la valeur 0
.
fn main() { use std::collections::HashMap; let texte = "bonjour le monde magnifique monde"; let mut table = HashMap::new(); for mot in texte.split_whitespace() { let compteur = table.entry(mot).or_insert(0); *compteur += 1; } println!("{:?}", table); }
Encart 8-26 : comptage des occurrences des mots en utilisant une table de hachage qui stocke les mots et leur quantité
Ce code va afficher {"monde": 2, "bonjour": 1, "magnifique": 1, "le": 1}
. La
méthode split_whitespace
va itérer sur les sous-slices, séparées par des
espaces vides, sur la valeur dans texte
. La méthode or_insert
retourne une
référence mutable (&mut V
) vers la valeur de la clé spécifiée. Nous stockons
ici la référence mutable dans la variable compteur
, donc pour affecter une
valeur, nous devons d'abord déréférencer compteur
en utilisant l'astérisque
(*
). La référence mutable sort de la portée à la fin de la boucle for
, donc
tous ces changements sont sûrs et autorisés par les règles d'emprunt.
Fonctions de hachage
Par défaut, HashMap
utilise une fonction de hachage nommée SipHash qui résiste
aux attaques par déni de service (DoS) envers les tables de hachage1.
Ce n'est pas l'algorithme de hachage le plus rapide qui existe, mais le
compromis entre une meilleure sécurité et la baisse de performances en vaut la
peine. Si vous analysez la performance de votre code et que vous vous rendez
compte que la fonction de hachage par défaut est trop lente pour vos besoins,
vous pouvez la remplacer par une autre fonction en spécifiant un hacheur
différent. Un hacheur est un type qui implémente le trait BuildHasher
. Nous
verrons les traits et comment les implémenter au chapitre 10. Vous n'avez pas
forcément besoin d'implémenter votre propre hacheur à partir de zéro ;
crates.io héberge des bibliothèques partagées par d'autres
utilisateurs de Rust qui fournissent de nombreux algorithmes de hachage
répandus.
Résumé
Les vecteurs, Strings, et tables de hachage vont vous apporter de nombreuses fonctionnalités nécessaires à vos programmes lorsque vous aurez besoin de stocker, accéder, et modifier des données. Voici quelques exercices que vous devriez maintenant être en mesure de résoudre :
- À partir d'une liste d'entiers, utiliser un vecteur et retourner la médiane (la valeur au milieu lorsque la liste est triée) et le mode (la valeur qui apparaît le plus souvent ; une table de hachage sera utile dans ce cas) de la liste.
- Convertir des chaînes de caractères dans une variante du louchébem.
La consonne initiale de chaque mot est remplacée par la lettre
l
et est rétablie à la fin du mot suivie du suffixe argotique “em” ; ainsi, “bonjour” devient “lonjourbem”. Si le mot commence par une voyelle, ajouter unl
au début du mot et ajouter à la fin le suffixe “muche”. Et gardez en tête les détails à propos de l'encodage UTF-8 ! - En utilisant une table de hachage et des vecteurs, créez une interface textuelle pour permettre à un utilisateur d'ajouter des noms d'employés dans un département d'une entreprise. Par exemple, “Ajouter Sally au bureau d'études” ou “Ajouter Amir au service commercial”. Ensuite, donnez la possibilité à l'utilisateur de récupérer une liste de toutes les personnes dans un département, ou tout le monde dans l'entreprise trié par département, et classés dans l'ordre alphabétique dans tous les cas.
La documentation de l'API de la bibliothèque standard décrit les méthodes qu'ont les vecteurs, chaînes de caractères et tables de hachage, ce qui vous sera bien utile pour mener à bien ces exercices !
Nous nous lançons dans des programmes de plus en plus complexes dans lesquels les opérations peuvent échouer, c'est donc le moment idéal pour voir comment bien gérer les erreurs. C'est ce que nous allons faire au prochain chapitre !
La gestion des erreurs
Les erreurs font partie de la vie des programmes informatiques, c'est pourquoi Rust a des fonctionnalités pour gérer les situations dans lesquelles quelque chose dérape. Dans de nombreux cas, Rust exige que vous anticipiez les erreurs possibles et que vous preniez des dispositions avant de pouvoir compiler votre code. Cette exigence rend votre programme plus résiliant en s'assurant que vous détectez et gérez les erreurs correctement avant même que vous ne déployiez votre code en production !
Rust classe les erreurs dans deux catégories principales : les erreurs récupérables et irrécupérables. Pour les erreurs récupérables, comme l'erreur le fichier n'a pas été trouvé, nous préférons probablement signaler le problème à l'utilisateur et relancer l'opération. Les erreurs irrécupérables sont toujours des symptômes de bogues, comme par exemple essayer d'accéder à un élément en dehors de l'intervalle de données d'un tableau, et alors dans ce cas nous voulons arrêter immédiatement l'exécution du programme.
La plupart des langages de programmation ne font pas de distinction entre ces
deux types d'erreurs et les gèrent de la même manière, en utilisant des
fonctionnalités comme les exceptions. Rust n'a pas d'exception. À la place, il
a les types Result<T, E>
pour les erreurs récupérables, et la macro panic!
qui arrête l'exécution quand le programme se heurte à des erreurs
irrécupérables. Nous allons commencer ce chapitre par expliquer l'utilisation de
panic!
, puis nous allons voir les valeurs de retour Result<T, E>
. Enfin,
nous allons voir les éléments à prendre en compte pour décider si nous devons
essayer de rattraper une erreur ou alors arrêter l'exécution.
Les erreurs irrécupérables avec panic!
Parfois, des choses se passent mal dans votre code, et vous ne pouvez rien y
faire. Pour ces cas-là, Rust a la macro panic!
. Quand la macro panic!
s'exécute, votre programme va afficher un message d'erreur, dérouler et
nettoyer la pile, et ensuite fermer le programme. Nous allons souvent faire
paniquer le programme lorsqu'un bogue a été détecté, et qu"on ne sait comment
gérer cette erreur au moment de l'écriture de notre programme.
Dérouler la pile ou abandonner suite à un
panic!
Par défaut, quand un panic se produit, le programme se met à dérouler, ce qui veut dire que Rust retourne en arrière dans la pile et nettoie les données de chaque fonction qu'il rencontre sur son passage. Cependant, cette marche arrière et le nettoyage demandent beaucoup de travail. Toutefois, Rust vous permet de choisir l'alternative d'abandonner immédiatement, ce qui arrête le programme sans nettoyage. La mémoire qu'utilisait le programme devra ensuite être nettoyée par le système d'exploitation. Si dans votre projet vous avez besoin de construire un exécutable le plus petit possible, vous pouvez passer du déroulage à l'abandon lors d'un panic en ajoutant
panic = 'abort'
aux sections[profile]
appropriées dans votre fichier Cargo.toml. Par exemple, si vous souhaitez abandonner lors d'un panic en mode publication (release), ajoutez ceci :[profile.release] panic = 'abort'
Essayons d'appeler panic!
dans un programme simple :
Fichier : src/main.rs
fn main() { panic!("crash and burn"); }
Lorsque vous lancez le programme, vous allez voir quelque chose comme ceci :
$ cargo run
Compiling panic v0.1.0 (file:///projects/panic)
Finished dev [unoptimized + debuginfo] target(s) in 0.25s
Running `target/debug/panic`
thread 'main' panicked at 'crash and burn', src/main.rs:2:5
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
L'appel à panic!
déclenche le message d'erreur présent dans les deux dernières
lignes. La première ligne affiche notre message associé au panic et
l'emplacement dans notre code source où se produit le panic : src/main.rs:2:5
indique que c'est à la seconde ligne et au cinquième caractère de notre fichier
src/main.rs.
Dans cet exemple, la ligne indiquée fait partie de notre code, et si nous
allons voir cette ligne, nous verrons l'appel à la macro panic!
. Dans d'autres
cas, l'appel de panic!
pourrait se produire dans du code que notre code
utilise. Le nom du fichier et la ligne indiquée par le message d'erreur seront
alors ceux du code de quelqu'un d'autre où la macro panic!
est appelée, et non
pas la ligne de notre code qui nous a mené à cet appel de panic!
. Nous pouvons
utiliser le retraçage des fonctions qui ont appelé panic!
pour repérer la
partie de notre code qui pose problème. Nous allons maintenant parler plus en
détail du retraçage.
Utiliser le retraçage de panic!
Analysons un autre exemple pour voir ce qui se passe lors d'un appel de panic!
qui se produit dans une bibliothèque à cause d'un bogue dans notre code plutôt
qu'un appel à la macro directement. L'encart 9-1 montre du code qui essaye
d'accéder à un indice d'un vecteur en dehors de l'intervalle des indices
valides.
Fichier : src/main.rs
fn main() { let v = vec![1, 2, 3]; v[99]; }
Encart 9-1 : tentative d'accès à un élément qui dépasse de
l'intervalle d'un vecteur, ce qui provoque un panic!
Ici, nous essayons d'accéder au centième élément de notre vecteur (qui est à
l'indice 99 car l'indexation commence à zéro), mais le vecteur a seulement
trois éléments. Dans ce cas, Rust va paniquer. Utiliser []
est censé
retourner un élément, mais si vous lui donnez un indice invalide, Rust ne
pourra pas retourner un élément acceptable dans ce cas.
En C, tenter de lire au-delà de la fin d'une structure de données suit un comportement indéfini. Vous pourriez récupérer la valeur à l'emplacement mémoire qui correspondrait à l'élément demandé de la structure de données, même si cette partie de la mémoire n'appartient pas à cette structure de données. C'est ce qu'on appelle une lecture hors limites et cela peut mener à des failles de sécurité si un attaquant a la possibilité de contrôler l'indice de telle manière qu'il puisse lire les données qui ne devraient pas être lisibles en dehors de la structure de données.
Afin de protéger votre programme de ce genre de vulnérabilité, si vous essayez de lire un élément à un indice qui n'existe pas, Rust va arrêter l'exécution et refuser de continuer. Essayez et vous verrez :
$ cargo run
Compiling panic v0.1.0 (file:///projects/panic)
Finished dev [unoptimized + debuginfo] target(s) in 0.27s
Running `target/debug/panic`
thread 'main' panicked at 'index out of bounds: the len is 3 but the index is 99', src/main.rs:4:5
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
Cette erreur mentionne la ligne 4 de notre fichier main.rs où on essaie
d'accéder à l'indice 99. La ligne suivante nous informe que nous pouvons régler
la variable d'environnement RUST_BACKTRACE
pour obtenir le retraçage de ce qui
s'est exactement passé pour mener à cette erreur. Un retraçage consiste à
lister toutes les fonctions qui ont été appelées pour arriver jusqu'à ce point.
En Rust, le retraçage fonctionne comme dans d'autres langages : le secret pour
lire le retraçage est de commencer d'en haut et lire jusqu'à ce que vous voyiez
les fichiers que vous avez écrits. C'est l'endroit où s'est produit le problème.
Les lignes avant cet endroit est du code qui a été appelé par votre propre
code ; les lignes qui suivent représentent le code qui a appelé votre code. Ces
lignes "avant et après" peuvent être du code du cœur de Rust, du code de la
bibliothèque standard, ou des crates que vous utilisez. Essayons d'obtenir un
retraçage en réglant la variable d'environnement RUST_BACKTRACE
à n'importe
quelle valeur autre que 0. L'encart 9-2 nous montre un retour similaire à ce
que vous devriez voir :
$ RUST_BACKTRACE=1 cargo run
thread 'main' panicked at 'index out of bounds: the len is 3 but the index is 99', src/main.rs:4:5
stack backtrace:
0: rust_begin_unwind
at /rustc/7eac88abb2e57e752f3302f02be5f3ce3d7adfb4/library/std/src/panicking.rs:483
1: core::panicking::panic_fmt
at /rustc/7eac88abb2e57e752f3302f02be5f3ce3d7adfb4/library/core/src/panicking.rs:85
2: core::panicking::panic_bounds_check
at /rustc/7eac88abb2e57e752f3302f02be5f3ce3d7adfb4/library/core/src/panicking.rs:62
3: <usize as core::slice::index::SliceIndex<[T]>>::index
at /rustc/7eac88abb2e57e752f3302f02be5f3ce3d7adfb4/library/core/src/slice/index.rs:255
4: core::slice::index::<impl core::ops::index::Index<I> for [T]>::index
at /rustc/7eac88abb2e57e752f3302f02be5f3ce3d7adfb4/library/core/src/slice/index.rs:15
5: <alloc::vec::Vec<T> as core::ops::index::Index<I>>::index
at /rustc/7eac88abb2e57e752f3302f02be5f3ce3d7adfb4/library/alloc/src/vec.rs:1982
6: panic::main
at ./src/main.rs:4
7: core::ops::function::FnOnce::call_once
at /rustc/7eac88abb2e57e752f3302f02be5f3ce3d7adfb4/library/core/src/ops/function.rs:227
note: Some details are omitted, run with `RUST_BACKTRACE=full` for a verbose backtrace.
Encart 9-2 : le retraçage généré par l'appel de panic!
qui s'affiche quand la variable d'environnement RUST_BACKTRACE
est définie
Cela fait beaucoup de contenu ! Ce que vous voyez sur votre machine peut être
différent en fonction de votre système d'exploitation et de votre version de
Rust. Pour avoir le retraçage avec ces informations, les symboles de débogage
doivent être activés. Les symboles de débogage sont activés par défaut quand on
utilise cargo build
ou cargo run
sans le drapeau --release
, comme c'est le
cas ici.
Dans l'encart 9-2, la ligne 6 du retraçage nous montre la ligne de notre projet qui provoque le problème : la ligne 4 de src/main.rs. Si nous ne voulons pas que notre programme panique, le premier endroit que nous devrions inspecter est l'emplacement cité par la première ligne qui mentionne du code que nous avons écrit. Dans l'encart 9-1, où nous avons délibérément écrit du code qui panique, la solution pour ne pas paniquer est de ne pas demander un élément en dehors de l'intervalle des indices du vecteur. À l'avenir, quand votre code paniquera, vous aurez besoin de prendre des dispositions dans votre code pour les valeurs qui font paniquer et de coder quoi faire lorsque cela se produit.
Nous reviendrons sur le cas du panic!
et sur les cas où nous devrions et ne
devrions pas utiliser panic!
pour gérer les conditions d'erreur plus tard
à la fin de ce chapitre. Pour le
moment, nous allons voir comment gérer une erreur en utilisant Result
.
Des erreurs récupérables avec Result
La plupart des erreurs ne sont pas assez graves au point d'arrêter complètement le programme. Parfois, lorsqu'une fonction échoue, c'est pour une raison que vous pouvez facilement comprendre et pour laquelle vous pouvez agir en conséquence. Par exemple, si vous essayez d'ouvrir un fichier et que l'opération échoue parce que le fichier n'existe pas, vous pourriez vouloir créer le fichier plutôt que d'arrêter le processus.
Souvenez-vous de la section “Gérer les erreurs potentielles avec le type
Result
” du chapitre 2 que l'énumération
Result
possède deux variantes, Ok
et Err
, comme ci-dessous :
#![allow(unused)] fn main() { enum Result<T, E> { Ok(T), Err(E), } }
Le T
et le E
sont des paramètres de type génériques : nous parlerons plus en
détail de la généricité au chapitre 10. Tout ce que vous avez besoin de savoir
pour le moment, c'est que T
représente le type de valeur imbriquée dans la
variante Ok
qui sera retournée dans le cas d'un succès, et E
représente le
type d'erreur imbriquée dans la variante Err
qui sera retournée dans le cas
d'un échec. Comme Result
a ces paramètres de type génériques, nous pouvons
utiliser le type Result
et les fonctions associées dans différentes
situations où la valeur de succès et la valeur d'erreur peuvent varier.
Utilisons une fonction qui retourne une valeur de type Result
car la fonction
peut échouer. Dans l'encart 9-3, nous essayons d'ouvrir un fichier :
Fichier : src/main.rs
use std::fs::File; fn main() { let f = File::open("hello.txt"); }
Encart 9-3 : ouverture d'un fichier
Comment savons-nous que File::open
retourne un Result
? Nous pouvons
consulter la documentation de l'API de la bibliothèque
standard, ou nous
pouvons demander au compilateur ! Si nous appliquons à f
une annotation de
type dont nous savons qu'elle n'est pas le type de retour de la fonction et
que nous essayons ensuite de compiler le code, le compilateur va nous dire que
les types ne correspondent pas. Le message d'erreur va ensuite nous dire quel
est le type de f
. Essayons cela ! Nous savons que le type de retour de
File::open
n'est pas u32
, alors essayons de changer l'instruction let f
par ceci :
use std::fs::File;
fn main() {
let f: u32 = File::open("hello.txt");
}
Tenter de compiler ce code nous donne maintenant le résultat suivant :
$ cargo run
Compiling error-handling v0.1.0 (file:///projects/error-handling)
error[E0308]: mismatched types
--> src/main.rs:4:18
|
4 | let f: u32 = File::open("hello.txt");
| --- ^^^^^^^^^^^^^^^^^^^^^^^ expected `u32`, found enum `Result`
| |
| expected due to this
|
= note: expected type `u32`
found enum `Result<File, std::io::Error>`
For more information about this error, try `rustc --explain E0308`.
error: could not compile `error-handling` due to previous error
Cela nous dit que le type de retour de la fonction File::open
est de la forme
Result<T, E>
. Le paramètre générique T
a été remplacé dans ce cas par le
type en cas de succès, std::fs::File
, qui permet d'interagir avec le fichier.
Le E
utilisé pour la valeur d'erreur est du type std::io::Error
.
Ce type de retour veut dire que l'appel à File::open
peut réussir et nous
retourner un manipulateur de fichier qui peut nous permettre de le lire ou d'y
écrire. L'utilisation de cette fonction peut aussi échouer : par exemple, si le
fichier n'existe pas, ou si nous n'avons pas le droit d'accéder au fichier. La
fonction File::open
doit avoir un moyen de nous dire si son utilisation a
réussi ou échoué et en même temps nous fournir soit le manipulateur de fichier,
soit des informations sur l'erreur. C'est exactement ces informations que
l'énumération Result
se charge de nous transmettre.
Dans le cas où File::open
réussit, la valeur que nous obtiendrons dans la
variable f
sera une instance de Ok
qui contiendra un manipulateur de
fichier. Dans le cas où cela échoue, la valeur dans f
sera une instance de
Err
qui contiendra plus d'information sur le type d'erreur qui a eu lieu.
Nous avons besoin d'ajouter différentes actions dans le code de l'encart 9-3 en
fonction de la valeur que File::open
retourne. L'encart 9-4 montre une façon
de gérer le Result
en utilisant un outil basique, l'expression match
que
nous avons vue au chapitre 6.
Fichier : src/main.rs
use std::fs::File; fn main() { let f = File::open("hello.txt"); let f = match f { Ok(fichier) => fichier, Err(erreur) => panic!("Erreur d'ouverture du fichier : {:?}", erreur), }; }
Encart 9-4 : utilisation de l'expression match
pour
gérer les variantes de Result
qui peuvent être retournées
Remarquez que, tout comme l'énumération Option
, l'énumération Result
et ses
variantes ont été importées par l'étape préliminaire, donc vous n'avez pas
besoin de préciser Result::
devant les variantes Ok
et Err
dans les
branches du match
.
Lorsque le résultat est Ok
, ce code va retourner la valeur fichier
contenue
dans la variante Ok
, et nous assignons ensuite cette valeur à la variable
f
. Après le match
, nous pourrons ensuite utiliser le manipulateur de
fichier pour lire ou écrire.
L'autre branche du bloc match
gère le cas où nous obtenons un Err
à l'appel
de File::open
. Dans cet exemple, nous avons choisi de faire appel à la macro
panic!
. S'il n'y a pas de fichier qui s'appelle hello.txt dans notre
répertoire actuel et que nous exécutons ce code, nous allons voir la sortie
suivante suite à l'appel de la macro panic!
:
$ cargo run
Compiling error-handling v0.1.0 (file:///projects/error-handling)
Finished dev [unoptimized + debuginfo] target(s) in 0.73s
Running `target/debug/error-handling`
thread 'main' panicked at 'Erreur d'ouverture du fichier : Os { code: 2, kind: NotFound, message: "No such file or directory" }', src/main.rs:8:24
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
Comme d'habitude, cette sortie nous explique avec précision ce qui s'est mal passé.
Gérer les différentes erreurs
Le code dans l'encart 9-4 va faire un panic!
peu importe la raison de l'échec
de File::open
. Cependant, nous voulons réagir différemment en fonction de
différents cas d'erreurs : si File::open
a échoué parce que le
fichier n'existe pas, nous voulons créer le fichier et retourner le manipulateur
de fichier pour ce nouveau fichier. Si File::open
échoue pour toute autre
raison, par exemple si nous n'avons pas l'autorisation d'ouvrir le fichier,
nous voulons quand même que le code lance un panic!
de la même manière qu'il
l'a fait dans l'encart 9-4. C'est pourquoi nous avons ajouté dans l'encart 9-5
une expression match
imbriquée :
Fichier : src/main.rs
use std::fs::File;
use std::io::ErrorKind;
fn main() {
let f = File::open("hello.txt");
let f = match f {
Ok(fichier) => fichier,
Err(erreur) => match erreur.kind() {
ErrorKind::NotFound => match File::create("hello.txt") {
Ok(fc) => fc,
Err(e) => panic!("Erreur de création du fichier : {:?}", e),
},
autre_erreur => {
panic!("Erreur d'ouverture du fichier : {:?}", autre_erreur)
}
},
};
}
Encart 9-5 : gestion des différents cas d'erreurs avec des actions différentes
La valeur de retour de File::open
logée dans la variante Err
est de type
io::Error
, qui est une structure fournie par la bibliothèque standard. Cette
structure a une méthode kind
que nous pouvons appeler pour obtenir une valeur
de type io::ErrorKind
. L'énumération io::ErrorKind
est fournie elle aussi
par la bibliothèque standard et a des variantes qui représentent les différents
types d'erreurs qui pourraient résulter d'une opération provenant du module
io
. La variante que nous voulons utiliser est ErrorKind::NotFound
, qui
indique que le fichier que nous essayons d'ouvrir n'existe pas encore. Donc nous
utilisons match
sur f
, mais nous avons dans celle-ci un autre match
sur
erreur.kind()
.
Nous souhaitons vérifier dans le match
interne si la valeur de retour de
error.kind()
est la variante NotFound
de l'énumération ErrorKind
. Si c'est
le cas, nous essayons de créer le fichier avec File::create
. Cependant, comme
File::create
peut aussi échouer, nous avons besoin d'une seconde branche dans
le match
interne. Lorsque le fichier ne peut pas être créé, un message
d'erreur différent est affiché. La seconde branche du match
principal reste
inchangée, donc le programme panique lorsqu'on rencontre une autre erreur que
l'absence de fichier.
D'autres solutions pour utiliser
match
avecResult<T, E>
Cela commence à faire beaucoup de
match
! L'expressionmatch
est très utile mais elle est aussi assez rudimentaire. Dans le chapitre 13, vous en apprendrez plus sur les fermetures, qui sont utilisées avec de nombreuses méthodes définies surResult<T, E>
. Ces méthodes peuvent s'avérer être plus concises que l'utilisation dematch
lorsque vous travaillez avec des valeursResult<T, E>
dans votre code.Par exemple, voici une autre manière d'écrire la même logique que celle dans l'encart 9-5 mais en utilisant les fermetures et la méthode
unwrap_or_else
:use std::fs::File; use std::io::ErrorKind; fn main() { let f = File::open("hello.txt").unwrap_or_else(|erreur| { if erreur.kind() == ErrorKind::NotFound { File::create("hello.txt").unwrap_or_else(|erreur| { panic!("Erreur de création du fichier : {:?}", erreur); }) } else { panic!("Erreur d'ouverture du fichier : {:?}", erreur); } }); }
Bien que ce code ait le même comportement que celui de l'encart 9-5, il ne contient aucune expression
match
et est plus facile à lire. Revenez sur cet exemple après avoir lu le chapitre 13, et renseignez-vous sur la méthodeunwrap_or_else
dans la documentation de la bibliothèque standard. De nombreuses méthodes de ce type peuvent clarifier de grosses expressionsmatch
imbriquées lorsque vous traitez les erreurs.
Raccourcis pour faire un panic lors d'une erreur : unwrap
et expect
L'utilisation de match
fonctionne assez bien, mais elle peut être un peu
verbeuse et ne communique pas forcément bien son intention. Le type
Result<T, E>
a de nombreuses méthodes qui lui ont été définies pour
différents cas. La méthode unwrap
est une méthode de raccourci implémentée
comme l'expression match
que nous avons écrite dans l'encart 9-4. Si la
valeur de Result
est la variante Ok
, unwrap
va retourner la valeur
contenue dans le Ok
. Si le Result
est la variante Err
, unwrap
va
appeler la macro panic!
pour nous. Voici un exemple de unwrap
en action :
Fichier : src/main.rs
use std::fs::File; fn main() { let f = File::open("hello.txt").unwrap(); }
Si nous exécutons ce code alors qu'il n'y a pas de fichier hello.txt, nous
allons voir un message d'erreur suite à l'appel à panic!
que la méthode
unwrap
a fait :
thread 'main' panicked at 'called `Result::unwrap()` on an `Err` value: Error {
repr: Os { code: 2, message: "No such file or directory" } }',
src/libcore/result.rs:906:4
De la même manière, la méthode expect
nous donne la possibilité de définir le
message d'erreur du panic!
. Utiliser expect
plutôt que unwrap
et lui
fournir un bon message d'erreur permet de mieux exprimer le problème et
faciliter la recherche de la source d'un panic. La syntaxe de expect
est la
suivante :
Fichier : src/main.rs
use std::fs::File; fn main() { let f = File::open("hello.txt").expect("Échec à l'ouverture de hello.txt"); }
Nous utilisons expect
de la même manière que unwrap
: pour retourner le
manipulateur de fichier ou appeler la macro panic!
. Le message d'erreur
utilisé par expect
lors de son appel à panic!
sera le paramètre que nous
avons passé à expect
, plutôt que le message par défaut de panic!
qu'utilise
unwrap
. Voici ce que cela donne :
thread 'main' panicked at 'Échec à l'ouverture de hello.txt: Error { repr: Os {
code: 2, message: "No such file or directory" } }', src/libcore/result.rs:906:4
Comme ce message d'erreur commence par le texte que nous avons précisé, Échec à l'ouverture de hello.txt
, ce sera plus facile de trouver là d'où provient ce
message d'erreur dans le code. Si nous utilisons unwrap
à plusieurs endroits,
cela peut prendre plus de temps de comprendre exactement quel unwrap
a causé
le panic, car tous les appels à unwrap
vont afficher le même message.
Propager les erreurs
Lorsqu'une fonction dont l'implémentation utilise quelque chose qui peut échouer, au lieu de gérer l'erreur directement dans cette fonction, vous pouvez retourner cette erreur au code qui l'appelle pour qu'il décide quoi faire. C'est ce que l'on appelle propager l'erreur et donne ainsi plus de contrôle au code qui appelle la fonction, dans lequel il peut y avoir plus d'informations ou d'instructions pour traiter l'erreur que dans le contexte de votre code.
Par exemple, l'encart 9-6 montre une fonction qui lit un pseudo à partir d'un fichier. Si ce fichier n'existe pas ou ne peut pas être lu, cette fonction va retourner ces erreurs au code qui a appelé la fonction.
Fichier : src/main.rs
#![allow(unused)] fn main() { use std::fs::File; use std::io::{self, Read}; fn lire_pseudo_depuis_fichier() -> Result<String, io::Error> { let f = File::open("hello.txt"); let mut f = match f { Ok(fichier) => fichier, Err(e) => return Err(e), }; let mut s = String::new(); match f.read_to_string(&mut s) { Ok(_) => Ok(s), Err(e) => Err(e), } } }
Encart 9-6 : une fonction qui retourne les erreurs au code
qui l'appelle en utilisant match
Cette fonction peut être écrite de façon plus concise, mais nous avons décidé de
commencer par faire un maximum de choses manuellement pour découvrir la gestion
d'erreurs ; mais à la fin, nous verrons comment raccourcir le code. Commençons
par regarder le type de retour de la fonction : Result<String, io::Error>
.
Cela signifie que la fonction retourne une valeur de type Result<T, E>
où le
paramètre générique T
a été remplacé par le type String
et le paramètre
générique E
a été remplacé par le type io::Error
. Si cette fonction réussit
sans problème, le code qui appellant va obtenir une valeur Ok
qui contient
une String
, le pseudo que cette fonction lit dans le fichier. Si cette
fonction rencontre un problème, le code qui appelle cette fonction va obtenir
une valeur Err
qui contient une instance de io::Error
qui donne plus
d'informations sur la raison du problème. Nous avons choisi io::Error
comme
type de retour de cette fonction parce qu'il se trouve que c'est le type
d'erreur de retour pour les deux opérations qui peuvent échouer que l'on utilise
dans le corps de cette fonction : la fonction File::open
et la méthode
read_to_string
.
Le corps de la fonction commence par appeler la fonction File::open
. Ensuite,
nous gérons la valeur du Result
avec un match
similaire au match
de
l'encart 9-4. Si le File::open
est un succès, le manipulateur de fichier dans
la variable fichier
du motif devient la valeur dans la variable mutable f
et la fonction continue son déroulement. Dans le cas d'un Err
, au lieu
d'appeler panic!
, nous utilisons return
pour sortir prématurément de toute
la fonction et en passant la valeur du File::open
, désormais dans la variable
e
, au code appelant comme valeur de retour de cette fonction.
Donc si nous avons un manipulateur de fichier dans f
, la fonction crée
ensuite une nouvelle String
dans la variable s
et nous appelons la méthode
read_to_string
sur le manipulateur de fichier f
pour extraire le contenu du
fichier dans s
. La méthode read_to_string
retourne aussi un Result
car
elle peut échouer, même si File::open
a réussi. Nous avons donc besoin d'un
nouveau match
pour gérer ce Result
: si read_to_string
réussit, alors
notre fonction a réussi, et nous retournons le pseudo que nous avons extrait du
fichier qui est maintenant intégré dans un Ok
, lui-même stocké dans s
. Si
read_to_string
échoue, nous retournons la valeur d'erreur de la même façon
que nous avons retourné la valeur d'erreur dans le match
qui gérait la valeur
de retour de File::open
. Cependant, nous n'avons pas besoin d'écrire
explicitement return
, car c'est la dernière expression de la fonction.
Le code qui appelle ce code va devoir ensuite gérer les cas où il récupère une
valeur Ok
qui contient un pseudo, ou une valeur Err
qui contient une
io::Error
. Il revient au code appelant de décider quoi faire avec ces
valeurs. Si le code appelant obtient une valeur Err
, il peut appeler panic!
et faire planter le programme, utiliser un pseudo par défaut, ou chercher le
pseudo autre part que dans ce fichier, par exemple. Nous n'avons pas assez
d'informations sur ce que le code appelant a l'intention de faire, donc nous
remontons toutes les informations de succès ou d'erreur pour qu'elles soient
gérées correctement.
Cette façon de propager les erreurs est si courante en Rust que Rust fournit
l'opérateur point d'interrogation ?
pour faciliter ceci.
Un raccourci pour propager les erreurs : l'opérateur ?
L'encart 9-7 montre une implémentation de lire_pseudo_depuis_fichier
qui a
les mêmes fonctionnalités que dans l'encart 9-6, mais cette implémentation
utilise l'opérateur point d'interrogation ?
:
Fichier : src/main.rs
#![allow(unused)] fn main() { use std::fs::File; use std::io; use std::io::Read; fn lire_pseudo_depuis_fichier() -> Result<String, io::Error> { let mut f = File::open("hello.txt")?; let mut s = String::new(); f.read_to_string(&mut s)?; Ok(s) } }
Encart 9-7 : une fonction qui retourne les erreurs au code
appelant en utilisant l'opérateur ?
Le ?
placé après une valeur Result
est conçu pour fonctionner presque de la
même manière que les expressions match
que nous avons définies pour gérer les
valeurs Result
dans l'encart 9-6. Si la valeur du Result
est un Ok
, la
valeur dans le Ok
sera retournée par cette expression et le programme
continuera. Si la valeur est un Err
, le Err
sera retourné par la fonction
comme si nous avions utilisé le mot-clé return
afin que la valeur d'erreur
soit propagée au code appelant.
Il y a une différence entre ce que fait l'expression match
de l'encart 9-6 et
ce que fait l'opérateur ?
: les valeurs d'erreurs sur lesquelles est utilisé
l'opérateur ?
passent par la fonction from
, définie dans le trait From
de
la bibliothèque standard, qui est utilisée pour convertir les erreurs d'un type
à un autre. Lorsque l'opérateur ?
appelle la fonction from
, le type d'erreur
reçu est converti dans le type d'erreur déclaré dans le type de retour de la
fonction concernée. C'est utile lorsqu'une fonction retourne un type d'erreur
qui peut couvrir tous les cas d'échec de la fonction, même si certaines de ses
parties peuvent échouer pour différentes raisons. À partir du moment qu'il y a
un impl From<AutreErreur>
sur ErreurRetournee
pour expliquer la conversion
dans la fonction from
du trait, l'opérateur ?
se charge d'appeler la
fonction from
automatiquement.
Dans le cas de l'encart 9-7, le ?
à la fin de l'appel à File::open
va
retourner la valeur à l'intérieur d'un Ok
à la variable f
. Si une erreur se
produit, l'opérateur ?
va quitter prématurément la fonction et retourner une
valeur Err
au code appelant. La même chose se produira au ?
à la fin de
l'appel à read_to_string
.
L'opérateur ?
allège l'écriture de code et facilite l'implémentation de la
fonction. Nous pouvons même encore plus réduire ce code en enchaînant
immédiatement les appels aux méthodes après le ?
comme dans l'encart 9-8 :
Fichier : src/main.rs
#![allow(unused)] fn main() { use std::fs::File; use std::io; use std::io::Read; fn lire_pseudo_depuis_fichier() -> Result<String, io::Error> { let mut s = String::new(); File::open("hello.txt")?.read_to_string(&mut s)?; Ok(s) } }
Encart 9-8 : enchaînement des appels aux méthodes après
l'opérateur ?
Nous avons déplacé la création de la nouvelle String
dans s
au début de la
fonction ; cette partie n'a pas changé. Au lieu de créer la variable f
, nous
enchaînons directement l'appel à read_to_string
sur le résultat de
File::open("hello.txt")?
. Nous avons toujours le ?
à la fin de l'appel à
read_to_string
, et nous retournons toujours une valeur Ok
contenant le
pseudo dans s
lorsque File::open
et read_to_string
réussissent toutes les
deux plutôt que de retourner des erreurs. Cette fonctionnalité est toujours la
même que dans l'encart 9-6 et l'encart 9-7 ; c'est juste une façon différente et
plus ergonomique de l'écrire.
L'encart 9-9 nous montre comment encore plus raccourcir tout ceci en utilisant
fs::read_to_string
.
Fichier : src/main.rs
#![allow(unused)] fn main() { use std::fs; use std::io; fn lire_pseudo_depuis_fichier() -> Result<String, io::Error> { fs::read_to_string("hello.txt") } }
Encart 9-9 : utilisation de fs::read_to_string
plutôt
que d'ouvrir puis lire le fichier
Récupérer le contenu d'un fichier dans une String
est une opération assez
courante, donc la bibliothèque standard fournit la fonction assez pratique
fs::read_to_string
, qui ouvre le fichier, crée une nouvelle String
, lit le
contenu du fichier, insère ce contenu dans cette String
, et la retourne.
Évidemment, l'utilisation de fs:read_to_string
ne nous offre pas l'occasion
d'expliquer toute la gestion des erreurs, donc nous avons d'abord utilisé la
manière la plus longue.
Où l'opérateur ?
peut être utilisé
L'opérateur ?
ne peut être utilisé uniquement que dans des fonctions dont le
type de retour compatible avec ce sur quoi le ?
est utilisé. C'est parce que
l'opérateur ?
est conçu pour retourner prématurémment une valeur de la
fonction, de la même manière que le faisait l'expression match
que nous avons
définie dans l'encart 9-6. Dans l'encart 9-6, le match
utilisait une valeur
de type Result
, et la branche de retour prématuré retournait une valeur de
type Err(e)
. Le type de retour de cette fonction doit être un Result
afin
d'être compatible avec ce return
.
Dans l'encart 9-10, découvrons l'erreur que nous allons obtenir si nous
utilisons l'opérateur ?
dans une fonction main
qui a un type de retour
incompatible avec le type de valeur sur laquelle nous utilisons ?
:
use std::fs::File;
fn main() {
let f = File::open("hello.txt")?;
}
Encart 9-10 : tentative d'utilisation du ?
dans la
fonction main
qui retourne un ()
, qui ne devrait pas pouvoir se
compiler
Ce code ouvre un fichier, ce qui devrait échouer. L'opérateur ?
est placée
derrière la valeur de type Result
retournée par File::open
, mais cette
fonction main
a un type de retour ()
et non pas Result
. Lorsque nous
compilons ce code, nous obtenons le message d'erreur suivant :
$ cargo run
Compiling error-handling v0.1.0 (file:///projects/error-handling)
error[E0277]: the `?` operator can only be used in a function that returns `Result` or `Option` (or another type that implements `FromResidual`)
--> src/main.rs:4:36
|
3 | / fn main() {
4 | | let f = File::open("hello.txt")?;
| | ^ cannot use the `?` operator in a function that returns `()`
5 | | }
| |_- this function should return `Result` or `Option` to accept `?`
|
= help: the trait `FromResidual<Result<Infallible, std::io::Error>>` is not implemented for `()`
For more information about this error, try `rustc --explain E0277`.
error: could not compile `error-handling` due to previous error
Cette erreur explique que nous sommes autorisés à utiliser l'opérateur ?
uniquement dans une fonction qui retourne Result
, Option
, ou un autre type
qui implémente FromResidual
. Pour corriger l'erreur, vous avez deux choix. Le
premier est de changer le type de retour de votre fonction pour être compatible
avec la valeur avec lequel vous utilisez l'opérateur ?
, si vous pouvez le
faire. L'autre solution est d'utiliser un match
ou une des méthodes de
Result<T, E>
pour gérer le Result<T, E>
de la manière la plus appropriée.