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 :

FerrisSignification
Ferris avec un point d'interrogationCe code ne compile pas !
Ferris qui lève ses brasCe code panique !
Ferris avec une pince en l'air, haussant les épaulesCe 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.

AnglaisFrançaisRemarques
adaptoradaptateur-
ahead-of-time compilationcompilation anticipéesigle : AOT
aliasalias-
allocatedalloué-
angle bracketchevrons-
annotateindiquer-
anti-patternanti-patron-
Appendixannexetout en minuscule (sauf en début de phrase)
appendajouter-
Application Programming Interface (API)interface de programmation applicative (API)-
assertionvérification-
assignassigner-
argumentargument / paramètre-
armbranchedans une expression match
arraytableau-
artifactartéfact-
associated functionfonction associée-
attributeattribut-
backendapplication dorsale-
backtraceretraçage-
benchmarkbenchmark-
binary cratecrate binaires'utilise au féminin
buffer overreadlecture hors limites-
n-bit numbernombre encodé sur n bits-
blanket implementationimplémentation générale-
blobblob-
boilerplate codecode standard-
booleanbooléen-
borrowemprunt(er)-
borrow checkervérificateur d'emprunt-
boxboite-
buffer overreadsur-lecture de tampon-
bugbogue-
buildcompilation-
build systemsystème de compilation-
byteoctet-
CargoCargo-
catchall valuevaleur passe-partout-
channelcanal-
Chapterchapitretout en minuscule (sauf en début de phrase)
CI systemsystème d'Intégration Continue-
clauseclause-
cleanupnettoyage-
closurefermeture-
code reviewrevue de code-
coercionextrapolation-
collectioncollection-
commandcommandedans un terminal
commitcommit-
compoundcomposé-
concept chapterchapitre théorique-
concurrencyconcurrence-
concurrentconcurrent-
concurrent programmingprogrammation concurrente-
conditionalstructure conditionnelle-
cons listliste de construction-
constantconstant / constante-
constructinstruction-
constructorconstructeur-
consuming adaptoradaptateur de consommation-
control flow constructstructure de contrôle-
core of the errormessage d'erreur-
corruptioncorruption / être corrompu-
CPUprocesseur-
crashplantage-
cratecratenom féminin (une crate)
curly bracketaccolade-
danglingpendouillant-
data raceaccès concurrent-
data representationmodèle de données-
deadlockinterblocage-
deallocatedésalloué-
debugdéboguer-
debuggingdébogage-
deep copycopie en profondeur-
dependencydépendance-
deref coercionextrapolation de déréferencement-
dereference operatoropérateur de déréférencement-
dereferencingdéréférencement-
design patternpatron de conception-
destructordestructeur-
destructuredéstructurer-
DevOpsDevOps-
directorydossier-
dot notationla notation avec un point-
double freedouble libération-
droplibérér-
elisionélision-
enuménumération-
enumerationénumération-
enum’s variantvariante d'énumération-
exploitfaille-
expressionexpression-
fieldchampd'une structure
FigureIllustration-
flagdrapeaupour les programmes en ligne de commande
floatnombre à virgule flottante-
floating-point numbernombre à virgule flottante-
frameworkenvironnement de développement-
frontendinterface frontale-
fully qualified syntaxsyntaxe totalement définie-
functionfonction-
functional programmingprogrammation fonctionnelle-
garbage collectorramasse-miettes-
genericsgénériques / généricité-
generic type parameterparamètre de type générique-
getteraccesseur-
globglobalopérateur
global scopeportée globale-
grapheme clustergroupe de graphèmes-
green threadtâche virtuelle-
guessing gamejeu de devinettes-
handleréférence abstraite-
hashhash / relatif au hachage-
hash maptable de hachage-
heaptas-
Hello, world!Hello, world!-
high-levelhaut niveau-
identifieridentificateur-
idiomaticidéal-
immutabilityimmuabilité-
immutableimmuable-
indexindice-
indexingindexation-
input/outputentrée/sortiesigle : IO
instanceinstance-
instantiateinstanciercréer une instance
integer literallittéral d'entiers-
integer overflowdépassement d'entier-
Integrated Development Environment (IDE)environnement de développement intégré (IDE)-
interior mutabilitymutabilité interne-
interrupt signalsignal d'arrêt-
invalidateneutraliser-
IOTinternet des objets (IOT)-
iteratoritérateur-
iterator adaptoradaptateur d'itération-
jobmission-
just-in-time compilationcompilation à la voléesigle : JIT
keywordmot-clé-
lazyévaluation paresseusecomportement d'un itérateur
legacy codecode instable que le programme a hérité avec le temps-
librarybibliothèque-
library cratecrate de bibliothèques'utilise au féminin
lifetimedurée de vie-
linkerlinker-
linteranalyse statique-
literal valuevaleur littérale-
Listingencarttout en minuscule (sauf en début de phrase)
loopboucle-
low-levelbas niveau-
machine learningapprentissage automatique-
macromacro-
mainmain-
maptableau associatif-
match guardcontrôle de correspondance-
memory leakfuite de mémoire-
memory managementgestion de mémoire-
message-passingpassage de messages-
methodméthode-
mock objectmock object-
modernrécent-
modulemodule-
module systemsystème de modules-
monomorphizationmonomorphisation-
movedéplacement-
mutabilitymutabilité-
mutablemutablemodifiable
mutatemuter-
namespaceespace de nom-
namespacingl'espace de nom-
nested (path)(chemin) imbriqué-
newtype patternmotif newtype-
nightly Rustversion expérimentale de Rust-
Noteremarquetout en minuscule (sauf en début de phrase)
numerical characterschiffres-
object-oriented languagelangage orienté objet-
operating systemsystème d'exploitation-
outputsortie-
overloadsurcharge-
ownerpropriétaire-
ownershippossession-
package managersystème de gestion de paquets-
panicpanique(r)-
parallel programmingparallélisme-
parallelismparallélisme-
parameterparamètre-
parseinterpréter-
PATHPATH-
patternmotif-
pattern-matchingfiltrage par motif-
placeholderespace réservé{} pour fmt
pointerpointeur-
popping off the stackdépiler-
preludeétape préliminaire-
primitive obsessionobsession primitive-
privacyvisibilitéen parlant des éléments d'un module
procedural macromacro procédurale-
processprocessus-
project chapterchapitre de projet-
propagatepropager-
pushing onto the stackempiler-
race conditionsituation de concurrence-
raw identifieridentificateur brut-
READMEREADME-
recursive typetype récursif-
refactoringremaniement-
referenceréférence-
reference countingcompteur de références-
reference cycleboucle de références-
releasepublication-
registryregistre-
regressionrégression-
releasepublication-
remaindermoduloopération %
reproducible buildcompilation reproductible-
Resource Acquisition Is Initialization (RAII)l'acquisition d'une ressource est une initialisation (RAII)-
returnretourner-
runexécuterpour les programmes
RustaceanRustacé-
section headerentête de section-
semantic versionversion sémantique-
scalarscalaire-
scopeportée-
scriptscript-
secretsecret-
section headeren-tête de section-
semantic versionversion sémantique-
semantic versioningversionnage sémantiqueabréviation : SemVer
shadowmasquerremplacer une variable par une autre de même nom
shadowingmasquage-
shallow copycopie superficielle-
shellterminal / invite de commande-
shorthandabréviation-
sidebarvolet latéral-
signaturesignatured'une fonction
signedsigné-
slashbarre oblique-
sliceslice-
smart pointerpointeur intelligent-
snake casesnake case-
snippartie masquée icidans un encart
spaceespacece mot est féminin quand on parle du caractère typographique
square bracketscrochets-
stackpile-
stack overflowdébordement de pile-
standardstandard (adj. inv.) / norme (n.f.)-
standard errorerreur standard-
standard inputentrée standard-
standard librarybibliothèque standard-
standard outputsortie standard-
statementinstruction-
statically typedstatiquement typé-
stringchaîne de caractères-
string literalun littéral de chaîne de caractères-
StringStringnom féminin (une String)
structstructure-
submodulesous-module-
supertraitsupertrait-
syntax sugarsucre syntaxique-
systems conceptnotion système-
systems-levelniveau système-
systems-level codecode système-
terminalterminal-
test doubledouble de test-
threadtâche-
thread poolgroupe de tâches-
tokenjeton-
traittrait-
trait boundtrait lié-
trait objectobjet trait-
treearborescence-
troubleshootingdépannage-
tupletuple-
tuple structstructure tuple-
tuple enuménumération tuple-
typetype-
type annotationannotation de type-
type inferenceinférence de types-
two’s complementcomplément à deux-
two’s complement wrappingrebouclage du complément à deux-
underlying operating systemsystème d'exploitation sous-jacent-
underscoretiret basle caractère _
unit-like structstructure unité-
unit typetype unitéle ()
unit valuevaleur unité-
unrollingdéroulagepour une boucle à taille connue à la compilation
unsafenon sécurisé-
unsignedsans signe (toujours positif)-
unsignednon signé-
unwinddérouler(la pile)
user inputsaisie utilisateur-
variablevariable-
variantvarianted'une énumération
vectorvecteur-
version control system (VCS)système de gestion de versions (VCS)-
vertical pipebarre verticalela barre `
warningavertissement-
weak referenceréférence faible-
wildcardjoker-
workeropérateur-
workspaceespace de travail-
yankdéprécier-
zero-cost abstractionabstraction 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 installer 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. Lancez cargo 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 les dernières versions de tout ce dont cette dépendance a besoin 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 crate rand, par exemple, vous pouvez lancer cargo doc --open et cliquer sur rand 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, elle ne va jamais changer, donc vous n'avez pas à vous en 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érations à 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

TailleSignéNon signé
8 bitsi8u8
16 bitsi16u16
32 bitsi32u32
64 bitsi64u64
128 bitsi128u128
archiisizeusize

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 que les littéraux numériques qui peuvent être de plusieurs types numériques autorisent l'utilisation d'un suffixe de type, tel que 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ériqueExemple
Décimal98_222
Hexadécimal0xff
Octal0o77
Binaire0b1111_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 le cas d'un u8, 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 exemple wrapping_add
  • Retourner la valeur None s'il y a un dépassement avec des méthodes checked_*
  • 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, le fait qu'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 les 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 copiés 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.

Une string en mémoire

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.

s1 et s2 qui pointent vers la même valeur

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.

s1 et s2 à deux endroits

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.

s1 déplacé dans s2

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 valeurs true et false.
  • 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émente Copy, 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.

&String s qui pointe vers la String s1

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.

world contient un pointeur vers l'octet d'indice 6 de la String s et
une longueur de 5

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 de stderr et de stdout 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, si objet 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 que objet 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 seule String.
  • ChangerCouleur intègre trois valeurs de type i32.

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 puissante appelée 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 une_valeur_u8 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 un l 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 avec Result<T, E>

Cela commence à faire beaucoup de match ! L'expression match 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 sur Result<T, E>. Ces méthodes peuvent s'avérer être plus concises que l'utilisation de match lorsque vous travaillez avec des valeurs Result<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éthode unwrap_or_else dans la documentation de la bibliothèque standard. De nombreuses méthodes de ce type peuvent clarifier de grosses expressions match 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.