Introduction
Bienvenue dans ce cours Rust orienté vers les développeurs connaissant déjà C++.
A la base, Rust est pensé pour être un successeur de C++, donc les deux langages ont de nombreux points communs. Mon objectif est faire meilleur usage de votre temps qu’un cours généraliste en passant moins de temps à discuter des ressemblances, et plus de temps à discuter des différences.
Par rapport aux autres cours Rust que je connais, ce cours se veut…
- Moins ennuyeux que The Rust Programming Language pour les programmeurs expérimentés.
- Plus accessible que Programming Rust pour les francophones sans budget livres.
- Au même niveau de détail : l’objectif n’est pas de tout savoir, mais d’avoir une bonne vision d’ensemble et assez de culture générale pour trouver l’information sur les sujets plus avancés.
- Plus frontal dans le discours : c’est plus important pour moi de vous donner une vision claire des différences Rust/C++, que de ménager votre sensibilité sur les sujets qui fâchent.
Durant ce cours, vous allez rencontrer de nombreux exemples de code Rust, comme celui-ci :
fn main() { println!("Hello world"); }
En survolant le code avec votre souris, vous aurez accès à deux icônes vous permettant, dans l’ordre, de copier le code dans votre presse-papier, et de le compiler et exécuter pour en voir les résultats.
J’utiliserai aussi parfois des exemples de code éditables, comme celui-ci :
fn main() { println!("N'hésitez pas à me modifier"); }
Comme vous pouvez le constater, leur apparence est un peu différente, ce qui vous permettra de les repérer facilement. Notez aussi la présence d’un nouveau bouton au survol, qui vous permet de revenir à la version originale du code.
Cette fonctionnalité est basée sur l’excellent service Rust Playground. Si cela vous rappelle vaguement Godbolt, sachez que ce dernier possède lui aussi un support du code Rust.
Pour naviguer entre les différents chapitres, vous pouvez utiliser la barre sur votre gauche, les icônes de flèches sur le côté de chaque page ou les flèches de votre clavier.
Installation locale
L’étape qui suit est optionnelle, vous n’en avez pas besoin pour suivre la partie “langage” de ce cours.
Mais pour la partie “utilisation”, ou même avant si vous trouvez ça plus confortable, vous devrez tôt ou tard installer un environnement de développement Rust sur votre machine.
Il y a plusieurs façons de procéder, par exemple vous pouvez le faire via le
gestionnaire de paquets d’une distribution Linux. Cependant, pour avoir accès
aux dernières versions du compilateur, je vous recommande d’utiliser le
mécanisme officiel de distribution du projet Rust : rustup
.
Vous trouverez des instructions adaptées à votre système d’exploitation sur le site du langage. Notez qu’on y trouve aussi des instructions pour configurer différents éditeurs de code.
Une fois l’environnement de développement installé et activé, vous pouvez
utiliser cargo
, le gestionnaire de configuration de Rust, pour créer un
nouveau projet :
cargo new mon-projet-test
Dans le squelette de projet que cette commande va créer, vous trouverez un
dossier src
pour le code source, au sein duquel le fichier src/main.rs
contient un “Hello world” prêt à être remplacé par votre nouvelle idée.
Une fois que vous avez écrit un peu de code et voulez le compiler, vous avez plusieurs options pour contrôler le compromis entre temps de compilation et performances d’exécution :
- Avec
cargo check
, vous vérifiez que le code n’a pas d’erreur de typage sans construire de binaire, ce qui est le plus rapide. La plupart des éditeurs de code peuvent être configurés pour exécuter cette commande automatiquement chaque fois qu’un fichier est enregistré, et afficher les erreurs de compilation éventuelles au niveau du code source concerné. - Avec
cargo run
, vous construisez un binaire sans optimisations et avec des vérifications de déboguage (ex : absence de débordement des entiers) puis vous l’exécutez. C’est un peu plus lent, mais évidemment plus riche en enseignements. - Avec
cargo run --release
, vous construisez un binaire avec optimisations et sans vérifications de déboguage. La compilation prendre plus de temps, mais le binaire produit s’exécutera beaucoup plus rapidement.- Si vous voulez optimiser à fond pour votre CPU, au prix de perdre la
portabilité des binaires générés entre CPUs, vous devez le demander
explicitement comme en C++. Il y a différentes manières de faire, la plus
simple est via une variable d’environnement :
export RUSTFLAGS='-C target-cpu=native' # Pris en compte pour tous les cargo run suivants dans ce shell
- Si vous voulez optimiser à fond pour votre CPU, au prix de perdre la
portabilité des binaires générés entre CPUs, vous devez le demander
explicitement comme en C++. Il y a différentes manières de faire, la plus
simple est via une variable d’environnement :
Il y a bien sûr un mécanisme de cache, donc si vous lancez ces commandes
plusieurs fois de suite, les dernières exécutions de cargo
seront plus
rapides que les premières.
On peut faire plusieurs autres choses
intéressantes avec cargo
(lancer
des tests unitaires, générer la documentation de référence, publier des
bibliothèques…), mais ces premières commandes suffiront pour la partie
“langage” de ce cours.
Enfin, si vous appréciez les fonctionnalités de type IDE (ajout automatique des
imports, autocomplétion, aller à la définition d’une fonction, renommage dans
tout le code…), sachez que la plupart des éditeurs de code modernes sont
compatibles avec l’extension
rust-analyzer
. Si
vous avez déjà eu de mauvaises expériences des fonctionnalités IDE en C++, ne
laissez pas ça vous dissuader : dans l’ensemble, rust-analyzer
est beaucoup
plus facile à installer et fiable à l’utilisation que l’intégration IDE moyenne
pour C++.
Hello world
Analysons maintenant le “Hello world” de Rust :
fn main() { println!("Hello world"); // Modifiez-moi ! }
On voit d’abord qu’une déclaration de fonction commence par l’abbréviation fn
.
Comme Python, Rust suit la règle de
Stroustrup :
les abbréviations sont acceptées pour des actions très fréquentes, comme
la déclaration de fonctions.
Plusieurs choix syntaxiques sont communs avec C++ :
- Comme en C++, la fonction principale s’appelle
main()
. Mais en Rust, elle n’est pas obligée de retournerint
, plusieurs types sont acceptés grâce à une conversion versint
. - Les blocs sont délimités par des accolades ouvrantes
{
et fermantes}
, ce qui est un bon compromis entre concision et évitement des bugs d’indentation. - Les instructions sont terminées par un point virgule, ce qui permet de placer ses sauts de ligne où l’on veut quand on clarifie une expression complexe.
- Les commentaires utilisent les syntaxes
//
et/* */
. Contrairement à la version C++, il est possible d’imbriquer le second type de commentaire indéfiniment.
Et pour terminer, on voit que pour écrire du texte sur stdout
, terminé par une
fin de ligne, on utilise println!()
. Le point d’exclamation à la fin de
l’appel nous renseigne sur le fait que println
n’est pas une fonction, mais
une macro. Cela lui permet d’accepter en entrée un mini-langage
spécifique avec des
fonctionnalités comme les arguments nommés et l’interpolation de variables :
#![allow(unused)] fn main() { // Argument positionnel println!("Bonjour, {} !", "Dave"); // Argument nommé println!("Mon nom est {nom}.", nom = "Personne"); // Interpolation de variable let reponse = 42; println!("La réponse est {reponse}, mais quelle est la question ?"); }
Valeurs
Faisons maintenant un point sur les variables et autres valeurs nommées en Rust.
Variables locales
Comme vous l’avez vu à la fin du chapitre précédent, on déclare une variable
locale avec let
, comme dans les langages de la famille ML (OCaml, Haskell,
…) :
#![allow(unused)] fn main() { let vrai = true; }
Les types sont inférés à l’intérieur d’une fonction, en exploitant non seulement
la façon dont une valeur est créée (comme fait auto
en C++), mais aussi la
fonction de la façon dont la valeur est utilisée ultérieurement dans le code.
Il est donc rarement nécessaire de typer manuellement ses variables, et ce
n’est pas une pratique idiomatique en Rust.
En revanche, les types d’entrées et de sortie des fonctions doivent être spécifiés explicitement, comme nous le verrons plus tard dans ce cours, ce qui garantit…
- Un bon niveau de clarté du code (pour peu qu’il soit bien découpé en fonctions).
- Une stabilité des APIs (en modifiant l’implémentation d’une fonction, on ne risque pas de changer accidentellement ses types d’entrée et de sortie et de casser du code client).
Pour guider l’inférence, on sera parfois amené à spécifier le type d’une variable explicitement. On peut le faire avec la syntaxe suivante :
#![allow(unused)] fn main() { let faux: bool = false; }
Pour conclure, mentionnons qu’en Rust, ce n’est pas une erreur de définir une nouvelle variable qui porte le même nom qu’une ancienne, ce qui rend cette dernière inaccessible (shadowing). C’est même idiomatique lorsque la nouvelle variable remplace l’ancienne qui ne devrait plus être utilisée.
#![allow(unused)] fn main() { // Imaginez que cette chaîne nous vienne d'une entrée utilisateur... let nombre = "123"; // ...et qu'on la traduise en nombre pour la suite let nombre = nombre.parse::<u8>() .expect("La chaîne source ne contient pas un nombre"); }
Initialisation différée
La lecture de mémoire non initialisée est un comportement indéfini en C++ et en Rust. N’ayant pas de valeur particulière à retourner, le compilateur est autorisé aussi bien à retourner n’importe quoi qu’à supprimer purement et simplement le code qui utilise la valeur lue, ce qu’il fera ou pas selon les décisions prises pendant le processus d’optimisation de code.
Sachant ça et connaissant les objectifs de conception de Rust (zéro comportement indéfini hors unsafe), vous ne serez pas surpris d’apprendre que ce code est illégal :
#![allow(unused)] fn main() { let a: bool; let b = a; // Interdit ! }
La raison pour laquelle la syntaxe de déclaration de variable sans initialisation existe néanmoins est que cela permet l’initialisation différée d’une variable. En voici un exemple un peu artificiel :
#![allow(unused)] fn main() { let condition = true; let resultat; if condition { resultat = 42; } else { resultat = 24; } let lecture = resultat; // OK }
Mutabilité
Par défaut, la plupart des variables sont immutables. Ainsi, ce code ne compilera pas :
#![allow(unused)] fn main() { let a = 42; a = 43; // Erreur de compilation }
Pour qu’une variable soit modifiable, il faut généralement l’avoir demandé avec
le mot-clé mut
:
#![allow(unused)] fn main() { let mut b = 123; b = 456; // OK }
Par rapport à C++, Rust encourage donc un style de programmation plus fonctionnel où la modification de variables est rare, et on manie principalement des valeurs immuables. Ce style de code est souvent à la fois plus facile à comprendre et à paralléliser.
Mais malheureusement, pour diverses raisons pratiques sordides sur lesquelles je
reviendrai plus tard, les choses ne sont pas tout à fait si simples, et les
variables de certains types peuvent être modifiées par des opérations spéciales
même si elles n’ont pas été déclarées avec le mot-clé mut
.
Nous aborderons ces types à “mutabilité interne” plus tard, pour l’instant
retenez juste que si mut
signifie toujours “modifiable”, l’interprétation
d’une absence de mut
dépend du type de la variable.
Variables globales
Les variables locales ne sont pas le seul type de variable nommée autorisée en Rust. On rencontre également plus rarement les variables statiques, qui se comportent comme si un exemplaire unique était stocké au sein du binaire et réutilisé chaque fois que du code y fait référence…
#![allow(unused)] fn main() { // Notez l'absence d'inférence de type sur les variables statiques static F: f32 = 4.2; }
…ainsi que les constantes de compilation, qui se comportent comme si, après évaluation à la compilation, le résultat était copié-collé en chaque point où la valeur est utilisée :
#![allow(unused)] fn main() { // Pas d'inférence de type ici non plus const X: u64 = 123 + 456; }
Ces deux types de valeurs doivent, par définition, être disponibles dès le
lancement du programme. Cela pourrait être assuré par une injection de code
avant la fonction main()
, mais l’expérience quotidienne de C++ et Python nous
enseigne que l’existence d’un tel mécanisme peut rendre le processus
d’initialisation des programmes très difficile à comprendre et à déboguer.
A la place, Rust impose donc que les valeurs const
et static
soient
initialisées avec des valeurs connues à la compilation. On peut utiliser pour
cela const fn
, l’équivalent en Rust des fonctions constexpr
de C++.
Mutabilité globale
Parfois, l’initialisation d’une variable statique à l’exécution est inévitable.
Dans ce cas on utilisera des mécanismes d’initialisation paresseuse comme
OnceLock
.
OnceLock
est un premier exemple de mutabilité interne : il faut bien que
la variable soit modifiable durant le processus d’initialisation, mais le type
OnceLock
garantit qu’elle ne sera plus modifiée par la suite.
Voici un exemple d’utilisation de OnceLock
pour charger paresseusement le
contenu d’un fichier de configuration. Ce code utilise plusieurs concepts que
nous n’avons pas encore abordés, donc concentrez-vous juste sur la structure
générale sans chercher à comprendre le détail.
#![allow(unused)] fn main() { use std::sync::OnceLock; /// Lit le contenu du fichier de configuration fn read_config() -> String { std::fs::read_to_string("/etc/ma_config.conf") .expect("Echec de la lecture du fichier") } /// Retourne une copie cachée du contenu du fichier de configuration fn cached_config() -> &'static String { // Contenu du fichier, lu paresseusement static CONFIG: OnceLock<String> = OnceLock::new(); // Lit le fichier de config depuis le disque au premier accès, retourne // la value lue initialement lors des appels suivants à `cached_config()` CONFIG.get_or_init(read_config) } }
Les valeurs const
ne sont jamais modifiables, et l’utilisation de variables
static
modifiables à volonté est fortement découragée pour les raisons
habituelles : perte d’intégrité référentielle, non-composabilité entre les
bibliothèques, mauvaise interaction avec le parallélisme…
En particulier, l’utilisation des variables static mut
, qui permettent la
modification libre, n’est possible en Rust que via des blocs unsafe
. En
effet, il n’est pas possible pour le compilateur de prouver qu’un code qui
utilise static mut
est correct en présence de plusieurs threads
d’exécution.
Données simples
Dans ce chapitre, nous allons aborder les types de données simples de Rust, des entiers aux types énumérés. Les types plus complexes définis par la bibliothèque standard (itérateurs, collections, pointeurs intelligents…) seront abordés un peu plus tard, lorsque nous aurons traité d’autres prérequis comme les fonctions et les références.
Arithmétique
Concentrons-nous maintenant sur les types arithmétiques primitifs de Rust, à savoir les booléens, les entiers et les flottants. Comme vous allez le voir, du point de vue d’un programmeur C++, il y a beaucoup de points communs et quelques différences importantes.
Types
Les types arithmétiques primitifs du langage sont :
- Le type booléen
bool
. - Les types entiers signés
i8
,i16
,i32
,i64
,i128
etisize
- Les types entiers non signés
u8
,u16
,u32
,u64
,u128
etusize
. - Les types flottants
f32
etf64
.
Une première différence avec le C++ saute aux yeux : à l’exception de isize
et
usize
qui ont la taille d’une adresse mémoire (comme size_t
en C++), les
types entiers sont de taille fixe, comme ceux de <cstdint>
en C++.
Dans l’ensemble, l’expérience du C et du C++ nous enseigne qu’il est presque
impossible d’écrire du code portable et correct en présence de types de
taille variable comme le long
de C. De plus, il y a aujourd’hui en C/++ une
relative standardisation des tailles qui fait que la plupart des programmes
existants seraient incorrects sur une implémentation utilisant, disons, des
char
de 16 bits. Accepter cet état de fait en privilégiant l’utilisation
d’entiers taille fixe est donc préférable.
Une autre curiosité de Rust est l’inclusion d’entiers 128 bits. Il est rare aujourd’hui de rencontrer du matériel qui supporte ceux-ci nativement (quoique ce soit prévu par l’architecture RISC-V), mais comme ils sont assez faciles à émuler et ont plusieurs applications intéressantes (timestamps Unix précis à la nanoseconde, cryptographie, identifiants de systèmes distribués…), ils y en a en Rust.
Litérales
Dans l’ensemble, les litérales de C++ et Rust sont très similaires.
Une différence importante est que par défaut, les litérales Rust ne sont pas
typées. Contrairement à ce qui se passe en C++ une litérale entière comme 123
n’est pas présumée être d’un certain type int
, et une litérale flottante
comme 4.2
n’est pas présumée être double précision. Si vous avez eu affaire
aux suffixes ULL
et f
en C++, vous comprendrez le gain ergonomique que ça
représente.
Ceci est une conséquence de l’inférence de type bidirectionnelle de Rust : puisque le compilateur est généralement capable d’inférer le type d’une variable en fonction du contexte dans lequel elle est utilisée, il est a fortiori souvent capable d’inférer le type d’une litérale, et donc il n’y a plus besoin de s’embêter avec des litérales typées.
Si vous tenez à préciser le type d’une litérale (par exemple pour guider
l’inférence d’un type générique), vous pouvez cependant le faire en l’écrivant
en fin de litérale : 4.2f32
est de type f32
.
En l’absence de toute contrainte, un type par défaut adapté à la litérale peut être sélectionné par le compilateur, mais ce n’est fait que dans des cas simples.
Les autres différences sont mineures et résumées par le code éditable suivant :
fn main() { // On n'écrit pas un nombre octal comme ceci... let decimal = 0321; // ...mais comme cela. let octal = 0o777; // On utilise _ et pas ' pour la lisibilité des grands nombres let long_number = 1_234_567.89; println!("{decimal} {octal} {long_number}"); }
Opérateurs
Les opérateurs arithmétiques de C++ et Rust sont très similaires, du moins
quand on les applique à des nombres de même type. Il en va de même pour les
opérateurs de comparaisons usuels comme !=
. Nous aborderons la question
des types hétérogènes un peu plus loin.
Une différence notable est le traitement des opérations bit à bit. Puisque
Rust n’autorise pas à traiter les entiers comme des booléens, il n’y a pas
besoin d’un opérateur ~
dédié pour le NON bit à bit, on réutilise tout
simplement la syntaxe !
utilisée pour le NON des booléens.
Une autre différence est qu’il n’y a pas d’opérateur d’incrémentation /
décrémentation dédié, ce qui résoud l’éternelle question du ++valeur
vs
valeur++
en C++. Les opérateurs de modification en place comme valeur += 1
ne retournent pas non plus de valeur numérique. L’ergonomie des boucles est
assurée par d’autres moyens (itérateurs) que nous étudierons ultérieurement.
Je n’ai pas vérifié si les priorités opératoires sont différentes, mais franchement, si vous êtes le genre de personne qui réduisez au maximum le nombre de parenthèses dans vos expressions au motif qu’une norme obscure quelque part dit que c’est légal, vous méritez ce qui vous arrive quand vous basculez entre deux langages de programmation…
fn main() { let add = 1 + 2; let sub = 3 - 4; let mut muldiv = 5 * 6; muldiv /= 7; // Un des rares cas où le type inféré par défaut est observable ! let not = !0; // Comparaison n'est pas raison dans le monde des flottants IEEE-754... let absurdity = f32::NAN == f32::NAN; println!("{add} {sub} {muldiv} {not:#x} {absurdity}"); }
Conversions
Sur la question des conversions, Rust a une conception très différente de C++.
En Rust, il n’y a pas de conversion implicite entre types arithmétiques, et très peu d’opérateurs hétérogènes entre deux types primitifs différents (il est possible d’en définir pour ses propres types). Ainsi, ce genre de code ne compile pas…
#![allow(unused)] fn main() { let x = 1u8 + 2u32; }
…et il faut à la place utiliser des conversions explicites pour spécifier dans quel type on veut que l’opération soit effectuée :
#![allow(unused)] fn main() { let a = 4u8; let b = 2u32; let c = a + (b as u8); }
Pourquoi ce choix a-t’il été fait par les concepteurs de Rust ?
- Les conversions implicites sont une source de bugs invisibles, notamment quand elles surviennent à l’affectation (qui peut jeter des décimales de flottants ou des bits de poids fort d’entiers sans autre forme de procès en C/++).
- Les conversions implicites causent aussi des problèmes de performance dans les calculs, où il est très fréquent de se retrouver à calculer en précision plus élevée qu’on ne le voulait.
- L’existence de conversions implicites rend très souvent l’inférence de type indécidable, or nous avons vu que celle-ci joue un rôle central dans le code Rust idiomatique.
L’exemple ci-dessus introduit as
, qui est le seul opérateur de conversion
explicite disposant d’une syntaxe dédiée dans le langage. Celui-ci
suit grosso-modo les règles des casts
C,
le comportement indéfini en moins, il a donc une sémantique assez complexe qui
facilite les erreurs. De l’aveu général, c’est un des ratés de la conception de
Rust.
Par conséquent, il y a un mouvement en cours dans la communauté pour construire
petit des alternatives plus spécialisées à toutes les utilisations de as
, qui
clarifient l’intention de l’auteur du code. Ces alternatives ne sont plus
directement intégrées au langage, mais implémentées sous forme de traits dans
des bibliothèques. Quelques exemples issus de la bibliothèque standard :
From
etInto
représentent les conversions qui réussissent toujours sans pertes d’information. Ces opérations permettent donc de convertiri8
eni16
, mais pasf32
enusize
.TryFrom
etTryInto
représentent les conversions qui peuvent être sans perte, et échouent si ce n’est pas le cas. Par exemple,u16::try_from(256usize)
réussirait maisu8::try_from(666usize)
échouerait.
Nous n’allons pas discuter de ces alternatives en détail ici car elles dépendent
d’autres aspects du langage que nous n’avons pas encore abordés (les traits et
la gestion des erreurs notamment). Mais retenez que as
est considéré
aujourd’hui comme une solution de facilité dont l’utilisation devrait être
réduite et idéalement éliminée à terme.
Autres opérations
Les types arithmétiques ont un très grand nombre de constantes et méthodes associées (voyez par exemple f32 et usize). C’est la façon idiomatique d’utiliser les fonctions mathématiques de base de la bibliothèque standard Rust :
#![allow(unused)] fn main() { println!("{} {}", f32::MAX, (-1.0f32).acos()); }
Les méthodes des types numériques sont ambigües du point de vue de l’inférence
de type, puisqu’elles sont implémentées par plusieurs types flottants/entiers.
On est donc souvent forcé de typer ses litérales quand on les utilise, comme je
l’ai fait avec 1.0f32
ci-dessus. Si je ne l’avais pas fait, le compilateur
aurait rejeté le code ambigü : le code suivant ne compile pas.
#![allow(unused)] fn main() { println!("{}", 0.0.asin()); }
Le choix d’implémenter les opérations mathématiques sous forme de méthodes est discutable, et a été abondamment débattu pendant et après la stabilisation de Rust. L’avantage est que les méthodes sont toujours disponibles, sans devoir les ramener dans le scope, mais en contrepartie…
- C’est peu conventionnel comme sens de lecture (sauf si vous venez d’un langage objet comme Java), et donc c’est difficile à lire au début.
- Cela rend les pages de documentation des types primitifs assez chargées, comme vous avez pu le constater si vous avez ouvert les liens ci-dessus.
Après, si vous ne pouvez pas vous passer de la notation préfixe, c’est assez facile de l’implémenter sous forme de bibliothèque. Je l’ai fait moi-même pour un projet de calcul numérique il y a quelques temps, et j’ai publié la bibliothèque pour que d’autres personnes intéressées puissent utiliser facilement cette notation.
Les méthodes mathématiques standard de Rust contiennent aussi plusieurs choses inhabituelles pour un utilisateur C++ :
- Il y a des opérations arithmétiques entières comme
checked_add()
,saturating_add()
,wrapping_sub()
etoverflowing_div()
où le comportement en cas de dépassement des bornes inférieures et supérieures est choisi explicitement. Quand on utilise les opérateurs de base comme “+”, ce dépassement est traité comme une erreur, donc…- En mode debug, cet événement est détecté et provoque l’arrêt du programme (panic).
- En mode release, le test pour détecter l’erreur coûte trop cher au vu de la fréquence de ces opérations. On accepte donc le compromis de suivre la sémantique de tous les CPUs modernes et revenir à la borne opposée du type entier utilisé (wraparound).
- On retrouve toutes les opérations courantes sur les bits des entiers (comptage des 1 et 0 dans la représentation binaire, nombre de 0 et de 1 au début où à la fin de cette représentation, puissance de 2 suivante…) que les compilateurs implémentent très efficacement depuis la nuit des temps mais qu’en C++ < 20 on ne peut utiliser que via des intrinsèques spécifiques à chaque compilateur, ce qui nuit à la portabilité du code.
- Les puissances entières sont supportées explicitement et sans pièges de
performance, pour les entiers comme pour les flottants. Plus besoin de remplir
son code de
f * f * f
pour éviter les problèmes de performances dustd::pow
de C++, en Rustf.powi(3)
fait ce qu’on veut.
Intervalles
Rust possède un certain nombre de syntaxes d’intervalles, avec des types associés, qui sont le plus souvent utilisées pour représenter des ensembles d’entiers. Elles sont cependant applicables à tout type ordonné (possédant une relation d’ordre mathématique).
..
est un objet de typeRangeFull
représentant l’ensemble des valeurs d’un type ordonné.i..
est unRangeFrom
représentant l’ensemble des x tels quei <= x
.i..j
est unRange
représentant l’ensemble des x tels quei <= x < j
.i..=j
est unRangeInclusive
représentant l’ensemble des x tels quei <= x <= j
...j
est unRangeTo
représentant l’ensemble des x tels quex < j
...=j
est unRangeToInclusive
représentant l’ensemble des x tels quex <= j
.- Les autres cas sont couverts par des tuples
(Bound, Bound)
oùBound
est un type pouvant représenter une borne inclusive, exclusive, ou l’absence de borne. Une description précise nécessite des notions de Rust que nous n’avons pas encore abordées, je vous invite à y revenir si besoin après avoir étudié les types produits et les types sommes.
On le voit, la syntaxe de Rust possède un net biais en faveur des intervalles fermés à gauche et ouverts à droite. Ces intervalles ont en effet un certain nombre de bonnes propriétés déjà signalées par Dijkstra dans les années 80 :
- Par rapport aux intervalles ouverts à gauche, ils permettent de gérer facilement le 0 dans l’ensemble des entiers non signés.
- Par rapport aux intervalles fermés à gauche et à droite, ils simplifient le calcul du nombre d’éléments (borne droite - borne gauche), la représentation de l’intervalle vide (même borne à gauche et à droite), et la manipulation d’intervalles consécutifs (la borne droite de l’intervalle N devient la borne gauche de l’intervalle N+1).
En Rust, les intervalles sont principalement utilisés pour…
- Tester facilement si un nombre appartient à un intervalle, via la méthode
contains()
exposée par la plupart des types d’intervalles.#![allow(unused)] fn main() { let oui = (123..456).contains(&256); let non = (24..=42).contains(&666); println!("{oui} {non}"); }
- Itérer sur l’ensemble des entiers de l’intervalle (tous les intervalles
d’entiers ayant une borne inférieure se comportent comme des itérateurs
d’entiers).
#![allow(unused)] fn main() { for i in 1..=5 { println!("{i}"); } }
- Sélectionner des éléments d’un tableau (ou d’une entité proche d’un tableau
comme le type chaîne de caractère
str
) dont l’indice appartient à l’intervalle choisi.#![allow(unused)] fn main() { let tableau = [9, 8, 7, 6]; let fragment = &tableau[..2]; println!("{fragment:?}"); }
Nous reviendrons sur ces deux dernières possibilités lorsque nous aurons abordé
l’itération et les tableaux de tailles variables (slice
).
Tableaux
Il y a un nombre assez important de différences entre la façon dont les tableaux de taille fixe sont gérés en C++ et en Rust. Etudier ces différences permet de comprendre comment les choix de conception de Rust affectent les bonnes pratiques de calcul numérique dans ce langage.
Création
La façon la plus simple de créer un tableau est de lister des éléments entre crochets :
#![allow(unused)] fn main() { // Crée un tableau contenant trois entiers usize : 1, 2 et 3 let a = [1usize, 2, 3]; }
Souvent, on veut donner la même valeur initiale à tous les éléments. Il existe une syntaxe dédiée :
#![allow(unused)] fn main() { // Crée un tableau contenant 66 exemplaires du chiffre 6 let b = [6u32; 66]; }
Le type d’un tableau s’écrit [T; N]
avec T
le type des éléments et N
le
nombre d’éléments (de type usize
). Dans les exemples ci-dessus, a
est donc de type [usize; 3]
et b
est de type [u32; 66]
.
La taille étant une donnée connue à la compilation (elle fait partie du type de tableau), il n’est pas nécessaire de la stocker au sein du tableau. A l’exécution, le stockage associé à chaque tableau contiendra donc juste les éléments du tableau.
Affichage
Les tableaux sont un premier exemple de type qui n’implémente pas le trait
Display
, ce qui signifie qu’on ne peut pas les afficher avec la syntaxe de
println!()
que nous avons vue jusqu’ici. Le code suivant ne compile donc pas :
#![allow(unused)] fn main() { println!("{}", [1, 2, 3]); }
Comme le message d’erreur vous l’indique, vous pouvez cependant utiliser à la
place le trait Debug
, avec une chaîne de formatage un tout petit peu
différente :
#![allow(unused)] fn main() { println!("{:?}", [4, 5, 6]); }
La différence entre ces deux traits est que Debug
est conçu pour le déboguage
et donc extrêmement facile à implémenter pour tous les types, comme nous le
verrons plus tard. Alors que Display
est conçu pour les sorties à l’intention
de l’utilisateur, donc il faut l’implémenter manuellement en réfléchissant un
peu.
Une autre différence pratique est que la bibliothèque standard Rust garantit la
stabilité des sorties textuelles issues de ses implémentations Display
, alors
que celle de Debug
peut changer librement d’une version de Rust à l’autre
(même si c’est rare en pratique).
De façon générale, la bibliothèque standard Rust n’implémente donc Display
qu’avec parcimonie, lorsqu’il n’y a clairement qu’une bonne façon d’afficher
des données d’un certain type. Ce n’est pas le cas pour les tableaux : au delà
d’un certain nombre d’éléments, on peut raisonnablement vouloir les abbrévier,
ou pas, selon ce qu’on est en train de faire.
Une fonctionnalité de Debug
et Display
qui est très utile quand on commence
à manipuler des valeurs de complexité non bornée comme les tableaux, c’est
l’affichage alternatif. On l’utilise en ajoutant un “#” dans la chaîne de
formatage, et dans les implémentations standard il a pour effet d’augmenter la
verbosité de certaines sorties (notamment l’affichage des entiers en base non
décimale) et d’aérer la sortie texte des types structurés en ajoutant des sauts
de ligne :
#![allow(unused)] fn main() { println!("Avant: {:x?}", [usize::MAX; 10]); println!("Après: {:#x?}", [usize::MAX; 10]); }
Accès
L’opérateur d’indexation de Rust est une paire de crochets, comme en C++. Et comme dans la plupart des langages de programmation actuellement utilisés, les indices de tableaux commencent à zéro :
#![allow(unused)] fn main() { let tab = [9, 8, 7, 6]; println!("{}", tab[3]); // Affiche "6", pas "7" }
L’opérateur d’indexation n’est cependant pas anodin, puisqu’il est possible de lui passer un index invalide. En C++, c’est un comportement indéfini, et le compilateur a le droit de faire ce qu’il veut avec votre code. En pratique, il va souvent vous faire lire/écrire dans le stockage associé à la variable d’à côté avec des conséquences dramatiques.
En Rust, en revanche, c’est une erreur qui interrompt l’exécution du programme (panic) :
#[allow(unconditional_panic)] fn main() { let tab = [6, 6, 6]; println!("{}", tab[6]); }
Bon, en réalité j’ai triché et modifié la configuration du compilateur pour cet exemple. Normalement, si l’erreur est identifiable à la compilation, comme dans mes exemples simples, le compilateur vous préviendra dès ce moment-là :
#![allow(unused)] fn main() { // Exemple où la configuration n'est pas modifiée let tab = [1, 2, 3]; println!("{}", tab[4]); }
Néanmoins, le fait est que ces cas-là sont rares dans la vraie vie. Souvent, le compilateur ne sait pas prédire si les accès aux tableaux vont être valides ou non. Dans ce cas, le code injecté pour tester la condition d’erreur à l’exécution peut ralentir votre programme si vous indexez beaucoup des tableaux au fond d’une boucle, comme on aime le faire en calcul numérique.
Par conséquent, en Rust c’est une mauvaise pratique d’indexer des tableaux dans du code sensible aux performances d’exécution. Chaque fois que c’est possible, on préfère utiliser des itérateurs, qui garantissent que les accès sont corrects sans avoir besoin de les tester un par un. Voici un premier exemple de boucle basée sur les itérateurs, nous reviendrons sur ce sujet ultérieurement :
#![allow(unused)] fn main() { for element in [1.2, 3.4, 5.6] { println!("{element}"); } }
Mentionnons pour conclure que si on est dans un des rares cas où il n’y a vraiment pas d’alternative à l’indexation de tableau, et on a prouvé par des mesures de performances que le compilateur ne parvient pas à implémenter le test associé de façon efficace, il est possible d’utiliser unsafe pour effectuer des accès non vérifiés analogues à ceux de C++ :
#![allow(unused)] fn main() { let tab = [9, 8, 7, 6]; // SAFETY: J'ai prouvé manuellement que l'indice utilisé est OK let element = unsafe { tab.get_unchecked(2) }; println!("{element}"); }
C’est avec ce genre de mécanismes que l’on peut implémenter de nouveaux itérateurs performants lorsque ceux de la bibliothèque standard ne sont pas suffisants.
Initialisation
Les personnes attentives auront remarqué qu’il y a un autre source courante de comportement indéfini que je n’ai pas encore discutée, c’est l’initialisation des tableaux.
En C++, le code pour initialiser un tableau ressemble souvent à ça :
std::array<int, 4> tab;
for (int i = 0; i < tab.size(); ++i) {
tab[i] = fonction_compliquee(i);
}
Et puis malheureusement, le temps passe, les demandes des utilisateurs changent, et un jour on doit quitter le cocon confortable des types primitifs.
std::array<TypeComplique, 4> tab;
for (int i = 0; i < tab.size(); ++i) {
tab[i] = fonction_compliquee(i);
}
Hélas, ce changement mécanique que l’on fait sans réfléchir n’est pas anodin. Si
TypeComplique
a un opérateur d’affectation, lorsqu’on va faire l’affectation
tab[i] = ...
dans la boucle, cet opérateur risque d’être appelé sur une
valeur non initialisée. Et si TypeComplique
a un destructeur et une exception
est lancée pendant l’exécution de fonction_compliquee()
, alors le destructeur
sera appelé sur les valeurs pas encore initialisées à la fin du tableau.
De plus, dans du code C++ écrit par quelqu’un d’un peu moins professionnel, le nombre d’itérations de la boucle d’initialisation pourrait être codé en dur séparément du nombre d’éléments du tableau, et les deux pourraient se désynchroniser comme dans ce code :
std::array<TypeComplique, 4> tab;
for (int i = 0; i < 3; ++i) {
tab[i] = fonction_compliquee(i);
}
// tab[3] non initialisé s'en va dans la nature...
Pour éviter ces différentes formes de comportement indéfini, le compilateur Rust pourrait, face à du code équivalent, essayer de prouver que la boucle remplit bien l’ensemble des éléments du tableau, sans lire les anciennes valeurs non initialisées explicitement ou implicitement.
C’est possible dans des cas particuliers simples, mais ce n’est pas possible dans le cas général. Donc pour éviter que le code compile ou non selon ce que le moteur d’analyse statique arrive à prouver, le langage Rust prend actuellement le parti pessimiste d’interdire totalement ce type de code d’initialisation même dans les cas les plus simples. Ce code Rust ne compile donc pas :
#![allow(unused)] fn main() { let mut tableau: [usize; 4]; for i in 0..4 { // Itération sur les indices de 0 à 3 tableau[i] = 2 * i; } }
A la place, on a deux possibilités :
- Soit on fait comme la plupart des programmeurs C++ chevronnés, et on
remplit défensivement le tableau de valeurs initiales avant d’itérer
dessus, en espérant que le compilateur soit assez malin pour éliminer le
remplissage initial redondant (il y arrive dans les cas simples) :
#![allow(unused)] fn main() { let mut tableau = [0; 4]; for i in 0..4 { tableau[i] = 2 * i; } }
- Soit on fait appel à la fonction
std::array::from_fn()
de la bibliothèque standard, implémentée avec du code unsafe mais dont l’utilisation ne présente pas de risque de comportement indéfini. Elle correspond exactement au type d’initialisation voulu ici :#![allow(unused)] fn main() { // "|i| 2*i" est une fonction qui au paramètre i associe le résultat 2 * i let tableau: [usize; 4] = std::array::from_fn(|i| 2 * i); }
C’est une utilisation typique du code unsafe en Rust : lorsqu’il n’est pas possible de faire prouver l’absence de comportement indéfini automatiquement par le compilateur, on le prouve de façon manuelle, puis on utilise unsafe pour indiquer au compilateur (et aux relecteurs du code) qu’on pense savoir ce qu’on fait, et enfin on expose le code résultant via une interface qui ne permet pas de déclencher du comportement indéfini.
Opérations plus avancées
Les tableaux de taille fixe peuvent être vus comme un cas particulier des tableaux de taille variable (slices) où la taille est connue à la compilation. Cela permet d’utiliser sur eux la très longue liste des opérations disponibles pour les slices.
Nous expliquerons plus en détail cette notion de slice lorsque nous aurons traité quelques prérequis, notamment la notion de référence.
Texte
Contexte
Si vous vous êtes déjà retrouvé face à un tas de ’ dans la sortie textuelle d’un programme que vous utilisez, vous vous êtes peut-être demandé comment on en arrive là.
C’est en fait une conséquence naturelle de la façon dont le C et le C++ gèrent les chaînes de caractère : la gestion du texte dans la bibliothèque standard de ces langages a une conception trop simpliste qui encourage le programmeur à entretenir des croyances telles que :
- “C’est une bonne idée de raisonner sur le texte en termes de tableau de caractères.”
- “Un caractère tient dans les 8 bits du type
char
.” - “Il n’y a qu’une seule façon d’interpréter les 8 bits d’un
char
en caractères écrits.” - “Puisqu’une chaîne est un tableau, on peut la couper en n’importe quel point et obtenir deux fragments du texte original en sortie.”
En réalité, manipuler du texte avec du code est nettement plus complexe que le débutant ne l’imagine. Et je ne vous parle pas ici des horreurs lovecraftiennes cachées dans l’implémentation des moteurs de rendu texte à l’écran qu’on utilise tous les jours sans y penser. Rien que pour échanger correctement des octets de texte avec un fichier ou stdin/stdout en effectuant des modifications mineures, on doit déjà surmonter un grand nombre de préjugés.
A cause de cette complexité, il ne serait pas raisonnable d’intégrer à la biblothèque standard d’un langage de programmation une solution complète qui couvre tous les besoins courants. Tout ce qu’un langage et sa bibliothèque standard peuvent faire, c’est couvrir les bases correctement, en concevant et documentant les types et interfaces très soigneusement pour aider les programmeurs à prendre conscience de leurs suppositions inconscientes.
C’est dans le contexte de ce compromis qu’on peut comprendre les types textuels standard de Rust.
char
Pour des raisons de familiarité, Rust perpétue malheureusement un piège ergonomique millénaire en nous fournissant un type char dont le nom suggère qu’il contient un caractère
En réalité, ce que ce type contient, c’est un point de code, la brique de base de la norme Unicode.
Comme la norme ASCII, la norme Unicode définit un texte comme une séquence de points de code. Mais cette modélisation logique doit être distinguée de la représentation machine d’un texte Unicode, qui elle n’est généralement pas un tableau de points de code comme en ASCII. Nous reviendrons sur ce point un peu plus loin.
Pour l’heure, commençons par clarifier ma remarque initiale sur char
. Même si
de nombreux caractères peuvent être encodés avec un seul point de code Unicode,
la notion de point de code en Unicode n’est ni un sur-ensemble, ni un
sous-ensemble de celle de caractère. En effet…
- Parmi les points de code d’Unicode, on trouve toutes sortes de modificateurs
affectant l’interprétation des points de code suivants et précédents dans le
texte : accents, changement de sens d’écriture, césures… Il ne serait pas
raisonnable d’appeler cela des caractères.
#![allow(unused)] fn main() { // Cette marque de sens d'écriture n'est pas un caractère println!("\u{200E}"); }
- A l’inverse, de nombreux caractères peuvent être encodés avec plusieurs
points de code (par exemple les caractères accentués en français), et pour les
caractères de nombreuses langues c’est même la seule option disponible.
#![allow(unused)] fn main() { // Ce caractère est encodé via une paire de points de code : println!("e\u{0301}"); }
A l’heure où ces lignes sont écrites, la norme Unicode définit 149186 points de
code. On peut facilement stocker des valeurs représentant ces différents points
de code dans un entier 32 bits, mais on devine que si on utilisait simplement
un type entier primitif comme u32
pour ça, on se retrouverait dans une
situation de typage trop faible, le type u32
autorisant aussi des valeurs qui
ne correspondent à aucun point de code.
Par conséquent, chaque fois qu’une fonction prendrait en paramètre un u32
censé être un point de code, elle devrait commencer par vérifier que c’en est
bien un. Et de façon symétrique, un code qui appelle une fonction retournant un
u32
censé être un point de code devrait aussi vérifier que c’en est bien un si
la source n’est pas fiable (ex : utilisateur).
Pour éviter toutes ces vérifications à l’exécution, Rust définit donc le type
char
, qui est un entier 32-bit spécialisé qui n’accepte que les valeurs
numériques des points de code d’Unicode.
Ainsi, une fonction qui prend un char
en paramètre est assurée à la
compilation que ce type contient un point de code valide, et à l’inverse un
appelant de fonction qui reçoit un char
en résultat sait aussi que ce sera un
point de code valide.
La syntaxe des litérales char
est au reste très similaire à ce qu’on connaît
en C++ : une paire d’apostrophes entourant le point de code désiré, avec des
séquences d’échappement possibles pour représenter les points de code
exotiques.
#![allow(unused)] fn main() { let c1 = 'a'; let c2 = '\u{0301}'; // Accent aigü isolé println!("{c1} {c2}"); }
Le type char
fournit également un certain nombre de méthodes
utiles pour la manipulation
du point de code qu’il contient, permettant par exemple de distinguer les
marques typographiques des caractères au sens usuel du terme.
str
Sur le principe, on pourrait stocker du texte Unicode sous forme de tableau de
char
, c’est ce qu’on appelle l’encodage UTF-32. Mais cet encodage est très peu
utilisé dans le monde réel, car il est extrêmement gourmand en mémoire.
Par exemple, la version UTF-32 d’un texte anglais prendrait quatre fois plus de
places en mémoire en UTF-32 que sa version ASCII, à information équivalente.
A la place, tous les programmes ayant effectué leur transition Unicode récemment
privilégient l’encodage UTF-8, qui est de taille variable et permet d’assurer
que tous les caractères simples tiennent en un seul octet, au prix d’une
complexité de décodage un peu plus élevée. Rust, qui a été stabilisé bien après
la création d’UTF-8, a naturellement suivi cette tendance pour son type
primitif de chaîne de caractère, str
.
Mais comme précédemment avec char
, le type str
ne peut pas être une simple
séquence d’octets (comme le type [u8; N]
de taille fixe que nous avons déjà
vu et le type [u8]
de taille variable que nous allons voir plus tard), car
toutes les séquences d’octet ne sont pas de l’UTF-8 valide.
Le type str
est donc représenté au niveau machine comme une séquence d’octets,
mais il impose en plus pour toutes les interfaces qui acceptent ou consomment
des str
l’invariant supplémentaire que la séquence d’octets manipulée doit
être de l’UTF-8 valide. Cela permet à la bibliothèque standard d’offrir des
fonctionnalités utiles comme le décodage de str
en séquence de char
, sans
devoir pour autant s’imposer des vérifications de types à l’exécution.
Comme char
, str
fournit une grande palette
d’outils pour les
manipulations Unicode simples, incluant par exemple la recherche d’une
sous-chaîne et le découpage d’une chaîne. Mais ces opérations illustrent aussi
les limites du support Unicode de la bibliothèque standard :
- Les comparaisons standard, et donc la recherche standard, sont non seulement
sensibles à la casse mais aussi à la séquence précise de points de code
utilisée pour encoder un caractère :
Pour éviter ce problème en présence de données d’entrée non contrôlées, il faut normaliser ses chaînes de caractère en ré-encodant chaque caractère par une séquence de points de code unique. Ce travail est laissé à des bibliothèques tierces comme unicode_normalization.#![allow(unused)] fn main() { let normalisation1 = "é"; let normalisation2 = "e\u{0301}"; let comparaison = normalisation1 == normalisation2; println!("{normalisation1} == {normalisation2} -> {comparaison}"); }
- Le découpage d’une chaîne ne peut être effectué qu’à la frontière entre deux
points de code, ce qui est suffisant pour préserver les invariants du type
str
mais pas toujours correct :
Là encore, le problème du choix d’un bon point de découpe (on parle de segmentation en Unicode) est sous-traité à des bibliothèque tierces comme unicode_segmentation.#![allow(unused)] fn main() { let mot = "rate\u{0301}"; let (debut, fin) = mot.split_at(4); println!("{mot} -> {debut} {fin}"); }
Comme les nombreux exemples précédents de ce cours le suggèrent, les litérales
chaînes de caractères sont du texte entre guillemets, comme en C++. Pour les
cas où le texte contient des guillemets, on peut éviter le cancer du \"
en
utilisant des litérales brutes (raw string literals) :
fn main() { // Vous pouvez utilisez autant de # que vous voulez ou presque (<256), // ce qui permet de supporter aussi la séquence "# dans une chaîne. let brut1 = r#"Il a dit "Bonjour !"."#; let brut2 = r##"Elle a répondu r#"Au revoir !"#."##; println!("{brut1}\n{brut2}"); }
Ce qui vous surprendra peut-être un tout petit peu plus, c’est d’apprendre que
les litérales chaînes ne sont pas des valeurs type str
, mais de type
&'static str
: ce qu’on manipule, ce n’est pas directement la chaîne, mais une
référence à une chaîne de caractère stockée quelque part dans le binaire, à la
manière des litérales const char*
de C.
On reviendra là-dessus quand on abordera les références, mais en gros, c’est parce que…
- On n’a pas envie de recopier tout le contenu d’une chaîne (ou d’espérer que l’optimiseur du compilateur parviendra à éliminer la copie) à chaque fois qu’on passe une litérale chaîne en paramètre d’une fonction.
- Il n’est pas aussi évident qu’on pourrait
l’imaginer
de supporter l’allocation de données de taille variable directement sur la
pile. Ce type d’allocation n’est donc pour l’instant pas supporté en Rust.
Or le type
str
est, par nature, de taille variable.
Support ASCII
Même si l’Unicode devrait être utilisé dans toutes les interactions avec les utilisateurs, Rust reconnaît l’importance historique de l’encodage ASCII et les bénéfices de performance qu’il peut y avoir à traiter un flux de données ASCII comme tel plutôt que comme un cas particulier d’UTF-8.
On retrouve donc, à différents endroits du langage Rust et de sa bibliothèque standard, quelques outils qui simplifient la manipulation de données ASCII. Par exemple…
- Des conversions faillibles entre le type
char
et le sous-ensemble deu8
utilisé par l’ASCII. - Des litérales ASCII telles que
b'\n'
etb"Je suis ASCII"
, qui permettent de déclarer des octets ou séquences d’octets correspondant à du texte en ASCII. - Des méthodes de
&str
qui permettent de détecter et gérer de façon optimisée le cas particulier où une chaîne UTF-8 est entièrement composée de caractères ASCII. - Des méthodes des slices d’octets qui permettent de gérer de façon optimisée le cas particulier où les octets appartiennent tous à l’encodage ASCII.
Ces fonctionnalités ne sont pas aussi développées ni ergonomiques que la gestion de texte standard basées sur l’Unicode, mais elles suffisent pour ne pas être complètement démuni dans les cas où on sait avoir affaire à de l’ASCII et veut profiter de ce cas particulier pour optimiser le traitement.
Conversions depuis et vers le texte
Tous les types primitifs de Rust implémentent les traits
ToString
et
FromStr
, qui
permettent respectivement de les convertir en format textuel sans pertes
d’information, et de décoder du texte de format standardisé (en gros, toutes les
litérales Rust) en gérant les erreurs.
Ces opérations utilisent des notions que nous n’avons pas encore abordées (traits, gestion des erreurs), donc pour l’instant je vous demande juste de retenir qu’elles existent, et vous pourrez y revenir quand vous aurez lu les sections adéquates de ce cours.
Types produits
En théorie des types, si on a T et U deux types, on appelle type produit le type T x U dont les valeurs contiennent une valeur de type T et une valeur de type U. Le nom de type produit vient du fait que cette construction est équivalente à celle du produit cartésien en théorie des ensembles.
Rust fournit deux implémentations du concept de type produit, les tuples et les structs. Si ces deux notions se retrouvent aussi en C++, nous allons voir que la conception de la version Rust est assez différente, globalement pour le mieux.
Tuples
Cas général
La façon la plus simple de grouper des données hétérogènes en Rust est de les
mettre dans un tuple. Ici, pas besoin d’un constructeur spécial comme
std::make_tuple
en C++. On met juste les données entre parenthèses, séparées
par des virgules, et c’est parti :
#![allow(unused)] fn main() { let tuple = (1, "bonjour"); println!("{tuple:?}"); }
Le nom d’un type tuple est tout aussi simple à retenir. C’est exactement la même syntaxe que pour créer un tuple, mais avec des types au lieu des valeurs :
#![allow(unused)] fn main() { let int_float_str: (u32, f64, &str) = (1, 2.0, "blabla"); }
On peut accéder aux différents éléments d’un tuple via une indexation à la compilation…
#![allow(unused)] fn main() { let t = (123, 4.56); println!("{t:?} contient {} et {}", t.0, t.1); }
…mais la façon la plus courante de déstructurer un tuple en Rust est d’utiliser des motifs (patterns). Nous reviendrons sur cette possibilité après avoir présenté les autres types structurés de Rust.
Tuple vide
Un cas particulier intéressant est le tuple vide ()
, parfois aussi appelé type
unité (unit) par les théoriciens. On peut créer un tuple de ce type soi-même,
mais ça n’a pas un très grand intérêt puisqu’un tel tuple ne contient aucune
information :
#![allow(unused)] fn main() { let unite: () = (); }
Ce qui est plus intéressant, en revanche, c’est que toutes les opérations qui ne
retournent pas de résultat, à la manière des fonctions void
en C++, retournent
une valeur de type unité en Rust :
#![allow(unused)] fn main() { let resultat = println!("Bonjour"); println!("{resultat:?}"); }
Ce choix de conception se révèle très bénéfique quand on essaie d’écrire du code
générique qui prend des fonctions en paramètre. Il évite d’avoir à gérer
séparément le cas des fonctions qui retournent void
et celui des fonctions
qui retournent des données d’un autre type.
Plus généralement, la notion de tuple vide prend tout son sens dans le code générique, où elle est un moyen d’avoir un paramètre de type optionnel : passer le type () au code générique génère du code équivalent à ce qui se passerait si le paramètre de type et le code associé n’existaient pas.
Si on reprend la vision théorique introduite au début de ce chapitre, le tuple vide peut aussi être vu comme l’élément identité du produit de types. D’où son autre nom de type unité.
Puisque le tuple vide ne contient pas d’information, il n’occupe aucun stockage en mémoire à l’exécution. Rust n’a heureusement pas d’équivalent à la règle de C++ qui impose que les valeurs de tout type doivent utiliser au moins un octet d’espace mémoire :
#![allow(unused)] fn main() { let taille_unite = std::mem::size_of::<()>(); // Equivalent du sizeof() de C++ println!("{taille_unite}"); }
Tuple unaire
Pour terminer cette discussion sur les tuples, mentionnons une autre syntaxe plus exotique qu’on croise parfois dans la nature, le tuple à un élément. Il se distingue d’une expression entre parenthèses par l’ajout d’une virgule finale, dans le type comme dans les valeurs :
#![allow(unused)] fn main() { let mono: (u32,) = (123,); }
Cette virgule finale est tolérée pour les autres tuples, ce qui facilite la génération de code :
#![allow(unused)] fn main() { let duo: (u8, u16,) = (123, 456,); }
Structs
Introduction
Si le tuple est un moyen très pratique de créer des groupes de données ad-hoc,
mieux vaut ne pas l’utiliser à grande échelle dans son code, car ce type
n’offre aucune auto-documentation. Si quelque part au milieu d’un bout de code
vous voyez un tup.3
, vous n’avez aucune idée de ce que ce quatrième élément
du tuple représente, sans vous pencher sur le code qui a créé la valeur tup
.
Bien sûr, ponctuellement, on peut donner à une variable tuple un nom qui donne
une idée de la fonction des éléments, du style key_and_value
. Mais si on se
retrouve à utiliser des tuples identiques en plusieurs points de son code, il
vaut mieux donner un nom clair aux différents éléments de façon centralisée.
C’est une des fonctions des structs en Rust :
#![allow(unused)] fn main() { // Déclaration d'un type struct struct KeyValue { key: u16, value: String, } // Création d'une variable de type struct let kv = KeyValue { key: 123, value: String::from("blabla"), }; // Accès aux membres println!("{}", kv.value); }
Quelques remarques s’imposent :
- Quand on déclare un membre de struct, comme quand on déclare une variable
explicitement typée avec
let
, on commence par donner un nom avant de donner un type. - Contrairement à une déclaration de variable, une déclaration de membre doit contenir un type. C’est un choix de conception omniprésent en Rust : si quelque chose est susceptible d’apparaître dans une API, ce quelque chose doit être explicitement typé.
- Les déclarations de membres sont séparées par des virgules (ce qui rend la syntaxe de déclaration et d’utilisation plus similaire), et on peut écrire une virgule excédentaire à la fin. C’est même idiomatique, car cela permet de réordonner facilement les membres plus tard.
- L’ordre dans lequel les membres sont déclarés n’affecte pas non plus l’ordre
dans lequel ils doivent être spécifiés lors de la création d’une valeur, qui
est libre. On peut tout à fait créer une valeur de type
KeyValue
en écrivantvalue:
en premier si on le souhaite :#![allow(unused)] fn main() { struct KeyValue { key: u16, value: String, } KeyValue { value: String::from("blabla"), key: 123, }; }
- Sauf indication contraire du programmeur, l’ordre des membres dans la déclaration d’une struct n’affecte pas la représentation mémoire, qui est choisie automatiquement par le compilateur pour minimiser le padding. On peut donc écrire ses membres dans l’ordre le plus logique pour le lecteur, sans risque pour l’empreinte mémoire et la localité de cache. C’est aussi vrai des tuples, un tuple étant traité exactement comme une struct à bas niveau.
- Comme un tuple vide, une struct vide n’occupe aucune place en mémoire. Et les
membres vides d’une struct n’ajoutent rien au poids de la struct.
#![allow(unused)] fn main() { struct Vide { membre_vide: () } println!("{}", std::mem::size_of::<Vide>()); }
La création d’une valeur est moins remarquable du point de vue d’un programmeur C++. La seule chose qui est inhabituelle est qu’on est obligé de préciser le nom des membres. Cela rend le code plus lisible, ce qui est une des grandes raisons d’utiliser une struct plutôt qu’un tuple.
Il y a un raccourci utile à connaître : si vous avez déjà dans le scope des variables portant le nom des membres d’une struct, vous pouvez définir des membres ayant la valeur de ces variables comme ceci :
#![allow(unused)] fn main() { struct KeyValue { key: u16, value: String, } let value = "blabla".to_string(); KeyValue { value, // Il n'est pas nécessaire de le faire pour tous les membres key: 123, }; }
Support du typage fort
Une autre raison d’utiliser une struct plutôt qu’un tuple (ou un type primitif en général) est de créer un type distinct, incompatible du point de vue du compilateur, ce qui permet d’avoir plus de contrôle sur ses interfaces.
Quand on abuse des types primitifs dans ses interfaces, on peut se retrouver
dans une situation où on a plusieurs fonctions qui acceptent en entrée des
données de même type, mais les interprètent très différemment. Par exemple une
fonction interprète un flottant f32
comme une distance en mètres et l’autre
l’interprète comme une distance en miles.
Ce type de différence d’interprétation est une source de bugs coûteux, et la création d’un plus grand nombre de types est un moyen d’éviter ces problèmes :
#![allow(unused)] fn main() { struct Metres { inner: f32 } struct Miles { inner: f32 } let a = Metres { inner: 4.2 }; let b = Miles { inner: 5.6 }; a = b; // Ne compile pas }
Pour aider à ce type d’utilisation, par défault une struct Rust n’implémente presque aucune opération, même si le ou les types intérieurs supportent ces opérations :
#![allow(unused)] fn main() { struct Metres { inner: f32 } // Erreur de compilation : Metres n'implémente pas Mul par défaut (on peut ainsi // définir un Mul manuel qui fait des conversions et retourne une Surface) let a = Metres { inner: 1.23 } * Metres { inner: 4.56 }; // Erreur de compilation : Metres n'implémente pas Debug println!("{:?}", Metres { inner: 7.89 }); }
Dans le cas de Debug
, on pourrait trouver ça excessif, vu qu’il y a une
implémentation évidente. Mais comme le message d’erreur de compilation
l’indique, il suffit d’ajouter une petite directive lors de la déclaration de
la struct pour que le compilateur génère ladite implémentation évidente…
#![allow(unused)] fn main() { #[derive(Debug)] struct Metres { inner: f32 } println!("{:?}", Metres { inner: 4.2 }); }
…et c’est important de pouvoir remplacer cette implémentation évidente par une implémentation plus futée lorsqu’elle devient trop verbeuse pour servir son objectif de déboguage efficace.
Nous reviendrons sur ce mécanisme d’implémentation automatique de traits via la
directive derive()
lorsque nous aurons abordé les traits.
Les membres d’une struct peuvent aussi être rendus privés, contrairement à
ceux d’un tuple qui sont toujours publics. L’utilisation de struct en
Rust permet donc l’encapsulation des données, un peu comme class
et private
en C++. Nous reviendrons sur ce point quand nous aborderons la question des
modules, qui sont le mécanisme par lequel on contrôle la visibilité en Rust.
Tuple structs
Un problème du typage fort est sa tendance à produire du code très verbeux. Par
exemple, en lisant les exemples ci-dessus avec Metres
et Miles
, vous avez
peut-être trouvé que la répétition de inner
devenait très vite indigeste.
Pour les cas comme ça où nommer les membres d’une struct finit par faire plus de mal que de bien, Rust fournit un compromis entre le tuple et la struct, logiquement appelé tuple struct. C’est globalement une struct, mais dont les membres sont anonymes comme un tuple.
#![allow(unused)] fn main() { #[derive(Debug)] struct TupleStruct(u32, f64); let a = TupleStruct(123, 4.56); println!("{a:?} contient {} et {}", a.0, a.1); }
Dans la vraie vie, ce type de struct est quasiment toujours utilisé pour des tâches comme celles discutées ci-dessus, où on encapsule une donnée d’un type primitif pour créer des interfaces plus fortement typées autour de ce type.
Types sommes
En théorie des types, si on a T et U deux types, on appelle type somme le type T + U dont les valeurs contiennent une valeur de type T ou une valeur de type U. Le nom de type somme vient du fait que cette construction est équivalente à celle d’union en théorie des ensembles, or l’union joue un rôle de somme dans l’algèbre des ensembles.
Rust fournit deux implémentations du concept de type somme, les enums et les
unions. Comme nous allons le voir, les unions de Rust sont très proches de
celles de C et C++, mais les enums de Rust sont très différentes de celles de
C++. On devrait plutôt les comparer au type std::variant
qui est enfin
arrivé en C++17, avec toutefois de nombreuses différences.
Enums
Introduction
En Rust, on définit une enum en spécifiant une série de variantes, dont chacune est définie à peu près comme une struct. Le type énuméré ainsi défini se comporte de la façon suivante :
- Le type énuméré se comporte un peu comme un namespace contenant des structs définies par les différentes variantes.
- Une variable du type énuméré peut contenir des valeurs de n’importe de laquelle des variantes ainsi définies, et passer d’une variante à l’autre au fil de son cycle de vie.
Voici un exemple qui illustre les principales possibilités. N’hésitez pas à jouer un peu avec pour vous familiariser avec le concept :
fn main() { #[derive(Debug)] enum Exemple { Rien, Entier(u32), Flottant { inner: f32 }, } let a = Exemple::Rien; let mut b = Exemple::Entier(123); b = Exemple::Flottant { inner: 4.2 }; println!("{a:?}\n{b:?}"); }
Vous noterez au passage que j’ai déclaré mon enum dans une fonction. C’est autorisé en Rust. On peut déclarer presque n’importe quoi dans une fonction, y compris d’autres fonctions.
Bien sûr, il vaut mieux vaut user de cette possibilité avec parcimonie pour ne pas rendre le code illisible, mais dans le cas de code à usage unique comme un garde RAII (type dont la fonction est de nettoyer l’état du programme en cas de panic), ce type de déclaration locale a totalement du sens.
Implémentation
L’implémentation correspond plus ou moins à une tagged union en C, c’est à dire une union C des différentes structs intérieures doublée d’un discriminant entier qui indique à quelle variante de l’union on a affaire actuellement.
Mais comme l’organisation des données en mémoire est flexible par défaut en Rust, selon le nombre de variantes et la nature des types utilisés, le compilateur peut souvent exploiter l’existence de valeurs interdites au sein des types de données intérieurs pour faire en sorte qu’une enum ne prenne pas plus de place en mémoire que la plus grande de ses variantes :
#![allow(unused)] fn main() { enum MaybeChar { Nothing, Some(char), } println!("size_of<char>: {}", std::mem::size_of::<char>()); println!("size_of<MaybeChar>: {}", std::mem::size_of::<MaybeChar>()); }
Discriminant explicite
La notion d’enum class
de C++ est traitée en Rust comme un cas particulier du
type enum général. Si aucune des variantes de l’enum ne contient des données,
on peut indiquer explicitement quel entier doit être utilisé pour stocker chaque
variante en mémoire. Une simple conversion as
permet ensuite de récupérer la
valeur de cet entier.
#![allow(unused)] fn main() { #[derive(Debug)] enum Numerique { Un = 1, Deux, // = 2 Trois, // = 3 } println!("{}", Numerique::Trois as usize); }
Mais la conversion inverse nécessite l’utilisation de unsafe car le compilateur ne peut pas, dans le cas général, vérifier que l’entier fourni en paramètre est une valeur valide pour l’enum cible.
#![allow(unused)] fn main() { #[derive(Debug)] enum Numerique { Un = 1, Deux, // = 2 Trois, // = 3 } // SAFETY: J'ai vérifié que c'est une valeur d'enum valide let num: Numerique = unsafe { std::mem::transmute(2u8) }; println!("{:?}", num); }
Enum vide et !
(Never)
L’enum vide, qui ne possède aucune variante, est un cas particulier intéressant :
#![allow(unused)] fn main() { enum Vide {} }
On ne peut pas créer de valeurs de ce type puisqu’il n’a pas de variante.
enum Vide {}
let x: Vide = /* ??? */;
Par conséquent, tout tuple ou structure contenant une valeur de type Vide
ou
équivalent ne peut pas être construit, et son existence peut être ignorée en
toute sécurité par le compilateur. Le code qui l’utilise peut être supprimé,
les variantes d’enum qui en contiennent peuvent être ignorées, etc.
Du point de vue mathématique, l’enum vide est l’élément neutre de l’ensemble des types énumérés, il ne change pas le type énuméré auquel on l’ajoute. C’est le zéro de l’addition des types.
Dans un système de type, ce genre de type sans valeur est utilisé pour représenter des situations impossibles. Par exemple la valeur retournée par une fonction qui arrête le programme :
let x: /* ??? */ = std::proces::abort();
Rust définit donc une enum vide standard qui s’écrit !
et se prononce “Never”
pour représenter ce genre de situation. Cette enum est “retournée” par toutes
les expressions qui ne retournent jamais de valeur à l’appelant : boucles
infinies, arrêt du programme, etc. Et puisque ce type n’a pas d’importance, le
compilateur la “convertit” librement vers n’importe quel autre type…
#![allow(unused)] fn main() { let n: u32 = std::process::abort(); // Ce code compile }
…ce qui prend tout son sens dans un contexte de gestion des erreurs, par exemple quand on se retrouve avec ce genre d’expression :
#![allow(unused)] fn main() { let suppose_vrai = true; // if .. else en Rust fonctionne comme l'opérateur ternaire ? : en C++ let reponse = if suppose_vrai { 42 } else { std::process::abort() }; println!("{reponse}"); }
Motifs
Introduction
Pour l’instant, je vous ai montré comment on déclare un type énuméré, et comment on définit des variables de ce type. Reste à savoir comment, muni d’une valeur de ce type, on peut déterminer à quelle variante du type on a affaire, et utiliser les données que cette variante contient.
La solution de Rust a se problème s’appelle les motifs (patterns). Ce terme exotique peut faire peur, mais en réalité nous en utilisons depuis la toute première déclaration de variable du cours :
#![allow(unused)] fn main() { let reponse = 42; // ^^^^^^^ // MOTIF }
En Rust, ce qui suit le mot-clé let
que nous utilisons pour déclarer nos
variables n’est pas forcé d’être un nom de variables, cela peut être toutes
sortes d’autres choses. Quelques exemples :
fn main() { #[derive(Debug)] struct S { x: i32, y: usize, } let obj = S { x: 12, y: 34 }; let tup = (123, 4.56); // Déstructuration d'un tuple let (a, b) = tup; println!("{tup:?} -> {a} et {b}"); // Déstructuration d'une structure let S { x: abc, y: def } = obj; println!("{obj:?} -> {abc} et {def}"); }
Ce que l’on met après let
et avant le signe =
s’appelle un motif
(pattern). Plus précisément, les motifs qui sont utilisés dans l’exemple
ci-dessus sont des motifs de
déstructuration.
Déstructuration
La syntaxe d’un motif de déstructuration est très similaire à celle qu’on utilise pour écrire des valeurs d’un type structuré dans le code, sauf que…
- Là où on écrirait normalement les valeurs des données membres, on écrit des
noms de variable. Cela crée des variables portant ces noms, qui “capturent”
les valeurs membres correspondantes dans l’expression à droite du signe
=
. - On peut ignorer un membre en utilisant
_
à la place d’un nom de variable :#![allow(unused)] fn main() { let (x, _) = (12, 34); println!("{x}"); }
- On peut ignorer tous les membres qui ne nous intéressent pas avec
..
:#![allow(unused)] fn main() { struct S { x: u8, y: u16, z: u32, t: u64 } let val = S { x: 1, y: 2, z: 3, t: 4 }; let S { z: coord_z, .. } = val; println!("{coord_z}"); }
- Quand les membres sont nommés (pas comme les tuples), on peut utiliser une
syntaxe raccourcie qui reprend les noms des membres :
#![allow(unused)] fn main() { struct S { x: u8, y: u16, z: u32, t: u64 } let val = S { x: 1, y: 2, z: 3, t: 4 }; let S { x, y, z, t } = val; println!("{x} {y} {z} {t}"); }
match
Tout ceci est très bien, mais il nous manque encore un ingrédient pour déstructurer nos enums. En effet, le code qui suit ne compile pas :
#![allow(unused)] fn main() { #[derive(Debug)] enum IntFloat { Int(i32), Float(f32), } let mut val = IntFloat::Int(42); // ... beaucoup de code plus tard ... let IntFloat::Int(x) = val; // Erreur de compilation }
La raison pour laquelle ce code ne compile pas est qu’une valeur val
de type
IntFloat
ne contient pas forcément une valeur de la variante Int
de
IntFloat
. Elle peut aussi contenir une valeur de la variante Float
. Et hors
des cas triviaux, le compilateur ne sait pas ce qu’il en est. Pour qu’on n’ait
pas des motifs qui marchent ou pas selon ce que l’analyseur statique du
compilateur a réussi à prouver, ce type de code est donc systématiquement
rejeté de façon pessimiste.
En termes plus formels, les motifs utilisés après let
doivent être
irréfutables,
c’est à dire que le compilateur ne doit pas être capable de trouver une variante
du type à droite qui n’est pas couverte par le motif de gauche. Cela n’est pas
possible avec les types énumérés, sauf avec le motif trivial “affectation de
variable” :
#![allow(unused)] fn main() { #[derive(Debug)] enum IntFloat { Int(i32), Float(f32), } let mut val = IntFloat::Int(42); let val2 = val; }
Pour gérer les types énumérés, il faut qu’on ait la possibilité de choisir un
motif parmi plusieurs. Ce travail est assuré par match
en Rust :
#![allow(unused)] fn main() { #[derive(Debug)] enum IntFloat { Int(i32), Float(f32), } let mut val = IntFloat::Int(42); match val { IntFloat::Int(i) => println!("Entier {i}"), IntFloat::Float(f) => println!("Flotant {f}"), } }
Avec match
, c’est l’ensemble des alternatives proposées qui doit être
irréfutable. Autrement dit, on ne peut pas oublier une variante d’une enum
quand on utilise match
, sinon cela causera une erreur de compilation. Quand
on ne veut pas traiter tous les cas, on doit le dire explicitement en utilisant
un motif qui matche toujours en fin de liste :
#![allow(unused)] fn main() { #[derive(Debug)] enum IntFloat { Int(i32), Float(f32), } let mut val = IntFloat::Float(4.2); match val { IntFloat::Int(i) => println!("Entier {i}"), a => println!("{a:?} n'est pas entier, je m'en fiche"), } }
Raccourcis
Pour le cas où on veut traiter un seul cas et on s’en fiche des autres, on peut
alléger la syntaxe avec le raccourci if let
. Ce code est équivalent au
précédent.
#![allow(unused)] fn main() { #[derive(Debug)] enum IntFloat { Int(i32), Float(f32), } let mut val = IntFloat::Int(42); if let IntFloat::Int(i) = val { println!("Entier {i}"); } else { println!("{val:?} n'est pas entier, je m'en fiche"); } }
Souvent, on veut extraire les valeurs quand le motif attendu est présent et
arrêter le traitement sinon. On pourrait le faire avec if let
, mais ça reste
encore un peu laborieux…
#![allow(unused)] fn main() { #[derive(Debug)] enum IntFloat { Int(i32), Float(f32), } let mut val = IntFloat::Int(42); // match et if let sont des expressions, qui peuvent retourner des valeurs let i = if let IntFloat::Int(i) = val { i } else { return }; println!("Entier {i}"); }
A la place, mieux vaut utiliser let .. else { .. }
:
#![allow(unused)] fn main() { #[derive(Debug)] enum IntFloat { Int(i32), Float(f32), } let mut val = IntFloat::Int(42); let IntFloat::Int(i) = val else { return }; println!("Entier {i}"); }
Autres motifs
La destructuration n’est pas le seul type de motif supporté par Rust. Voici quelques autres exemples de motifs supportés :
fn main() { let compteur = 42; let nombre = match compteur { // Litérale 1 => "Un", // Intervalle 2 <= x <= 6 2..=6 => "Plusieurs", // Plusieurs alternatives compatibles 24 | 42 => "Ce qu'il faut", // Conditions x if x < 42 => "Beaucoup", // Le premier motif qui colle est sélectionné, si aucune des // conditions ci-dessus n'est remplie ce sera celui-ci. _ => "Trop", }; println!("{nombre}"); }
On trouve la liste ce qui est possible avec plein d’autres exemples dans la doc de référence et les pages associées de Rust By Example, mais ces exemples utilisent plusieurs fonctionnalités que nous n’avons pas encore abordées, notamment les références, donc ne vous attendez pas à tout comprendre tout de suite.
Unions
Pour des raisons d’interopérabilité avec le C, Rust doit aussi supporter les unions “à la C”, celles-ci sont donc disponibles et utilisables via le code unsafe :
#![allow(unused)] fn main() { union IntFloatBool { i: u32, f: f32, b: bool, } let ifb = IntFloatBool { i: u32::MAX }; // Utilisation correcte : l'union contient une valeur de type u32 let x = unsafe { ifb.i }; // Utilisation correcte : les octets de u32 peuvent être réinterprété en f32 let y = unsafe { ifb.f }; // Equivalent standard du code ci-dessus, pas besoin d'unsafe let y2 = f32::from_bits(x); // Utilisation INCORRECTE : le premier octet de u32::MAX n'est pas une // représentation valide d'un booléen. Ce code cause du comportement indéfini. /* let z = unsafe { ifb.b }; */ }
Il n’est presque jamais nécessaire de s’en servir hors interaction avec le C/++ parce que tous les cas d’utilisation courants sont couverts par la bibliothèque standard avec une interface plus sûre.
Code structuré
Après ce premier tour d’une partie des types de Rust, qui a posé toutes les briques pour expliquer match, nous pouvons maintenant faire le tour de la plupart des structures de contrôles de Rust.
Conditions
if
/else
Les principales différences entre les if/else de C++ et Rust sont qu’en Rust…
- Il n’y a pas de parenthèses obligatoires autour de la condition de “if”
- if/else est une expression, et remplace donc l’opérateur ternaire de C++
#![allow(unused)] fn main() { let condition = true; let choix = if condition { 42 } else { 24 }; }
if let <motif> = <expression>
, que nous avons introduit dans le chapitre précédent, propose une alternative plus légère àmatch
pour les cas simples#![allow(unused)] fn main() { enum PeutEtre { Oui(u32), Non } // ... plus tard ... let x = PeutEtre::Oui(42); if let PeutEtre::Oui(x) = x { println!("La réponse est {x}"); } else { println!("Pas de réponse..."); } }
Jusqu’ici, rien de bien nouveau.
Pattern matching
Pour sélectionner différents bouts de code en fonction de la valeur d’une
expression, Rust fournit match
, que nous avons présenté dans le chapitre sur
les types sommes. Ce mécanisme remplace avantageusement le switch
de C++,
qui n’existe donc pas en Rust.
Boucles
Formes
Venant de C++, Rust a un nombre inhabituel de types de boucles.
Il y a d’abord la boucle infinie…
#![allow(unused)] fn main() { // N'exécutez pas ce code ;) loop { println!("Salut, je m'appelle Horace"); } }
…la boucle while
avec une condition, familière quand on vient de C++…
#![allow(unused)] fn main() { let condition = false; while condition { println!("La condition est encore vraie"); } println!("La condition n'est plus vraie"); }
…sa variante while let
qui fait du pattern matching comme if let
…
#![allow(unused)] fn main() { enum PeutEtre { Oui(u32), Non } fn calcul() -> PeutEtre { PeutEtre::Non } while let PeutEtre::Oui(x) = calcul() { println!("Le calcul a encore retourné Oui avec le contenu {x}"); } }
…et une boucle qui accepte des objets itérables en paramètre, qui peuvent être
des itérateurs (implémentant le trait Iterator
) ou des objets itérables
(implémentant le trait IntoIterator
qui permet de créer un itérateur pour
itérer dessus).
#![allow(unused)] fn main() { println!("Boucle basée sur un itérateur (trait Iterator)"); for c in "Ge\u{0301}nial".chars() { println!("- {c}"); } println!(); println!("Boucle basée sur un objet itérable (trait IntoIterator)"); for valeur in [1.2, 3.4, 5.6] { println!("- {valeur}"); } }
Nous reviendrons sur cette boucle un peu plus tard, lorsque nous aurons traité la notion d’itérateur. Mais pour l’heure, retenez qu’il y a une différence terminologique entre C++ et Rust : les itérateurs de Rust correspondent aux ranges de C++20, pas aux itérateurs historiques de C++.
Itérer sur des entiers
Une chose qu’on veut généralement faire quand on est habitué au C++, c’est itérer sur des entiers. C’est possible via la syntaxe des intervalles (ranges) :
#![allow(unused)] fn main() { // Intervalle fermé à gauche, ouvert à droite (comme le range() de Python) for i in 0..4 { println!("{i}"); } println!(); // Intervalle fermé à gauche et à droite for j in 2..=4 { println!("{j}"); } }
Mais ce type d’itération est moins souvent utilisé en Rust, car on lui préfère habituellement l’itération sur les conteneurs. Nous reviendrons sur cette question dans le chapitre sur les itérateurs.
Contrôle
Au sein d’une boucle, on peut utiliser les habituelles instructions break
et
continue
pour affecter le déroulement de la boucle :
#![allow(unused)] fn main() { let condition = false; loop { println!("Bonjour"); if condition { continue; } else { break; } } println!("Au revoir"); }
Et comme Rust est un langage orienté expressions, les boucles infinies peuvent
retourner une expression comme les autres structures du langage, grâce à la
forme de break
qui prend une valeur en paramètre (le break;
sans argument
étant équivalent à break ();
).
#![allow(unused)] fn main() { let resultat = loop { println!("Itération de boucle"); break 42; }; println!("La réponse est {resultat}"); }
Un problème bien connu de break
et continue
est qu’ils ne fonctionnent pas
toujours comme on veut lorsqu’on a plusieurs boucles imbriquées. Rust résout ce
problème en permettant de nommer les boucles pour clarifier de quelle boucle on
parle :
#![allow(unused)] fn main() { 'externe: loop { loop { break 'externe; } } }
Et comme break
est un outil bien pratique pour la gestion de conditions
exceptionnelles, Rust permet de l’utiliser en-dehors des boucles via les blocs
nommés, qui se comportent comme une boucle d’une seule itération du point de
vue de break
.
#![allow(unused)] fn main() { let traitement1_ok = || true; let traitement2_ok = || false; let traitement1 = || (); let traitement2 = || (); let nettoyage = || (); 'bloc: { if !traitement1_ok() { break 'bloc; } traitement1(); if !traitement2_ok() { break 'bloc; } traitement2(); } nettoyage(); }
Fonctions
Autant les structures de contrôle de Rust sont très similaires à celles de C++,
en-dehors de match
et de ses cousins if let
et while let
, autant au niveau
des fonctions il y a plusieurs différences, que nous allons aborder dans ce
chapitre.
Nous allons en particulier voir comment Rust gère le concept de fonction
anonyme (lambda), et introduirons les trois modificateurs const
, unsafe
et
async
.
En revanche, la déclaration des méthodes associées à un type sera introduite plus tard dans le chapitre dédié aux blocs d’implémentation, et le fonctionnement des fonctions génériques sera introduit dans le chapitre dédié aux traits et à la généricité.
Déclaration
La syntaxe pour déclarer une fonction ne devrait pas vous surprendre beaucoup compte tenu des autres syntaxes que nous avons déjà abordées :
#![allow(unused)] fn main() { fn addition(x: u32, y: u32) -> u32 { x + y } }
On retrouve le mot-clé fn
suivi du nom de la fonction, une liste de paramètres
entre parenthèses, et le résultat après une flèche. Si il n’y a pas de
résultat, on peut enlever cette dernière partie, et dans ce cas la fonction
retourne le type unité ()
.
Notez qu’en général, on n’utilise pas explicitement le mot-clé “return” pour retourner des résultats d’une fonction en Rust, car celui-ci est redondant. Il suffit d’écrire une expression à la fin du code de la fonction pour que le résultat correspondant à cette expression soit retourné par la fonction.
Le mot-clé return
n’est donc utilisé que quand on doit sortir d’une fonction
plus tôt que prévu. Comme en C++, il est par exemple couramment utilisée pour
gérer les cas exceptionnels sans se retrouver avec une pile de else
imbriqués dans son code :
#![allow(unused)] fn main() { fn sinc(x: f32) -> f32 { // Cas exceptionnel if x == 0.0 { return 1.0; } // Cas général x.sin() / x } }
Comme les déclarations de variables, les déclarations de paramètres de fonction permettent l’utilisation de motifs irréfutables :
fn main() { // Notez qu'il existe aussi des motifs pour les tableaux fn dot3([x1, y1, z1]: [f32; 3], [x2, y2, z2]: [f32; 3]) -> f32 { x1 * x2 + y1 * y2 + z1 * z2 } let v1 = [1.2, 3.4, 5.6]; let v2 = [9.8, 7.6, 5.4]; let produit = dot3(v1, v2); println!("{produit}"); }
Cela permet d’exposer une fonction qui prend un type structuré en paramètre pour l’utilisateur, et qui le déstructure dans l’implémentation.
Fonctions anonymes
La conception de Rust est fortement inspirée des langages de programmation fonctionnels, et le code Rust idiomatique fait un usage intensif du style fonctionnel. Il y a donc un fort besoin d’avoir une syntaxe concise pour déclarer des fonctions à usage unique en plein milieu du code, pour ne pas se retrouver avec ce genre de fatras de déclarations de fonctions :
#![allow(unused)] fn main() { // Création d'un tableau dont chaque élément est défini par l'application // de la fonction "constructeur" à son indice. // // Notez qu'on peut utiliser "_" pour laisser le compilateur inférer une // partie du type en précisant une autre partie. fn constructeur(i: usize) -> u64 { 3 * (i as u64) } let tableau: [_; 30] = std::array::from_fn(constructeur); // Recherche d'un élément dans le tableau vérifiant le prédicat "recherche" fn recherche(valeur: &u64) -> bool { *valeur == 42 } let pos = tableau.iter().position(recherche); // Si l'élément n'a pas été trouvé, on utilise un résultat par défaut, // calculé uniquement dans ce cas car il son calcul est coûteux. fn substitut() -> usize { /* ...un calcul compliqué... */ usize::MAX } let resultat = pos.unwrap_or_else(substitut); }
En Rust, comme en C++11, ce besoin est assuré par les fonctions anonymes, aussi appelées “lambdas” ou de façon plus obscure “clôtures lexicales” (lexical closures). Avec elles, l’exemple compliqué ci-dessus est grandement simplifié :
let tableau: [_; 30] = std::array::from_fn(|i| 3 * (i as u64));
let resultat = tableau.iter()
.position(|i| *i == 42)
.unwrap_or_else(|| /* ...un calcul compliqué... */)
Les fonctions anonymes ont deux propriétés importantes pour ce type d’application :
- Leurs types d’entrée et de sortie sont inférables. Mais il est aussi possible
de les préciser en cas d’échec de l’inférence de type ou si ça améliore la
lisibilité du code :
#![allow(unused)] fn main() { let constructeur = |i: usize| -> u64 { 3 * (i as u64) }; }
- Elles peuvent capturer les valeurs de variables de leur environnement :
#![allow(unused)] fn main() { // Recherche la position de `valeur` dans le tableau `tab`, retourne // `usize::MAX` si la valeur n'est pas trouvée. fn recherche(tab: [u32; 4], valeur: u32) -> usize { tab.iter().position(|x| *x == valeur).unwrap_or(usize::MAX) } }
Comme en C++, la capture peut s’effectuer par valeur ou par référence, mais la façon de le spécifier est fortement simplifiée par rapport à C++. Comme nous n’avons pas encore traité les références, je vais devoir survoler un peu, mais les exemples ultérieurs devraient clarifier les choses :
- Si on ne précise rien lors de la déclaration de la fonction anonyme, toutes les variables référencées sont capturées par référence.
- Si on ajoute le mot-clé
move
avant la déclaration de la fonction anonyme, les variables référencées sont capturées par valeur. Nous verrons ultérieurement ce que ça change et dans quel cas c’est intéressant.#![allow(unused)] fn main() { fn recherche(tab: [u32; 4], valeur: u32) -> usize { // Pas de différence visible dans ce cas précis tab.iter().position(move |x| *x == valeur).unwrap_or(usize::MAX) } }
- Si on est dans un cas plus exotique où on désire un mélange de capture par
valeur et par référence, on peut le faire en commençant par construire une
référence, puis en capturant la référence par valeur. On utilise alors
généralement un bloc pour clarifier l’intention :
#![allow(unused)] fn main() { // Déclarations préalable let x = 123; let y = 456; // Mélange de capture par valeur et par référence { let ref_x = &x; let lambda = move |a: u32| a == *ref_x || a == y; } }
Fonctions const
Il n’est pas possible de calculer la valeur d’une variable statique ou d’une constante de compilation avec une fonction ordinaire. Le compilateur refusera donc ce genre de code :
#![allow(unused)] fn main() { fn valeur() -> u32 { 42 } const VALEUR: u32 = valeur(); // Erreur: valeur() ne peut pas être utilisée ici }
Cela s’explique par le fait que certaines opérations ne peuvent pas être effectuées à la compilation :
- Pour certaines opérations, c’est parce que le travail de conception associé n’est pas encore terminé. Par exemple, la liste des manipulations autorisées sur l’adresse mémoire d’une allocation effectuée à la compilation n’est pas finalisée à l’heure où ces lignes sont écrites, et c’est un prérequis pour que l’allocation mémoire à la compilation soit stabilisée.
- Pour d’autres opérations, c’est parce que les concepteurs du langage pensent que c’est une mauvaise idée de permettre d’effectuer facilement ces opérations pendant le processus de compilation. Par exemple, écrire dans des fichiers à la compilation n’aura probablement pas le résultat attendu à l’exécution.
Or, un objectif de conception important de Rust est qu’il ne doit pas être possible de casser du code client en modifiant l’implémentation d’une fonction, sans toucher à son interface. Cet objectif n’est pas compatible avec le fait de permettre l’utilisation des fonctions normales, qui peuvent tout faire, dans un contexte restreint d’exécution de code à la compilation.
Pour rendre une fonction évaluable à la compilation, on ajoute donc le mot-clé
const
dans la déclaration. En échange de quoi le compilateur vérifiera que la
fonction peut être évaluée à la compilation, puis acceptera son utilisation
pour calculer des constantes de compilation :
#![allow(unused)] fn main() { const fn valeur() -> u32 { 42 } const VALEUR: u32 = valeur(); // OK }
Ce mécanisme est analogue aux fonctions constexpr
de C++, mais l’ensemble des
opérations qui peuvent être effectuées dans une fonction const fn
en Rust et
une fonction constexpr
en C++ est un peu différent : le code Rust peut faire
certaines choses dans une const fn
que le code C++ ne peut pas faire dans une
fonction constexpr
, et vice versa.
Comme avec constexpr
en C++, il est très important de bien comprendre que
l’ajout du mot-clé const
ne force pas une fonction à être évaluée à la
compilation. En dehors du contexte particulier du calcul des constantes de
compilation, c’est une fonction comme les autres vue de l’extérieur, et on peut
tout à fait lui passer en paramètre des valeurs qui ne sont connues qu’à
l’exécution, pour calculer des résultats à l’exécution comme on le ferait avec
n’importe quelle autre fonction.
De même, contrairement à une croyance tenace, appeler une fonction const fn
pendant l’exécution avec une valeur connue à la compilation ne garantit
aucunement que le résultat sera précalculé par l’optimiseur du compilateur
pendant la compilation. Si vous tenez à cette garantie, pas besoin d’une
sémantique consteval
spéciale à la C++23, en Rust il suffit de créer une
const
et lui affecter le résultat du calcul.
Fonctions unsafe
Nous avons vu précédemment plusieurs exemples de fonctions et méthodes unsafe
.
Il s’agit de fonctions possédant des préconditions qui doivent être respectées
par l’appelant, sous peine de quoi le comportement sera indéfini.
Puisque l’un des objectifs majeurs de Rust est qu’il ne doit pas être possible
de causer du comportement indéfini avec du code normal, on ne peut appeler des
fonctions unsafe
qu’au sein d’un bloc unsafe
. Cela rend les endroits du
code où du comportement indéfini est possible repérables avec des outils de
recherche textuelle simples tels que grep
:
#![allow(unused)] fn main() { // Déclaration d'une fonction unsafe. Il est très fortement recommandé de // clarifier les préconditions dans la documentation de la fonction. /// SAFETY: Ne doit être appelée que le lundi matin unsafe fn mort_au_lundi() { // ... actions spécifiques au maudit lundi, avec un risque de comportement // indéfini si on n'est pas lundi matin ... } // Interdit : Le comportement indéfini n'est pas autorisé en Rust hors unsafe /* mort_au_lundi(); */ // Autorisé : On a utilisé un bloc unsafe, le compilateur suppose qu'on sait // ce qu'on est en train de faire. Il est d'usage de clarifier pour les autres // développeurs au nom de quoi on pense que le comportement est défini. // SAFETY: J'ai briefé tous les collègues, ce code ne sera exécuté qu'un lundi. unsafe { mort_au_lundi() }; }
Si la bibliothèque standard de C++ était traduite en Rust, la quasi-totalité de
ses fonctions serait donc des unsafe fn
, puisque toutes les opérations
courantes de C++ sont susceptibles de déclencher du comportement indéfini si on
les appelle avec de mauvais arguments. On mesure ici l’ampleur du défi que
s’est lancé Rust en déclarant la guerre au comportement indéfini.
A l’heure où ces lignes sont écrites, il est possible d’utiliser des fonctions
unsafe
à l’intérieur de unsafe fn
sans bloc unsafe
supplémentaire. La
motivation initiale de ce raccourci était que le rôle de unsafe fn
est de
créer une abstraction de plus haut niveau par dessus des opérations unsafe
de
bas niveau. Mais à l’usage, cette conception est piégeuse, car on risque
d’appeler accidentellement des fonctions unsafe
sans y prendre garde et
vérifier leurs préconditions.
Il est donc probable que les règles de unsafe fn
soient révisées dans une
édition future du langage Rust, et je vous encourage dès à présent à utiliser
des blocs unsafe
à l’intérieur de vos fonctions unsafe
pour clarifier à
quels endroits du code vous effectuez des opérations dangereuses, et pourquoi
vous pensez que vous le faites correctement.
Dans la suite de ce cours, nous reviendrons sur la question du code unsafe
et
les opérations supplémentaires permises dans un bloc unsafe
en dehors
de l’appel aux fonctions unsafe
.
Fonctions async
Rust a assez récemment gagné un nouveau type de fonction, les fonctions asynchrones :
#![allow(unused)] fn main() { async fn foo() -> u32 { let x = 32; let resultat = bar(x).await; resultat } // L'ordre des déclarations n'a pas d'importance en Rust, ce qui permet de // commencer par les fonctions de haut niveau qui intéressent l'utilisateur et // terminer par les détails d'implémentation de bas niveau. async fn bar(x: u32) -> u32 { 2 * x } }
Cela fait parti de l’infrastructure de programmation asynchrone de Rust. En une phrase, celle-ci permet d’attendre que des événements se produisent au niveau de l’OS (temporisations, entrées/sorties…), de façon plus efficace qu’en bloquant un thread par événement attendu.
Un gros chapitre est dédié à l’asynchronisme en Rust à la fin de ce cours, et dans ce chapitre, je rentrerai dans les détails de cette infrastructure. Je vous invite à consulter ce chapitre pour en savoir plus, idéalement quand vous aurez une meilleure compréhension générale du langage car il a des recouvrements avec à peu près tous les autres sujets abordés dans ce cours…
Blocs impl
Nous avons vu précédemment que Rust permet d’associer des fonctions à des types, afin notamment de définir des méthodes. Pour déclarer ce type de fonctions, et d’autres entités attachées à un type, on utilise les blocs d’implémentation, qui sont l’objet de ce chapitre.
Introduction
En Rust, on ne mélange pas la déclaration des données d’un type et celle de
l’API associée comme en C++. On déclare les méthodes, fonctions et constantes
associées à un type dans des blocs d’implémentation séparés, introduits par le
mot-clé impl
.
En voici un exemple, que nous allons analyser dans la suite de ce chapitre :
#[derive(Debug)] struct Vec2(f32, f32); impl Vec2 { const X: Self = Self(1.0, 0.0); const Y: Self = Self(0.0, 1.0); fn new(x: f32, y: f32) -> Self { Self(x, y) } fn x(&self) -> f32 { self.0 } fn y(&self) -> f32 { self.1 } fn dot(&self, other: &Self) -> f32 { self.x() * other.x() + self.y() * other.y() } } fn main() { let v = (Vec2::X).dot(&Vec2::Y); println!("{v:?}"); }
Implémentation inhérente
Le premier élément nouveau dans le code ci-dessus, c’est le bloc
d’implémentation, introduit par le mot-clé impl
. Il en existe deux formes, la
forme simple discutée ici (bloc d’implémentation inhérent) et une forme plus
complexe impliquant les traits que nous aborderons dans le chapitre associé.
En Rust, on peut écrire autant de blocs d’implémentation qu’on veut pour un type, mais on ne peut écrire des blocs d’implémentation inhérents que pour les types que nous avons déclaré. Il est interdit d’en écrire pour des types définis par d’autres crates, y compris la bibliothèque standard.
La raison est que si c’était autorisé, cela nuirait à l’interopérabilité entre
bibliothèques. En effet, si par exemple deux bibliothèques décidaient
indépendamment d’ajouter une fonction foo()
au type usize
, nous ne pourrions
pas utiliser ces bibliothèques simultanément, car le compilateur ne saurait pas
quelle version de la méthode usize::foo()
il doit utiliser lorsqu’on appelle
cette méthode. Il faudrait donc remplacer tous nos appels à usize::foo()
par
une syntaxe explicite du genre usize::<foo from bibliotheque1>()
, ce qui
serait un cauchemar.
Ce problème est appelé le problème de la cohérence, et est analogue à la One Definition Rule de C++, le comportement indéfini en moins. Nous verrons plus tard comment Rust permet de le contourner partiellement grâce au mécanisme des traits.
Self
et self
Dans un bloc d’implémentation, on peut utiliser deux nouveaux mots-clés :
Self
, avec une majuscule, désigne le type auquel s’applique le bloc d’implémentation.self
, avec une minuscule, est utilisable en premier argument d’une fonction et désigne une variable d’un type “lié à” Self. Une fonction avec un tel argument peut être utilisée via la syntaxereceveur.methode()
, et on appelle une telle fonction une méthode.
Le paramètre self
a une palette de syntaxes assez
riche.
Dans l’exemple ci-dessus, nous utilisons &self
, qui est un raccourci vers la
syntaxe plus explicite self: &Self
, et permet de dire que cette méthode
accepte son paramètre self
par référence.
Le fonctionnement des références en Rust sera expliqué dans un prochain chapitre. Pour l’heure vous pouvez retenir que comme en C++, c’est un moyen de garder une variable à sa position actuelle en mémoire et d’en passer un genre de pointeur à la fonction qui l’utilise.
Constantes, fonctions et méthodes associées
En Rust, comme en C++, chaque type a un scope associé. Les déclarations que nous effectuons au sein d’un bloc d’implémentation sont ajoutées à ce scope, et on les appelle entités associées.
#[derive(Debug)] struct Vec2(f32, f32); impl Vec2 { const XY: Self = Self(1.0, 1.0); } fn main() { println!("{:?}", Vec2::XY); }
A l’heure où ces lignes sont écrites, on ne peut pas déclarer tout en n’importe quoi dans un bloc d’implémentation. Seules les déclarations de constantes et de fonctions/méthodes associées sont autorisées dans les blocs d’implémentation inhérents.
Une différence surprenante vue du C++ est que Rust ne possède presque pas de syntaxe dédiée pour les constructeurs. On utilise simplement des fonctions associées pour jouer ce rôle. En présence de plusieurs constructeurs, cela permet de donner des noms qui clarifient l’intention.
Pour donner un exemple le type File
de la bibliothèque standard, qui permet de
manipuler des fichiers, a une fonction associée File::open()
pour ouvrir un
fichier en lecture seule et une autre fonction associée File::create()
pour
ouvrir un fichier en écriture en le créant s’il n’existe pas.
Mais il existe quand même quelques fonctions constructeur spéciales en Rust, ce sont celles qui permettent de construire les tuple structs et variantes de types énumérés de type tuple :
#![allow(unused)] fn main() { // Ce code... struct Tuple(u32, u16); // ...définit implicitement une fonction constructeur Tuple, qui prend un u32 // et un u16 en paramètre et retourne un Tuple de ces valeurs. // Et ce code... enum Enum { Tuple(usize), } // ...définit implicitement une fonction constructeur Enum::Tuple, qui prend un // usize et retourne la variante Enum::Tuple avec cette valeur à l'intérieur. }
Quand aux destructeurs de C++, leur équivalent en Rust est le trait Drop
, que
nous pouvons implémenter avec une syntaxe sur laquelle nous reviendrons dans le
chapitre sur les traits :
struct Dechet; impl Drop for Dechet { fn drop(&mut self) { println!("Tu OSES me jeter ?!"); } } fn main() { let _ = Dechet; }
Gestion des erreurs
Dès qu’on commence à concevoir des abstractions comme des fonctions, la question de la gestion des erreurs se pose (ou du moins devrait se poser). Là où la norme C++ a trop longtemps campé sur la position du tout-exceptions, sans essayer de standardiser des alternatives pour le cas où les exceptions ne sont pas un bon choix, Rust a dès le départ adopté une approche plus flexible basée sur la dichotomie entre erreur gérable et non gérable par l’appelant.
Panique à bord !
Nous avons déjà vu une première manière de signaler les erreurs dans les chapitres précédents. Lorsque l’implémentation d’une fonction fait quelque chose de manifestement incorrect, comme indexer un tableau en-dehors de ses bornes, cela arrête le programme :
#![allow(unused)] fn main() { // Comme précédemment, j'ai modifié la configuration du compilateur pour // désactiver la détection de l'erreur à la compilation. #![allow(unconditional_panic)] let tab = [1, 2, 3]; println!("{}", tab[4]); }
Comme en Go, le mécanisme sous-jacent est appelé panique (panic). De façon un peu inhabituelle, le langage autorise plusieurs implémentations de cette fonctionnalité :
- Dans l’implémentation unwind, la panique fonctionne à peu près comme une
exception en C++. On remonte la pile d’appel en appelant les destructeurs des
différents objets présents sur la pile, jusqu’à trouver un gestionnaire
de panique
(analogue au
try .. catch
de C++) ou la fonction principale du programme. - Dans l’implémentation abort, la panique provoque directement l’arrêt du
programme via la fonction libc
abort()
, sans appeler les destructeurs.
Cette double implémentation permet deux choses :
- Elle rappelle que l’utilisation de gestionnaires de panique n’est pas une pratique courante en Rust. On s’en sert juste pour fixer des barrières de protection dans les programmes qui ont besoin de pouvoir récupérer des erreurs même quand elles viennent d’un mauvais code.
- Elle permet une implémentation qui n’a pas les surcoûts liés aux exceptions (notamment une augmentation de la taille du binaire, problématique pour le code embarqué).
Nous pouvons déclencher une panique avec la macro panic!()
, qui peut
s’utiliser seule…
#![allow(unused)] fn main() { panic!() }
…ou, de préférence, avec un message formaté comme une écriture println!()
,
qui clarifie pourquoi le programme s’est arrêté :
#![allow(unused)] fn main() { panic!("La réponse aurait dû être {}", 42) }
Comme le message d’erreur vous l’explique, en cas de panique, vous pouvez mettre la variable d’environnement RUST_BACKTRACE à 1 pour avoir la pile d’appel du programme au moment où il s’est arrêté. On n’est pas en C++, pas besoin d’un débogueur externe.
Petite curiosité : la macro panic
à quelques synonymes qui changent le message
d’erreur par défaut, pour clarifier au lecteur du code et à l’utilisateur
quelques raisons d’arrêt courantes.
#![allow(unused)] fn main() { // Placeholder pour du code pas encore implémenté todo!() }
#![allow(unused)] fn main() { // Le programme est arrivé dans un état que le programmeur pensait impossible unreachable!() }
Mais le plus intéressant, ce sont les assertions, qui permettent de vérifier si une condition est vraie, et d’arrêter le programme avec une panique si la condition attendue n’est pas vérifiée :
#![allow(unused)] fn main() { assert!(true); // Forme simple assert!(false, "La condition n'est pas vérifiée"); // Forme longue }
Un cas courant d’assertion est de vérifier si une valeur est égale ou pas à une référence. Ce type d’assertion a une syntaxe dédiée, qui permet en cas d’échec l’affichage des valeurs concernées :
#![allow(unused)] fn main() { // Jusqu'ici tout va bien let x = 42; assert_eq!(x, 42); // Il existe aussi une version "not equal" qui échoue si c'est égal assert_ne!(12, 12); }
De temps en temps, il arrive aussi qu’on soit dans du code très sensible aux performances, où l’on devrait mettre une assertion mais le test associé à l’assertion se révèle être trop coûteux. Dans ce cas, on fait comme le test de débordement d’entier standard de Rust : on teste dans les builds de debug, et on désactive le test (quitte à avoir un comportement incorrect) en mode release.
#![allow(unused)] fn main() { debug_assert_eq!(123, 456); // En mode release, le code continue comme si de rien n'était }
Cette technique est parfois utilisée à l’intérieur des fonctions unsafe, lorsque l’invariant est vérifiable à l’exécution, mais qu’on a fourni une alternative unsafe à la fonction sûre parce que le coût de vérification est trop élevé dans certains cas d’utilisation. Mais c’est une bonne pratique, pas une norme universellement appliquée, donc restez prudents avec unsafe ! ;)
Type optionnel
On l’a vu, la panique est un outil assez brutal qui ne doit être utilisé que dans le cas où le code est en train de faire quelque chose de manifestement incorrect.
Pour les autres cas, on utilise des types dits “monadiques”. Il s’agit de types
sommes avec deux variantes, une variante pour le cas normal et une variante pour
le cas erroné. Le plus simple de ces types est le type
Option
. C’est un type
générique défini par la bibliothèque standard comme suit :
#![allow(unused)] fn main() { enum Option<T> { Some(T), None } }
Nous n’avons pas encore vu les types génériques, mais ce code devrait quand même être assez clair : il peut contenir soit une valeur d’un type T quelconque, soit rien du tout.
Les variantes de ce type énuméré sont mises dans le scope global avec cette commande, injectée automatiquement dans les programmes utilisateur, sur laquelle nous reviendrons quand nous aborderons les modules…
#![allow(unused)] fn main() { use Option::*; }
…ce qui signifie qu’on peut librement taper des choses comme Some(42)
ou
None
dans le code et ça créera les variantes associées du type Option
.
Le type optionnel est utilisé comme valeur de résultat pour les fonctions qui
posent une question dont la réponse peut être “il n’y en a pas”. Par exemple la
méthode str::find()
, qui retourne la position d’un motif
(caractère, sous-chaîne…) dans une chaîne de caractère :
#![allow(unused)] fn main() { let chaine = "Bonjour à tous"; // Le motif existe dans la chaîne à une certaine position println!("Position du mot tous : {:?}", chaine.find("tous")); // Le motif n'existe pas dans la chaîne println!("Position du mot toutes : {:?}", chaine.find("toutes")); }
On peut l’utiliser comme une forme légère de gestion d’erreurs…
#![allow(unused)] fn main() { let tab = [1u32, 2, 3]; // Forme plus "douce" de l'opérateur d'indexation standard let elem = tab.get(4usize); println!("{elem:?}"); }
…ou pour d’autres choses, comme les paramètres de fonction optionnels :
#![allow(unused)] fn main() { fn call_me_maybe(x: Option<u32>) -> u32 { // On l'a déjà dit, cette utilisation du shadowing est idiomatique en Rust, // même si elle perturbe un peu au début. if let Some(x) = x { x } else { 42 } } }
Notez que la fonction qui reçoint une Option
doit gérer la possibilité
qu’elle soit None
. Il n’y a pas de raccourci pour accéder à la valeur
intérieure en supposant qu’elle est là. On ne donc peut pas oublier de gérer
l’absence de résultat, comme c’est le cas en C++ avec les fonctions qui
retournent des types nullables comme std::unique_ptr
, ou qui retournent un
int
comme code d’erreur.
Il y a des façons d’utiliser une Option
qui reviennent souvent en
Rust, et ces opérations sont disponibles dans la bibliothèque standard sous
forme de méthodes du type Option
. Par exemple :
- Extraire la valeur contenue dans l’option ou retourner une valeur par défaut
sinon :
unwrap_or()
,unwrap_or_else()
. - “Dégrader” une option en assertion en supposant qu’elle contient une valeur,
et déclenchant une panique si ce n’est pas le cas :
unwrap()
,except()
. - Transformer la valeur éventuelle d’une
Option<T>
via une fonctionT -> U
, et retourner l’Option<U>
du résultat :map()
.
N’hésitez donc pas à consulter régulièrement la documentation du type Option quand vous vous préparez à faire quelque chose avec, pour ne pas réimplémenter inutilement une opération qui existe déjà sous un nom connu de tous les programmeurs Rust.
C++ a récemment produit sa propre version du concept de type Option
avec
std::optional
de C++17. Mais son API relève hélas de la publicité
involontaire pour Rust : beaucoup moins ergonomique, elle facilite aussi
grandement l’introduction de comportement indéfini…
Type résultat
On l’a vu, le type Option
est utilisé pour implémenter des opérations qui
peuvent ou non retourner un résultat en fonction de ce qu’on leur passe en
paramètre. Par exemple les recherches au sein d’une collection, qui peuvent ou
non trouver un élément qui correspond à la requête.
On pourrait utiliser cette absence de résultat pour signaler la survenue d’une erreur non fatale. Mais souvent, c’est trop imprécis. On ne veut pas seulement savoir qu’une erreur, on veut aussi savoir de quelle nature est l’erreur, et pourquoi elle est survenue.
Par exemple, quand on tente d’accéder à un fichier et ça échoue, on veut savoir si c’est parce que…
- Le fichier n’existe pas.
- Le fichier existe, mais on n’y a pas accès.
- On a accès au fichier, mais on ne peut pas écrire car le support de stockage est plein.
- …et cette liste de problèmes possibles n’est pas exhaustive.
Pour représenter la possibilité d’avoir différents types d’erreur, en Rust, on utilise généralement des types énumérés représentant les différentes erreurs possible. Dans l’exemple ci-dessus, par exemple, on pourrait avoir ce type erreur :
#![allow(unused)] fn main() { type Permissions = u8; enum FileError { FileNotFound { filename: String }, AccessDenied { requested: Permissions, actual: Permissions }, StorageFull, // ...et ainsi de suite... } }
Une fois qu’on a une description précise de l’erreur, il faut pouvoir la
propager à l’appelant. On utilise pour ça le type
Result
, qui est
défini par la bibliothèque standard comme ceci :
// Côté bibliothèque standard, on définit ça
enum Result<T, E> {
Ok(T),
Err(E),
}
// Côté appelant, les variantes sont rendues disponibles automatiquement
use Result::*;
On utilise le type Result
comme une généralisation du type Option
, où le cas
Result::Ok
joue le même rôle que Option::Some
, et le cas Result::Err
est
une forme détaillée de Option::None
où on précise pourquoi on n’a pas pu
retourner un résultat.
Et donc si on ignore quelques subtilités des accès aux fichiers pour simplifier l’explication, notre fonction finale pourrait ressembler à ça :
fn write_file(file_name: &str, data: &str) -> Result<(), FileError> {
// Vérification de l'accès au fichier
if !file_exists(file_name) {
return Err(FileError::FileNotFound { filename: file_name.to_string() };
}
// Ouverture du fichier
if !can_write_file(file_name) {
return Err(FileError::AccessDenied {
requested: Permissions::Write,
actual: file_permissions(file_name),
});
}
// Ecriture des données
if let Err(_) = try_write_data(file_name, data) {
return Err(FileError::StorageFull);
}
// Ok, tout s'est bien passé
Ok(())
}
Notez au passage l’utilisation de ()
pour indiquer que la fonction ne retourne
pas de résultat dans le cas où aucune erreur n’est survenue.
Composition des erreurs
L’exemple de code précédent montre un problème de composabilité qui doit encore
être résolu avant qu’on puisse utiliser Result
facilement à grande échelle :
if let Err(_) = try_write_data(file_name, data) {
return Err(FileError::StorageFull);
}
Cela n’a pas tellement de sens de s’embêter à créer des types erreur détaillés si à la fin, l’appelant va juste jeter cette information détaillée comme ça pour construire son erreur à lui. On aimerait garder une trace de la chaîne d’événements qui a conduit à l’erreur haut niveau finale, depuis l’erreur bas niveau qui a provoqué l’arrêt du traitement.
La recherche d’une solution ergonomique à ce problème a été un travail de longue haleine dans l’écosystème Rust. De nombreuses solutions ont été proposées, et encore aujourd’hui le problème n’est pas considéré comme complètement résolu de façon satisfaisante. Donc les solutions d’interim existent au sein de bibliothèques tierces et pas du langage et de la bibliothèque standard, dont les garanties de stabilité ne se prêtent pas à l’expérimentation.
Néanmoins, deux grandes familles de solution se dessinent, qui répondent à la grande majorité des besoins et seront donc probablement intégrées à la bibliothèque standard à terme :
- Avec des bibliothèques comme
thiserror
, on a une syntaxe très légère pour définir un type énuméré d’erreur de haut niveau dont les variantes “héritent” d’erreurs de plus bas niveau, en y ajoutant des clarifications spécifiques à l’utilisation. Cette approche est privilégiée pour les bibliothèques, dont les erreurs doivent fournir une vision claire de ce qui se passe. - Avec des bibliothèques comme
eyre
, on a un type erreur abstrait qui peut être créé à partir de n’importe quelle erreur de bas niveau, et propagé trivialement à travers le code pour aboutir à l’émission finale d’un rapport d’erreur détaillé pour les utilisateurs. Cette approche est privilégiée pour les applications, qui ne peuvent généralement pas récupérer des erreurs (sauf à gros grain) et n’exposent pas de type erreur dans leurs interfaces.
Ces bibliothèques sont supportées par deux mécaniques de base au niveau du langage :
- Le trait
Error
définit une interface minimale que toutes les erreurs devraient implémenter, ce qui permet ensuite de les manipuler de façon homogène. - L’opérateur de propagation d’erreur
?
fournit une syntaxe concise pour la propagation d’erreur vers l’appelant avec conversion automatique vers le type erreur de plus haut niveau. Avec cet opérateur, notre exemple de départ devient ceci :// Tenter d'écrire dans le fichier. Si ça échoue, l'erreur est propagée. try_write_data(file_name, data)?; // Le code n'arrive à ce point que si il n'y a pas eu d'erreur.
Grâce à cette alliance du langage et des bibliothèques tierces, on obtient un système de gestion des erreurs dont l’ergonomie est excellente, tout en permettant une gestion précise des erreurs, et le tout sans risque d’oublier de gérer les erreurs émises.
Propriété
En une phrase, la notion de propriété (ownership) de Rust reprend les bonnes pratiques du C++ moderne, et les intègrent pleinement au langage pour qu’elles aient une meilleure ergonomie et que les programmeurs les utilisent vraiment.
Théorie
L’édition 2011 de la norme C++ a introduit la sémantique de déplacement (move semantics), qui vise à permettre de transférer la propriété d’une ressource (par exemple une allocation mémoire) d’une région de code à une autre sans faire de copies inutiles. Mais en C++, c’est resté une fonctionnalité obscure et seulement utilisée par les experts parce que…
- Pour effectuer ce transfert, il faut utiliser des syntaxes verbeuses comme
std::move
etstd::forward
, des fonctions d’insertion spéciales sur les conteneurs commeemplace()
… et tout ceci est très déplaisant et exotique pour le programmeur habitué au C++98. - Pour qu’il y ait un bénéfice en termes de performance, le type qui est déplacé
doit supporter le déplacement explicitement. La plupart des types C++ ne le
font pas, et donc la plupart du temps utiliser
std::move
ne sert qu’à clarifier l’intention du programmeur. - Qui plus est, les compilateurs C++ vivent dans un monde de copies inutiles
depuis si longtemps que leurs optimiseurs ont été construits pour éliminer
certaines de ces copies. Cela réduit encore le nombre de situations où
std::move
a un intérêt pour les performances.
En définitif, la sémantique de déplacement C++ est donc généralement présentée
comme une optimisation de performances, mais elle n’améliore généralement pas
les performances et est trop complexe à utiliser par rapport à la copie
traditionnelle. Il est donc normal que les programmeurs C++ s’en servent peu,
hors des types qui imposent son utilisation comme std::unique_ptr
.
Face à ce triste état de fait, Rust a adopté un point de vue un peu différent :
- Si on ne veut pas que les programmeurs fassent des copies inutiles, il faut optimiser l’ergonomie pour ça : le déplacement doit être facile, la copie doit être verbeuse.
- Si on veut que le déplacement soit universellement supporté, il faut que ce
soit si simple que les types n’ont pas besoin de le supporter explicitement.
Ca pourrait être un simple
memcpy()
des octets de la valeur source vers la destination, que le compilateur sait souvent éliminer. - Pour qu’un simple
memcpy()
fonctionne comme opération de déplacement générale, y compris sur des types qui gèrent des ressources, il faut que la valeur source ne soit plus accessible après déplacement, et que son destructeur ne soit pas exécuté. Intuitivement, cela a parfaitement du sens : si un objet du monde réel a été déplacé, on ne peut plus y accéder à sa position d’origine. Et côté technique, le compilateur peut facilement imposer ces contraintes. - Il y a des types pour lesquels
memcpy()
est aussi un opérateur de copie valide, comme les entiers et les flottants par exemple. Dans ce cas, ça n’a pas tellement de sens de rendre l’original inaccessible, on peut le laisser accessible.
Pratique
A chaque fois qu’on utilise l’opérateur =
en Rust, on fait donc un
déplacement, qui est implémenté par une copie mémoire bit à bit
(parfois éliminable par le compilateur).
#![allow(unused)] fn main() { #[derive(Debug)] struct Wrapper(u32); let a = Wrapper(42); let b = a; // Déplacement de a println!("{b:?}"); }
Une donnée qui a été déplacée n’est plus accessible. Donc cette variante du code ci-dessus, où on ré-utilise la variable “a” après déplacement, ne compilera pas.
#![allow(unused)] fn main() { #[derive(Debug)] struct Wrapper(u32); let a = Wrapper(42); let b = a; println!("{a:?}"); // Erreur: Accès à une variable déplacée }
Quand une variable a été déplacée, tout se passe comme si elle n’existait plus, et son destructeur n’est notamment pas exécuté. C’est le destructeur de la donnée déplacée qui s’exécutera plus tard, quand son heure sera venue.
#![allow(unused)] fn main() { #[derive(Debug)] struct Wrapper(u32); impl Drop for Wrapper { fn drop(&mut self) { println!("Destruction d'un Wrapper"); } } let b; { let a = Wrapper(42); b = a; println!("Sortie du scope de a"); }; println!("Sortie du scope de b"); }
Les types peuvent implémenter un opérateur de copie explicite via le trait
Clone
, qui a une syntaxe plus verbeuse que le déplacement pour en décourager
l’utilisation abusive :
#![allow(unused)] fn main() { #[derive(Clone, Debug)] struct Wrapper(u32); let a = Wrapper(42); let b = a.clone(); // Copie explicite de a println!("{a:?} {b:?}"); }
Pour les types primitifs du langage et les types structurés qui ne contiennent
que des valeurs de ce type, la copie bit à bit effectuée par le déplacement est
équivalente à la copie explicite effectuée par Clone
au niveau machine. Ca
n’a donc pas de sens de différencier ces deux types de copie, et on peut
implémenter le trait Copy
qui supprime la sémantique de déplacement pour ce
type et garde l’original accessible après affectation :
fn main() { // Notez que l'implémentation de Copy nécessite celle de Clone #[derive(Clone, Copy, Debug)] struct Wrapper(u32); let a = Wrapper(42); let b = a; // Copie implicite de a println!("{a:?} {b:?}"); }
Copy
n’est pas implémenté automatiquement parce que sa présence contraint
l’implémentation : un type public d’une bibliothèque qui implémente Copy
ne
peut plus être modifié en ajoutant des membres qui n’implémentent pas Copy
,
sans supprimer l’implémentation Copy
et donc briser la compatibilité avec
tous les utilisateurs de la bibliothèque qui font des copies implicites.
N’hésitez pas à jouer un peu avec l’exemple de code éditable ci-dessus pour vous
assurer que vous avez bien compris le concept de propriété en Rust. Pouvez-vous
prédire ce qui va se passer si on ajoute un destructeur qui affiche du texte à
la version de Wrapper
qui implémente Copy
?
Emprunt et références
Motivation
La notion de propriété est puissante, mais elle a ses limites. En-dehors du cas
particulier des types Copy
, quand on passe une valeur en paramètre à une
fonction, on ne peut plus utiliser cette valeur par la suite. Ce code ne
compile donc pas :
#![allow(unused)] fn main() { struct Wrapper(u32); fn fonction(x: Wrapper) { println!("J'ai reçu {} en paramètre", x.0); } let x = Wrapper(42); fonction(x); // Erreur : x n'est plus accessible println!("Je voudrais encore utiliser {}", x.0); }
Bien sûr, il y a des contournements, comme copier la valeur avant…
#![allow(unused)] fn main() { #[derive(Clone)] struct Wrapper(u32); fn fonction(x: Wrapper) { println!("J'ai reçu {} en paramètre", x.0); } let x = Wrapper(42); let x2 = x.clone(); fonction(x); println!("J'ai gardé une copie de {}", x2.0); }
…ou modifier la fonction pour qu’elle retourne la valeur après utilisation…
#![allow(unused)] fn main() { struct Wrapper(u32); fn fonction(x: Wrapper) -> Wrapper { println!("J'ai reçu {} en paramètre", x.0); x } let mut x = Wrapper(42); x = fonction(x); println!("J'ai récupéré l'accès à {}", x.0); }
…mais on sent bien que tout ceci n’est pas brillant au niveau ergonomique, et aussi qu’on entre dans une zone dangereuse au niveau des performances, où on est très dépendant du compilateur pour éliminer toutes les copies liées aux transferts de propriété.
Il y a donc besoin d’une alternative plus légère au transfert de propriété, et cette alternative c’est l’emprunt (borrow) de références.
Conception
Une référence en Rust, c’est un peu comme un pointeur ou une référence en C++, mais avec des garanties de validité que n’ont ni les pointeurs, ni les références en C++.
Quand on utilise des pointeurs ou des références en C++, on doit faire preuve d’une vigilance constante pour ne pas créer un pointeur invalide et déclencher du comportement indéfini. Il existe de trop nombreuses manières de le faire accidentellement, qui vont de l’indémodable fonction qui retourne une référence vers une variable locale…
int& locale() {
int x = 42;
return x;
}
…à la boucle qui insère des éléments dans le conteneur sur lequel elle est en train d’itérer, ce qui peut invalider l’itérateur de la boucle en cas de réallocation du stockage sous-jacent :
std::vector v { 1, 2, 3, 4 };
for (auto& x : v) {
v.push_back(2 * x);
}
Et l’utilisation de mémoire en parallèle depuis plusieurs threads est bien sûr une véritable usine à comportement indéfini en C++, puisqu’il est très facile d’écrire du code qui a l’air parfaitement raisonnable quand on étudie le code de chaque thread isolément, mais qui explose en vol dès qu’on considère les interactions entre threads et les problèmes de cohérence mémoire liés aux accès non synchronisés (il y a une bonne raison pour laquelle c’est un comportement indéfini).
Les langages fonctionnels ont depuis longtemps tiré la sonette d’alarme, et défendent que l’on pourrait éliminer beaucoup de ces problèmes (à l’exception de celui de la portée des variables, qui est généralement résolu avec un ramasse-miettes) en s’interdisant de modifier le contenu des variables après création.
Rust, quand à lui, est bâti sur l’idée qu’il n’est pas nécessaire d’interdire totalement la mutation, et qu’on peut préserver toutes les bonnes propriétés des langages fonctionnels (intelligibilité, absence de comportement indéfini…) avec une plus grande puissance expressive en adoptant un modèle mémoire un peu plus laxe où à chaque instant, soit une variable est accessible en écriture, soit elle est accessible depuis plusieurs points du code, mais jamais les deux en même temps.
Il s’avère que quand on couple cette simple contrainte à une analyse statique de la portée des variables, on peut éliminer tous les comportements indéfinis liés aux pointeurs et références en C++.
Et l’adoption de ce modèle mémoire simplifié permet aussi au compilateur de
prouver trivialement l’absence d’aliasing mutable, ce qui veut dire
concrètement qu’il n’y a pas besoin d’un mot-clé restrict
et d’une preuve
manuelle de son bon emploi par l’auteur du code pour avoir de bonnes
performances de calcul quand on manipule des données par référence en Rust.
Utilisation
Bases
On crée une référence partagée avec le symbole &
, comme on construit un
pointeur en C. En Rust, on parle d’emprunt (borrow) pour désigner cette
opération.
#![allow(unused)] fn main() { let x = 42; let y = &x; }
Le type d’une référence partagée est &T
où T
est le type de la donnée à
laquelle on fait référence. Une référence ne peut pas être nulle, si on a besoin
de nullabilité on utilise Option<&T>
.
On accède à la valeur située derrière la référence avec le symbole *
, comme
on déréférence un pointeur en C. Si cette valeur est Copy
, on peut en créer
une copie comme ça :
#![allow(unused)] fn main() { let y = &42; let z = *y; }
En revanche, si le type n’est pas Copy
on ne peut pas déplacer la valeur
située derrière la référence en la subtilisant à son propriétaire. Emprunter
c’est emprunter, emprunter sans rendre c’est voler.
#![allow(unused)] fn main() { struct Wrapper(u32); let x = Wrapper(42); let y = &x; // Erreur : On ne peut pas utiliser y pour "voler" la valeur de x let z = *y; }
Une référence partagée donne un accès presque équivalent à la déclaration d’une
variable avec let
. Donc hors cas particulier des types à mutabilité interne,
on ne peut pas non plus modifier une variable via une référence partagée en
Rust, indépendamment de la mutabilité de ladite variable. C’est similaire aux
références const
en C++.
#![allow(unused)] fn main() { let mut x = 42; let y = &x; *y = 24; // Erreur : y ne donne pas un accès en écriture à x }
Et les références ne peuvent pas être utilisées en-dehors de la portée de la variable sur laquelle elles pointent, donc ce genre de code maladroit qu’on a évoqué au début ne compile pas.
#![allow(unused)] fn main() { // Erreur : Tentative de construction d'une référence invalide let invalide = { let y = 42; &y }; }
De même, il n’est bien sûr pas autorisé de déplacer la donnée sur laquelle une référence pointe, puisque ça créerait une référence invalide.
#![allow(unused)] fn main() { struct Wrapper(u32); let x = Wrapper(42); let y = &x; let z = x; // Déplacement de x; // Erreur : Accès à une référence y invalidée (donnée source déplacée) println!("{}", y.0); }
Fonctions
On peut aussi utiliser les méthodes d’un type à travers une référence. Comme le type référence n’a pas de méthodes, il y a un raccourci ergonomique qui traverse toutes les couches de référence pour atteindre la valeur, ré-acquiert une référence dessus si besoin, et appelle la méthode :
#![allow(unused)] fn main() { let x = 42u32; let y = &x; let z = &y; println!("{}", z.count_ones()); }
Plus généralement, toutes les fonctions peuvent prendre des références en paramètre :
#![allow(unused)] fn main() { fn par_reference(x: &u32) -> bool { return *x == 42 } println!("{}", par_reference(&24)); }
Pour les fonctions qui retournent des références, en revanche, il faut réfléchir un tout petit peu plus. Bien sûr, les cas simples se codent simplement…
#![allow(unused)] fn main() { fn identite(x: &u32) -> &u32 { x } }
…mais en présence de plusieurs paramètres d’entrée passés par référence, la question se pose : à quel paramètre est-ce que la valeur de retour correspond ?
fn double(x: &u32, y: &u32) -> &u32 {
/* ... implémentation ... */
}
Ne pas pouvoir le dire sans regarder l’implémentation serait un problème, parce qu’en changeant l’implémentation de la fonction, on pourrait changer quelles utilisations sont valides ou pas :
let x = 42;
let z = {
let y = 24;
double(&x, &y)
};
// Cet accès à z est-il valide ? Ca dépend de l'implémentation de double() !
println!("{}", *z);
Par conséquent, en dehors de cas triviaux, Rust impose de clarifier la provenance des références émises par la fonction au niveau de l’interface, via la nouvelle syntaxe des lifetimes, qui reprend celle utilisée pour la généricité comme nous le verrons ultérieurement.
#![allow(unused)] fn main() { fn double<'a>(x: &'a u32, y: &u32) -> &'a u32 { x } }
Il y a cependant deux cas où le compilateur choisit une lifetime par défaut. Dans ces cas, il n’y a pas besoin de préciser l’origine des données avec la syntaxe ci-dessus, même si on peut le faire quand même pour remplacer le comportement par défaut :
- Quand la fonction ne prend qu’un seul paramètre par référence, le compilateur
suppose par défaut que la référence de sortie vient de ce paramètre. Ce n’est
pas toujours vrai, la fonction pourrait aussi retourner une référence vers
une valeur statique. Mais si un programmeur veut exprimer ce cas
d’utilisation exotique, il peut l’écrire explicitement :
#![allow(unused)] fn main() { // Les données statiques ont la lifetime spéciale 'static fn statique(x: &u32) -> &'static u32 { &42 } }
- Quand la fonction est une méthode de type avec un paramètre
&self
, on suppose par défaut que la valeur vient de ce type. Cela couvre tous les cas courants d’accesseurs qui retournent des données par références en code orienté objet.
Types
Il y a cependant encore un trou dans notre raquette de stabilité d’API à ce stade. Supposons que quelque part dans une bibliothèque nous ayons une fonction qui retourne une structure…
#![allow(unused)] fn main() { struct Wrapper(u32); fn wrapper(x: &u32) -> Wrapper { Wrapper(*x) } }
…et que maintenant, le mainteneur de la bibliothèque décide de remplacer la valeur à l’intérieur de la structure par une référence :
struct Wrapper(&u32);
fn wrapper(x: &u32) -> Wrapper {
Wrapper(x)
}
Si c’était légal, alors bien qu’on n’aie changé qu’un membre privé de la struct
Wrapper
, caché au monde extérieur (cf le chapitre sur les modules), on se
retrouverait soudain dans une situation où des appels auparavant valides à
wrapper()
ne seraient plus valides !
let x = {
let y = 42;
wrapper(&y) // Valide avant, plus maintenant !
};
println!("{x}");
Pour éviter ça, le fait qu’un type contienne une référence fait partie de l’interface publique du type. Le besoin d’ajouter ce paramètre de lifetime, visible du monde extérieur, agit comme un signal très clair qui avertit le mainteneur de la bibliothèque qu’il est en train de briser son API.
#![allow(unused)] fn main() { struct Wrapper<'a>(&'a u32); struct Wrapper2<'a, 'b> { inner: Wrapper<'a>, inner2 : Wrapper<'b>, } }
Vous imaginez que cette syntaxe peut devenir lourde à grande échelle. C’est un petit rappel que les références en Rust sont avant tout destinées à optimiser l’ergonomie et les performances d’emprunts temporaires, et pas à être stockées indéfiniment et en grandes quantité.
Mutation
Maintenant que nous avons introduit les références partagées, il est temps de tenir la promesse de Rust en introduisant la possibilité de modifier les données situées derrière la référence.
Pour expliquer le problème que ça représente, je vais prendre un peu
d’avance sur le chapitre sur les collections et introduire Vec
, qui est
l’équivalent Rust du std::vector
de C++. On peut créer un vecteur avec une
syntaxe très similaire à celle utilisée pour créer un tableau…
#![allow(unused)] fn main() { let v = vec![1, 2, 3]; }
…mais contrairement au tableau qui est alloué sur la pile, le vecteur est
alloué sur le tas. Quand on le crée, une allocation mémoire est effectuée pour
stocker ses données, et quand il est détruit, cette allocation mémoire est
libérée. Comme le std::vector
de C++ donc.
Maintenant, j’ai une question pour vous : que se passe-t’il si on fait ceci ?
#![allow(unused)] fn main() { let mut v = vec![1, 2, 3]; let elem = &v[1]; v = vec![4, 5, 6]; println!("{elem}"); }
Si on était en C++, ce code invoquerait du comportement indéfini, puisqu’on
essaierait de lire le contenu de l’allocation mémoire associée à l’ancienne
valeur de v
qui n’existe plus.
Conformément aux objectifs de Rust, c’est donc une erreur de compilation dans ce langage. Dans ce cas, comme dans de très nombreux autres (invalidation des itérateurs, partage de données entre threads…), ce qui nous sauve c’est l’interdiction de la mutabilité partagée.
Plus précisément, la règle est que lorsqu’un modifie une donnée, on invalide toutes les références partagées qui pointent dessus. Il est donc interdit de se resservir de ces références par la suite.
Référence mutable
Sachant cela, on peut maintenant introduire les références mutables. Leur
syntaxe est cohérente avec le reste du langage : de la même façon que pour
rendre une donnée modifiable on utilise let mut
au lieu de let
, pour
permettre la modification au travers d’une référence on utilise &mut
au lieu
de &
:
#![allow(unused)] fn main() { let mut x = 42; let y = &mut x; *y = 24; }
Les règles de base ne surprendront pas non plus les habitués des pointeurs C/++ :
- Pour créer une référence
&mut
, il faut que la cible soitmut
. - Une référence n’a pas besoin d’être
mut
pour permettre l’écriture des données vers lesquelles elle pointe. C’est seulement nécessaire quand on veut changer la cible de la référence.
Mais le concepteur de compilateur, lui, va commencer à suer à grosses gouttes au moment où on introduit ce type, car il sait que tôt ou tard, on va vouloir le passer à une fonction opaque que le compilateur ne peut pas analyser : bibliothèque liée dynamiquement, appel système, etc.
// Pas exactement la vraie syntaxe, mais faisons comme si ça l'était.
extern fn fonction_externe(x: &mut Vec<u32>);
let mut vecteur = vec![1, 2, 3];
let elem = &v[1];
fonction_externe(&mut vecteur);
println!("{elem}");
Dans ces conditions, le compilateur n’a pas accès à l’implémentation de
externe
. Donc il ne sait pas si vecteur
est modifié ou pas. Donc il ne
sait pas si la référence elem
est encore valide après l’appel à externe()
.
Et donc il ne sait pas si ce code et valide.
Face à ces angoisses existentielles liées aux limites de l’analyse statique, Rust utilise comme d’habitude une approche pessimiste, qui a l’avantage de rendre les règles simples pour l’utilisateur, mais le défaut d’interdire du code raisonnable. En Rust, le simple fait de créer une référence mutable, sans même l’utiliser, invalide instantanément les références partagées qui pointent vers la même donnée cible. Donc ce code ne compile pas :
#![allow(unused)] fn main() { let mut x = 42; let y = &x; let z = &mut x; println!("{y}"); }
C’est indubitablement très pessimiste, mais ça a l’avantage de régler le problème des fonctions externes dont nous avons discuté précédemment, sans avoir besoin d’intégrer la notion d’analyse d’échappement (aka “savoir si un pointeur a fuité vers une fonction externe dont le compilateur ne connaît pas l’implémentation”) dans la spécification du langage.
Conversions implicites
Enfin, les références ont un certain nombre de conversions implicites, qui peuvent se résumer par “qui peut le plus peut le moins” :
- On peut utiliser une référence mutable là où une référence partagée est
demandée :
#![allow(unused)] fn main() { fn conversion(x: &mut u32) -> &u32 { x } }
- On peut utiliser une référence vers une donnée de longue durée de vie
là où une référence vers une donnée de courte durée de vie est
demandée :
#![allow(unused)] fn main() { fn extension<'a>(x: &'a u32) -> &'a u32 { &42 // &'static u32 vit plus longtemps que &'a u32 -> OK } }
Je ne couvre ici que les cas les plus simples de conversions implicites liées aux références. Le sujet est d’une profondeur surprenante mais les règles sont assez intuitives pour qu’on n’ait généralement pas besoin d’en connaître le détail pour utiliser le langage.
Types de taille variable
Rust a un support limité des types de taille variable (Dynamic Sized Types ou DSTs). Il en existe actuellement trois, définis par le langage, dont deux sont conceptuellement très similaires :
- Le type
slice
, qui s’écrit[T]
avec T un type de taille fixe. Il représente un tableau de taille inconnue à la compilation, mais connue à l’exécution. - Le type chaîne de caractères
str
est une séquence UTF-8, elle aussi de taille inconnue à la compilation. On peut le voir comme une forme spécialisée de la slice d’octets[u8]
. - Le type objet trait (trait object)
dyn Trait
, représente une implémentation inconnue d’un trait.
Ces types ne peuvent actuellement pas être manipulés directement, mais uniquement par le biais de références spéciales, les fat references, ainsi appelées parce qu’en plus d’un pointeur vers les données elles contiennent des métadonnées permettant d’interpréter la cible du pointeur :
- La référence
&[T]
représente un sous-ensemble d’un jeu de données tabulaire. En plus du pointeur vers le début du jeu de données, ce type de référence stocke la taille du sous-ensemble qui nous intéresse. Cette taille peut donc varier au gré des affectations de variables :#![allow(unused)] fn main() { let tab: [usize; 6] = std::array::from_fn(|i| 2 * i); println!("Tableau original : {tab:?}"); // Création d'une référence de slice let mut s: &[usize] = &tab[1..4]; println!("Sélection des indices 1 <= i < 4 : {s:?}"); // Modification de la référence pour couvrir tout le tableau s = &tab[..]; println!("Vue d'ensemble du tableau {s:?}"); }
- Le type
&str
représente un sous-ensemble d’une chaîne de caractères, et fonctionne exactement comme un&[T]
sauf qu’on découpe selon les octets composant la représentation UTF-8 de la chaîne :#![allow(unused)] fn main() { // On l'a évoqué, les litérales chaînes sont déjà des &str... let mut s: &str = "Bonjour"; println!("{s}"); // ...mais on peut aussi les redécouper : s = &s[3..]; println!("Portion de la chaîne démarrant à l'indice 3 de l'UTF-8 : {s}"); }
- Le type
&dyn Trait
est la base du polymorphisme dynamique en Rust. Il est composé d’un pointeur vers une implémentation d’un trait et d’une vtable permettant d’appeler l’implémentation du trait. Cela permet, par exemple, d’avoir des collections hétérogènes d’objets qui implémentent un même trait :#![allow(unused)] fn main() { use std::fmt::Debug; let debug: [&dyn Debug; 4] = [&42, &"abc", &56.78, &true]; println!("{debug:?}"); }
En dehors de leur capacité à pointer vers des données de différentes tailles/types au gré des affectations et de leur taille inhabituelle (2 fois la taille d’un pointeur machine), ces références obéissent aux mêmes règles que les références simples que nous avons déjà rencontrées.
Types avancés
Dans le début de ce cours, nous avons dû nous cantonner à des types simples. Mais à ce stade, vous en savez assez sur Rust pour aborder des types plus complexes fournis par la bibliothèque standard, qui se révéleront indispensables dès que vous voudrez écrire du code plus complexe/réaliste que les petits exemples introductifs de ce cours.
Itération
Tout comme il existe une abstraction d’itérateur pour itérer sur des données en C++, il en existe aussi une en Rust. Cependant les itérateurs de Rust sont un peu différents de ceux de C++, car ils savent quand ils se terminent (à la manière des ranges de C++20). Cela leur permet de supporter beaucoup plus d’opérations de façon ergonomique.
De plus, en accord avec les objectifs de conception de Rust, les itérateurs Rust interdisent toutes sortes de comportements indéfinis permis par les itérateurs C++ (accès hors bornes, invalidation…).
Types d’itérateurs
On l’a vu, en Rust, il y a trois façons de base de transmettre une valeur à une fonction :
- Par valeur
- Par référence partagée
- Par référence mutable
En toute logique, on a donc trois façons conventionnelles d’itérer sur une collection d’éléments. Ces façons de faire sont accessibles via des méthodes aux noms tout aussi conventionnels :
collection.into_iter()
pour l’itération par valeur.- L’itérateur est construit en consommant la collection (sauf si elle
implémente
Copy
) - Les valeurs contenues dans la collection sont déplacées vers le code utilisateur.
- On peut utiliser la syntaxe raccourcie
for elem in collection {}
.
- L’itérateur est construit en consommant la collection (sauf si elle
implémente
collection.iter()
pour l’itération par référence partagée.- L’itérateur est construit à partir d’une référence partagée sur la collection.
- Le code utilisateur reçoit des références partagées sur les éléments de la collection.
- On peut utiliser la syntaxe raccourcie
for elem in &collection {}
.
collection.iter_mut()
pour l’itération par référence mutable.- L’itérateur est construit à partir d’une référence mutable sur la collection.
- Le code utilisateur reçoit des références mutables sur les éléments de la collection.
- On peut utiliser la syntaxe raccourcie
for elem in &mut collection {}
.
Voilà pour le cas général. Le détail dépendra du type précis auquel on a affaire. Exemples :
- Le type
str
fournit deux manières différentes d’itérer sur ses données, soit par point de code (chars()
), soit par octet UTF-8 (bytes()
). Le bon choix dépend de ce que l’utilisateur veut faire, donc il n’y a pas de méthodeiter()
conventionnelle. - Les collections associatives
HashMap
etBTreeMap
que nous verrons plus tard permettent d’itérer par clé, par valeur, ou les deux. - Plusieurs types (
str
,BTreeMap
, …) ne fournissent pas d’accès en écriture à tout ou partie de leurs données car un utilisateur ayant un tel accès pourrait facilement violer les invariants du type et potentiellement causer du comportement indéfini. - La plupart des collections permettent de retirer des éléments, et disposent
donc d’un itérateur
drain()
qui est construit à partir d’une référence mutable vers la collection et permet de la vider de ses éléments sans la déplacer (et donc sans la détruire à terme).
Boucle for
Avec les itérateurs, la chose la plus simple qu’on peut faire, c’est de créer une boucle for dont le code sera appelé pour chaque élément d’une collection.
fn main() { // Itération par valeur (version explicite) for x in [1u8, 2, 3].into_iter() { println!("{x}"); } println!("---"); // Itération par valeur (raccourci) for x in [4u8, 5, 6] { println!("{x}"); } println!("---"); // Itération par référence partagée (raccourci) for r in &[7u8, 8, 9] { println!("{}", *r); } }
C’est très similaire aux boucles for
dans la plupart des langages modernes,
donc je ne vais pas m’apesantir beaucoup dessus :
- Ca marche bien dans les cas simples où on veut faire une chose une fois par élément.
- On peut l’employer pour d’autres tâches comme les réductions (calculer la somme des éléments d’un tableau, etc.), mais il y a souvent d’autres outils plus adaptés.
- Quand on commence à vouloir faire des choses plus sophistiquées comme itérer sur le voisinage d’une case dans un tableau (ce dont on a besoin pour résoudre le problème de la réaction de Gray-Scott), ça ne marche plus.
Itérateurs spécialisés
Les types itérables comme slice
ont souvent des méthodes qui donnent accès à
des itérateurs plus spécialisés. Dans le cas de slice
, on notera notamment
les itérateurs…
chunks()
etchunks_mut()
, qui permettent de tronçonner le tableau source en sous-tableaux d’une certaine taille, avec potentiellement un tronçon plus petit à la fin :#![allow(unused)] fn main() { for chunk in [1, 2, 3, 4, 5, 6, 7, 8, 9, 10].chunks(3) { println!("{chunk:?}"); } }
chunks_exact()
etchunks_exact_mut()
, variantes dechunks()
etchunks_mut()
qui permettent de traiter le dernier tronçon à part pour que la boucle qui consomme l’itérateur traite toujours des tronçons de même taille. Cette régularité rend le code plus facile à optimiser pour le compilateur, on a donc souvent de meilleurs performances :#![allow(unused)] fn main() { let chunks_exact = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10].chunks_exact(3); let remainder = chunks_exact.remainder(); for chunk in chunks_exact { println!("Chunk: {chunk:?}"); } println!("Remainder: {remainder:?}"); }
windows()
permet d’accéder au voisinage de taille N autour de chaque point du tableau :
Saurez-vous deviner pourquoi il n’existe pas de méthode#![allow(unused)] fn main() { for window in [1, 2, 3, 4, 5, 6, 7, 8, 9, 10].windows(4) { println!("{window:?}"); } }
windows_mut()
? Un indice : que se passerait-il si on extrayait les sorties successives de cet itérateur et les mettait de côté ?
Dans l’ensemble, quand vous êtes coincés avec les trois itérateurs de base, pensez toujours à étudier la documentation de vos types itérables pour connaître leurs itérateurs spécialisés, il y en aura peut-être un qui sera plus adapté à votre cas d’utilisation.
Transformations d’itérateurs
La boucle for
n’est pas la seule utilisation possible d’un itérateur en Rust.
Tous les itérateurs partagent un certain nombres de méthodes (via le trait
Iterator
) qui
permettent de transformer l’itérateur de différentes façons. Quelques exemples :
enumerate()
associe à chaque élément de l’itérateur un indice croissant. On peut par exemple s’en servir pour connaître l’indice des éléments du tableau sur lequel on itère :#![allow(unused)] fn main() { for (idx, elem) in [98, 76, 54, 32].iter().enumerate() { println!("Indice {idx} : {elem}"); } }
map()
prend en paramètre une fonction de T -> U où T est le type d’éléments émis par l’itérateur. Il en résulte un itérateur de U où chaque élément est le résultat de l’application de la fonction aux éléments de l’itérateur d’origine :#![allow(unused)] fn main() { for elem in [1, 2, 3, 4].iter().map(|x| 2 * x) { println!("{elem}"); } }
filter()
prend en paramètre une fonction de &T -> bool où T est le type d’élément émis par l’itérateur. Il en résulte un itérateur de T qui n’émet que les éléments de l’itérateur d’origine pour lequel la fonction retournetrue
:#![allow(unused)] fn main() { for elem in [1, 2, 3, 4].iter().filter(|x| *x % 2 == 0) { println!("{elem}"); } }
zip()
prend en paramètre un deuxième itérateur. Il en résulte un itérateur de paires d’éléments issus des deux itérateurs, tronqué à la taille du plus court des deux itérateurs :#![allow(unused)] fn main() { for (x, y) in ([1, 2, 3, 4].into_iter()) .zip([5, 6, 7].into_iter()) { println!("Reçu {x} et {y}"); } }
Il y a beaucoup d’autres méthodes pour éliminer un certain nombre d’éléments,
sélectionnner un certain nombre d’éléments, rechercher un élémént dans
l’itérateur… Je vous recommande très fortement de faire au moins une fois le
tour de la documentation du trait
Iterator
pour avoir une idée de tout ce qu’il est possible rien qu’avec les itérateurs de
la bibliothèque standard.
Ces méthodes d’itérateurs comparables aux algorithmes de la STL en C++, sauf que contrairement aux algorithmes de la STL, c’est aussi assez facile à utiliser pour que vous ayez vraiment envie de les utiliser sans qu’un collègue ou un framework vous y force.
Avec toutes ces possibilités, on en finit par se demander si on a toujours
besoin des boucles for
. Et de fait, quand on ajoute des réductions comme
reduce()
,
il est tout à fait possible d’effectuer un calcul itératif sans jamais utiliser
de boucle explicite :
#![allow(unused)] fn main() { let somme = [1, 2, 3, 4].into_iter() .reduce(|x, y| x + y) .unwrap_or(0); println!("{somme}"); }
Le choix entre ces deux styles de programmation (boucles explicites et méthodes d’itérateurs) dépendra de ce qu’on essaie de faire :
- Les pipelines d’itérateurs comme celui qu’on a montré ci-dessus tendent à
devenir plus complexes que les boucles quand le calcul est lui-même complexe,
notamment parce qu’on perd la possibilité de sortir facilement de la
boucle avec des outils comme
break
. - En contrepartie, ils tendent à être plus lisibles que les boucles pour les
choses simples, et du fait de leur nature paresseuse, ils se prêtent à
l’application automatique d’optimisations. Nous verrons ainsi dans le
chapitre sur le parallélisme comment un pipeline d’itérateurs sur une
collection standard peut être trivialement parallélisé avec
rayon
.
Collections
Posons-le d’entrée de jeu : à chaque fois que j’ai dis et je vais redire “collection” dans ce cours, en tant que programmeur C++, vous pouvez lire “conteneur”. Les deux mots ont exactement le même sens, ce sont juste des différences culturelles entre les communautés Rust et C++ qui amènent à des choix de vocabulaire un peu différents.
Ceci étant posé, faisons un petit tour des collections standard de Rust, pour voir en quoi elles diffèrent (ou pas) des conteneurs C++.
Vec
Vous avez aimé le std::vector
de C++, vous allez adorer le Vec
de Rust.
C’est la même structure de données sous-jacentes, et les mêmes opérations de
base pour modifier le contenu, la seule différence majeure c’est qu’au niveau de
l’accès aux données on bénéficie aussi de l’API d’une slice
Rust, qui est
nettement plus riche que celle de std::vector
en C++.
fn main() { let mut v = Vec::new(); // Création d'un vecteur vide v.push(42); // Ajout d'un élément println!("{v:?}"); // Création directe d'un vecteur (optimisation de l'allocation) v = vec![1, 2, 3]; println!("{v:?}"); // Création avec une certaine capacité (analogue à reserve() en C++) v = Vec::with_capacity(5); v.push(9); v.push(8); v.push(7); v.push(6); v.push(5); println!("{v:?}"); // Une fois le vecteur créé, l'API est quasiment identique à celle d'un // tableau (ils partagent toute l'interface slice). // On peut faire des tranches... let s = &v[1..3]; println!("{s:?}"); // ...et on peut construire un vecteur ayant le même contenu (possible avec // toute slice, je ne l'avais pas encore évoqué). let owned_s = s.to_owned(); println!("{owned_s:?}"); // ...et on peut itérer, la seule forme d'itération qui se comporte de façon // inhabituelle étant l'itération par valeur... for (idx, elem) in v.into_iter().enumerate() { println!("Element {idx} : {elem}"); } // ...parce que comme Vec gère une allocation mémoire, il n'est pas copiable. // Donc après un mouvement, la valeur Vec<u32> n'est plus utilisable, bien // que u32 en lui même soit copiable. // Et donc ce code là ne compilerait pas après into_iter(), car v a été // déplacé dans l'itérateur ci-dessus : /* println!("{v:?}"); */ }
La syntaxe des génériques en Rust est la même qu’en C++ (on reviendra dessus
ultérieurement), donc le type d’un vecteur d’éléments de types T est Vec<T>
.
N’hésitez pas à lire attentivement la documentation de
Vec
, et à relire celle de
slice
. La plupart des
opérations que vous allez vouloir effectuer sur des Vec
au début de votre
apprentissage de Rust existent déjà dans la bibliothèque standard, et leur
implémentation est souvent plus intelligente que la première idée qui vous
viendra à l’esprit.
String
De la même façon que str
est une forme spécialisée de [u8]
avec l’invariant
de type supplémentaire que le contenu est une séquence UTF-8 valide, String
est une forme spécialisée de Vec<u8>
possédant ce même invariant.
L’invariant “doit rester une séquence UTF-8” valide a des conséquences sur
l’interface de modification, qui est exprimée en termes d’ajout et suppression
de points de code (char
), et pas d’octets individuels.
En dehors de ça, on peut dire que String
est à Vec<u8>
ce que str
est à
[u8]
, et à partir de là tout ce qu’on apprend de Vec
et [u8]
peut souvent
être transposé à String
et str
avec des modifications mineures. Et encore
une fois, mon conseil reste : étudiez soigneusement la documentation de
String
et
str
et utilisez les
implémentations de la bibliothèque standard chaque fois que c’est possible.
Autres collections
Vous avez peut-être remarqué qu’en C++, std::vector
et std::string
sont des
conteneurs ayant une place à part : dans un programme C++ typique, on trouve
beaucoup plus d’objets de ces types que de tous les autres types conteneurs
réunis.
C’est normal : le tableau est une structure de données très fondamentale, en accord étroit avec les réalités du matériel, qui est extrêmement efficace pour un grand nombre d’opérations. Si on n’a pas de raisons d’utiliser autre chose qu’un tableau, c’est une excellente structure de données par défaut.
Rust prend acte de cet état de fait et traite les types tableaux et chaînes de
façon spéciale en les plaçant d’autorité dans le scope global. Pour toutes
les autres collections, il faut faire son marché dans le module
std::collections
de
la bibliothèque standard, et si on ne veut pas taper leur chemin complet, il
faut les importer explicitement avec des instructions comme use std::collections::VecDeque
(nous reviendrons sur ce point quand nous
aborderons les modules).
Séquences
Dans le module std::collections
, on trouve d’abord un certain nombre de
structures de données dont la sémantique est essentiellement séquentielle : les
données sont rangées dans un certain ordre, et la notion d’ordre est
omniprésente dans l’interface.
VecDeque
est une file à double entrée basée sur unVec
: on peut insérer ou enlever des éléments au début et à la fin, et en interne c’est implémenté avec un buffer circulaire qui rend ces deux opérations très efficace. Ce type de structure de données est omniprésent dès qu’on veut ordonnancer le traitement de tâches ou traiter des données en flux tendu, c’est donc très bien d’en avoir une implémentation standard sous la main dans ce genre de cas.#![allow(unused)] fn main() { use std::collections::VecDeque; let mut fifo = VecDeque::from([9, 8, 7, 6]); fifo.push_front(1); fifo.push_back(2); println!("{fifo:?}"); println!("{:?}", fifo.pop_front()); println!("{fifo:?}"); }
BinaryHeap
est une file d’attente avec priorités basée sur un tas binaire : on insère des éléments avec une relation d’ordre entre eux, et à tout moment on peut extraire le plus grand élément. CommeVecDeque
, c’est une structure de données très utile dans les problèmes d’ordonnancement : en théorie de l’ordonnancementVecDeque
correspond à la politique d’ordonnancement FIFO, là oùBinaryHeap
correspond à l’ordonnancement avec priorités.#![allow(unused)] fn main() { use std::collections::BinaryHeap; let mut heap = BinaryHeap::new(); heap.push(1); heap.push(4); heap.push(2); println!("{:?}", heap.pop()); println!("{:?}", heap.pop()); }
LinkedList
est une liste chaînée, une structure de données omniprésente dans les cours d’informatique car elle a plein d’excellentes propriétés sur le plan théorique. Toutefois, dans la vraie vie, elle a des performances désastreuse pour presque tous les cas d’utilisation (la seule exception étant l’ajout d’éléments en milieu de liste quand on connaît déjà un des voisins). Je vous recommanderais donc de ne l’utiliser qu’après très mûre réflexion.
Associations
En plus de collections séquentielles, std::collections
fournit aussi des
collections associatives, qui permettent d’établir un lien entre des objets
d’un type clé K et un type valeur V, à la manière de std::map
et
std::unordered_map
en C++. On s’en sert pour faire ce genre de choses :
#![allow(unused)] fn main() { use std::collections::HashMap; let mut user_ids = HashMap::new(); user_ids.insert("Hadrien", 123); user_ids.insert("Pierre", 456); println!("{user_ids:?}"); println!("Hadrien a l'ID {}", user_ids["Hadrien"]); println!("Pierre a l'ID {}", user_ids["Pierre"]); }
Ici, on utilise la structure de données associative comme une sorte de struct
définie à l’exécution. On peut aussi s’en servir comme un tableau dont tous les
indices ne sont pas alloués. C’est extrêmement flexible, le domaine
d’application est très large.
Pour rechercher une clé de façon efficace, sans avoir à examiner toutes les clés une par une, une structure de données associative exploite les propriétés du type clé. Deux propriétés sont couramment utilisées :
- Soit on possède une fonction de hachage qui, partant d’une clé, produit un
petit nombre entier (le hash) tel que deux clés différentes ont une
probabilité très faible d’avoir le même hash. Cela réduit grandement le
nombre de clés qu’on doit examiner pour retrouver la valeur, qui ne dépend
en théorie pas du nombre de couples (clés, valeurs) stockées. C’est le
principe utilisé par
HashMap
, qui est analogue àstd::unordered_map
en C++. - Soit on exploite le fait que le type clé est ordonné et on range les clés
selon un arbre de tri (red-black tree, B-tree…) pour permettre une
recherche de clé en O(log(N)) étapes où N est le nombre d’élément dans la
structure de données. Cette structure de données peut être intéressante sur
des clés qui sont beaucoup plus efficaces à comparer qu’à hacher, quand le
nombre de valeurs n’est pas trop grand, ou quand il n’y a pas de fonction de
hachage évidente. C’est le principe de
BTreeMap
en Rust, qui est analogue àstd::map
en C++.
Ensembles
Une fois qu’on a une collection associative, on peut trivialement implémenter
une collection ensembliste qui contient des valeurs d’un type K et permet de
savoir rapidement si une valeur est où non présente dans la collection. Il
suffit de construire une collection associatif dont le type valeur est ()
: on
a des clés, mais pas de valeur associée, on exploite juste la recherche de clé
optimisée fournie par la collection associative sous-jacente.
Néanmoins, les collections ensemblistes sont en pratique utilisés de façon
différente par les utilisateurs, avec des opérations plutôt issues de la théorie
des ensembles : union, intersection, différence symétrique… Il est donc
utile de fournir une interface dédiée pour ces opérations, et c’est ce que font
HashSet
et BTreeSet
,
respectivement basés sur HashMap
et BTreeMap
.
#![allow(unused)] fn main() { use std::collections::HashSet; let mut set1 = HashSet::new(); set1.insert(123usize); set1.insert(456); set1.insert(789); println!("{}", set1.contains(&123)); println!("{}", set1.contains(&42)); println!(); let set2 = HashSet::from([24usize, 42]); // Notez que l'itération sur HashSet produit les données en ordre arbitraire for elem in set1.union(&set2) { println!("{elem}"); } }
Construction depuis un itérateur
Quand on a un itérateur de valeurs, on peut trivialement construire une
collection de valeurs de ce type avec l’opération collect()
de l’itérateur :
#![allow(unused)] fn main() { let v = (0..50) // Itération sur les nombres 0 <= i < 50 .filter(|i| *i % 3 == 0) // Sélection des multiples de 3 .map(|i| 42 * i) // Multiplication par 42 .collect::<Vec<usize>>(); // Construction d'un vecteur de usize println!("{v:#?}"); }
Outre l’élégance du formalisme, cette manière de construire une collection a l’avantage que lorsque la taille de l’itérateur est connue à l’avance, elle peut être transmise à l’implémentation de la collection pour optimiser automatiquement l’allocation mémoire. Utilisez-la donc chaque fois que la liste des éléments de la collection s’exprime bien sous la forme d’un pipeline d’itérateurs.
Les collections associatives se construisent à partir d’un itérateur de tuples
(clé, valeur), pour le reste leur fonctionnement est identique à celui des
autres collectionss du point de vue de collect()
.
Puisqu’il existe un grand nombre de collections ayant des éléments d’un certain
type, le type émis par collect()
ne peut pas être inféré et doit toujours être
spécifié explicitement. Il y a deux manières courantes de procéder :
#![allow(unused)] fn main() { // Typer la variable de destination let v: Vec<u32> = (0..100).collect(); println!("{v:?}"); // Typer l'opération collect() let v = (0..100).collect::<Vec<u32>>(); println!("{v:?}"); }
La sémantique est parfaitement équivalente, le choix entre les deux est une pure
question de goût personnel. Personnellement, je trouve la seconde forme plus
lisible sur des gros pipelines, car elle évite de devoir remonter le regard
vers le haut de la déclaration pour savoir ce que collect()
va produire. Mais
je suis sensible à l’argument que la deuxième version a une syntaxe nettement
plus lourde, qui peut sembler excessive à petite échelle.
Pointeurs
Introduction
Lorsque les programmeurs découvrent l’emprunt en Rust, ils tendent à être tellement fascinés par ce concept relativement original qu’ils essaient de l’utiliser à toutes les sauces, y compris dans des contextes où ce n’est pas l’outil le plus approprié.
Une erreur classique est notamment de vouloir allouer le plus de variables possibles sur la pile avec une gestion de temps de vie à la compilation en mettant des références partout, alors que ce genre d’optimisation n’a d’importance que dans la partie chaude du code. Dans la grande majorité du code, une gestion du temps de vie à l’exécution sera nettement plus ergonomique tout en fournissant une performance qui reste raisonnable. Sans ça, les langages qui allouent presque tout sur le tas comme Java auraient fait faillite depuis longtemps.
Pour ces situations, Rust propose comme C++ une bibliothèque de pointeurs intelligents, qui fournissent un accès à une allocation tas qui sera automatiquement libérée par leur destructeur.
Une différence importante entre Rust et C++ est que grâce à la magie du trait
Deref
, il n’y a pas besoin de syntaxe spéciale ->
pour accéder à l’élément
pointé. L’opérateur standard d’accès au membre objet.membre
fera le travail
dans 99% des cas. On peut donc plus facilement convertir du code qui utilise
des pointeurs intelligents en code qui n’en utilise pas et vice versa.
Box<T>
Le premier pointeur intelligent de la bibliothèque Rust est Box
, un type qui
alloue une allocation quand il est créé et la libère quand il est détruit,
à la manière de std::unique_ptr
en C++ :
#![allow(unused)] fn main() { // Création d'une Box let b: Box<u32> = Box::new(42); println!("{b}"); // Déréférencement explicite let x: u32 = *b; println!("{x}"); // Déréférencement implicite println!("{}", x.count_ones()); // Destruction automatique en sortie de scope }
Box réplique aussi fidèlement que possible la sémantique d’une valeur allouée sur la pile, et son ergonomie est donc globalement comparable, ni meilleure ni mauvaise. Il y a cependant quelques différences notables entre les deux :
- Même si le type d’origine est
Copy
,Box
ne sera pasCopy
, car unmemcpy()
du pointeur n’est pas une façon correcte de dupliquer une allocation tas. Pour créer des copies, il faudra donc utiliser l’opérateur expliciteb.clone()
. - Si la valeur pointée est de grande taille (ex : un gros tableau ou une grosse
struct), déplacer une
Box
est plus efficace que de déplacer une valeur sur la pile, puisque lememcpy()
associé ne copie qu’un pointeur. On est donc moins à la merci des optimisations d’élimination de copie du compilateur, qui ne sont pas toujours aussi fiable qu’on aimerait.- De plus, sur la plupart des OS, la pile est de taille limitée (quelques Ko à quelques Mo), et à trop en mettre sur la pile on risque un crash. Alors que le tas n’est limité que par la quantité de RAM disponible ou presque.
- Au niveau des performances,
Box
, comme tout pointeur, implique une indirection dans l’accès aux données qui va rendre les accès mémoire moins efficaces. Il faut donc éviter d’accèder aux données pointées par un grand nombre deBox
différentes dans une boucle chaude. - Il est possible d’allouer sur le tas des blocs de taille inconnue à la
compilation, donc on peut avoir des
Box<[T]>
,Box<str>
etBox<dyn Trait>
.
Ce dernier point mérite clarification, prenons donc l’exemple de
Box<[T]>
. Dans le chapitre sur les collections, nous avons introduit Vec<T>
,
une collection de taille variable représentant un tableau auquel on peut ajouter
et enlever des éléments. Pour faire ce travail correctement, Vec
a besoin de
stocker trois informations :
- Un pointeur
base
vers le début de l’allocation mémoire. - Un entier
size
indiquant la taille actuelle du vecteur. - Un entier
capacity
indiquant la taille de l’allocation mémoire, qui peut être supérieure àsize
(cela évite de refaire une allocation et déplacer les données à chaque opération).
Mais il arrive très souvent que l’on utilise Vec
dans un certain cycle de vie
où après une phase d’initialisation, la taille du vecteur ne varie plus. Dans
ce cas, la nuance size
/capacity
devient inutile, on pourrait juste
redimensionner une dernière fois l’allocation pour qu’elle fasse exactement la
bonne taille et ne plus conserver que le pointeur de début d’allocation et la
taille du vecteur.
Et c’est là qu’intervient le type Box<[T]>
de Rust : c’est un pointeur
intelligent vers un tableau de taille fixe, mais de taille non connue à la
compilation.
Voici deux façons courantes de construire un objet de type Box<[T]>
:
#![allow(unused)] fn main() { // Si un pipeline d'itérateur suffit, on utilise collect() : let boxed_slice = (0..20).collect::<Box<[u8]>>(); println!("{boxed_slice:?}"); // Sinon, on commence par créer un vecteur, puis on le convertit en allocation // de taille fixe quand on a fini d'ajouter/supprimer des éléments. let mut data = Vec::new(); data.push(123); data.push(456); data.push(789); data.pop(); // Suppression d'un élément let data = data.into_boxed_slice(); println!("{data:?}"); }
Comptage de références
Parfois, la sémantique de propriété peut être limitante, et on veut que plusieurs parties indépendantes du code partagent la valeur d’une même variable sans que l’une d’entre elle ait une référence sur le stockage de l’autre.
Pour gérer ce cas en Rust, on utilise principalement la technique du comptage de
référence, via les types Rc
(“Reference
Counted”) et Arc
(“Atomically Reference
Counted”) :
- On crée une allocation tas comprenant la valeur à partager et un entier indiquant le nombre de pointeurs intelligents existant vers cette valeur, qu’on appelle compteur de références.
- On retourne à l’utilisateur un pointeur intelligent
Rc<T>
ouArc<T>
qui peut être copié via l’opérateurclone()
. A chaque fois qu’une copie est faite, la copie pointe vers la même allocation qu’avant, mais le compteur de références de l’allocation est incrémenté. - A chaque fois qu’un pointeur intelligent est détruit, le compteur de références de l’allocation associée est décrémenté.
- Quand le compteur de références tombe à zéro, il n’y a plus d’accès possible à l’allocation (grâce à l’analyse de validité des références extraites de cette allocation), elle peut donc être libérée en toute sécurité.
Utilisé à grande échelle, le comptage de références est moins efficace que les autres implémentations de ramasse-miettes basée sur l’analyse récursive des pointeurs de la pile de l’application. Et il faut faire attention avec les références cycliques (A pointe vers B, qui pointe vers A), qui doivent être gérées via un mécanisme spécifique des références faibles.
Mais en contrepartie, cette approche…
- A des caractéristiques de performances plus déterministes : si on ne détruit
pas de
Rc<T>
ou deArc<T>
, on ne risque pas de libération de mémoire (opération coûteuse à éviter dans certains types de code opérant sous contraintes de temps réel). - Ne nécessite pas que l’implémentation ait une connaissance exhaustive de tous les pointeurs manipulés par le code et donc permet une interopérabilité plus facile avec les langages ayant d’autres méthodes de gestion mémoire.
En Rust, l’utilisation du comptage de références a aussi une interaction subtile avec l’analyse de la validité des références effectuées par le compilateur :
- Les données situées derrière un pointeur
Rc<T>
ouArc<T>
sont accessibles depuis plusieurs endroits du code. Ce sont donc des références partagées, et il n’est possible de modifier les données sous-jacentes que via des mécanismes de mutabilité interne (que nous allons aborder dans un chapitre ultérieur). - Le compteur de référence de l’implémentation nécessite lui-même une forme de
mutabilité interne, puisque c’est un état partagé qui est modifiable depuis
tous les pointeurs
Rc<T>
etArc<T>
en créant un clone du pointeur. - Il faut faire, à ce niveau, un choix entre une implémentation efficace (simple incrément d’entier machine) et une implémentation thread-safe (incrément atomique via une instruction matérielle spéciale coûtant typiquement 100x plus cher).
En C++, ce dernier choix est fait pour vous : si vous utilisez
std::shared_ptr
, vous avez une implémentation thread-safe, et en paierez le
prix en termes de performances même si vous ne partagez jamais de shared_ptr
entre threads.
Mais en Rust, ce choix est sous votre contrôle. Si vous avez besoin de partage
entre threads, vous utilisez Arc<T>
, sinon vous utilisez Rc<T>
. En cas de
doute, essayez Rc<T>
: si vous avez besoin des garanties de Arc<T>
, le
compilateur vous le fera savoir en refusant de compiler votre code au motif que
vous essayer de partager des structures de données non thread-safe entre
plusieurs threads.
Pour le reste, Rc
et Arc
fonctionnent globalement exactement comme Box
du point de vu de l’utilisateur, l’accès mutable en moins.
Pointeurs bruts
Rust fait de son mieux pour fournir des alternatives sûres à la gestion mémoire traditionnelle du C, et y parvient très bien la plupart du temps. Mais il peut arriver qu’on ait besoin d’un accès à cette fonctionnalité bas niveau :
- Quand on veut implémenter une structure de données avec un graphe de relations compliquées entre les objets intérieurs, relations qui sont mal représentées par le modèle de possession et d’emprunt en arbre de Rust.
- Quand on veut interopérer avec d’autres langages de programmation via une interface C, où c’est la seule sémantique disponible pour passer des données par référence.
Dans ces cas-là, il faut utiliser directement ou indirectement (via une bibliothèque) le pendant maléfique des pointeurs intelligents, les pointeurs bruts (raw pointers).
Rust possède trois types de pointeurs bruts, NonNull<T>
, *const T
et
*mut T
.
NonNull<T>
est un pointeur qui, comme tous les types de références et pointeurs intelligents en Rust, ne peut pas être nul. Cela autorise certaines optimisations au niveau de l’implémentation et clarifie les choses pour les utilisateurs, donc je vous encourage à l’utiliser chaque fois que c’est possible.*const T
et*mut T
visent respectivement à imiter la sémantique deconst T*
etT*
en C : ils font exactement la même chose au niveau du compilateur, mais le premier suggère que vous ne souhaitez pas que les données soient modifiées par la fonction à laquelle vous le passez, et agit donc comme une forme limitée d’auto-documentation.
En Rust, les pointeurs bruts sont extrêmement difficiles à utiliser
correctement, encore plus qu’en C et en C++, parce que le compilateur Rust tire
abondamment parti des garanties offertes par le langage (pas de pointeurs nuls,
pas de coexistence de &
et &mut
, etc) au cours de son processus
d’optimisation du code. Si vous violez ces règles par une utilisation
incorrecte des pointeurs bruts, vous causerez instantanément du
comportement indéfini encore plus vicieux que celui qui existe dans le code
C/++ ordinaire (hors restrict
, std::execution_policy::unseq
, et autres
formes de masochisme encouragées par la norme C++).
Je vous encourage donc très fortement à ne pas aborder cette partie du langage Rust avant d’avoir acquis une très bonne maîtrise du reste, et à privilégier l’utilisation de bibliothèques écrites par des experts reconnus quand il en existe. Lorsque vous vous sentirez prêts, une bonne introduction au sujet est le Rustnomicon, un guide qui présente un panorama à peu près complet des invariants du langage Rust et des techniques que le code unsafe utilise couramment pour les préserver.
Mutabilité interne
Nous l’avons vu, Rust encourage fortement une politique de gestion mémoire où à chaque instant, une donnée est soit partagée, soit modifiable, mais jamais les deux en même temps.
Nous avons aussi déjà rencontré plusieurs exemples de situations où cette politique est trop limitante, et nous devons à la place adopter une politique “à la C” où il est possible de modifier certaines valeurs via une référence partagée. On parle de mutabilité interne.
Dans ce chapitre, nous allons voir comment utiliser nous-même ce mécanisme de mutabilité interne dans notre code lorsque nous en avons besoin.
Initialisation paresseuse : OnceCell
et OnceLock
Un cas simple de mutabilité interne que nous avons déjà rencontré, c’est
l’initialisation paresseuse : une valeur doit être calculée à l’exécution, mais
nous voulons qu’elle ne soit calculée que lors du première accès. Par la suite,
on veut que la donnée précalculée soit stockée, et que l’accesseur retourne
immédiatement une référence partagée vers la donnée précalculée. Cette
référence partagée fonctionnera ensuite selon les règles usuelles (pas de
mutation via &T
).
En Rust, cette fonctionnalité a été implémentée initialement par des
bibliothèques, d’abord
lazy_static
puis once_cell
. Ensuite, au vu
de la fréquence d’utilisation de la bibliothèque once_cell
et de son niveau de
maturité, elle a récemment été jugée digne d’être intégrée au sein de la
bibliothèque standard. Le processus est encore en cours, mais les types
OnceCell
et
OnceLock
sont déjà
disponibles dans les versions récentes du langage Rust.
Ces deux types partagent une relation similaire à Rc
et Arc
: le type
OnceCell
est destiné à être utilisé pour des références partagées au sein
d’un même thread et exploite cette limitation pour implémenter les choses de
façon plus efficace. Alors que le type OnceLock
est destiné à être utilisé
pour des références partagées entre plusieurs threads, et paye le prix requis
pour en arriver là.
La principale API de mutabilité interne exposée par ces types est la méthode
get_or_init()
,
qui prend une fonction de construction en paramètre. Si la cellule n’a pas
encore été initialisée, la fonction de construction est appelée est son résultat
est utilisé pour l’initialisation. Sinon, une référence partagée vers le
résultat précalculé est retournée :
#![allow(unused)] fn main() { use std::cell::OnceCell; fn initialisation() -> u32 { println!("Initialisation en cours..."); /* ... un calcul très compliqué ... */ 42 } fn paresseux(x: &OnceCell<u32>) -> &u32 { x.get_or_init(initialisation) } let x = OnceCell::new(); println!("{}", paresseux(&x)); println!("{}", paresseux(&x)); }
Les types OnceCell
et OnceLock
sont soigneusement optimisés pour que l’accès
à une donnée initialisée soit presque aussi efficace que l’accès à une variable
normale. Mais il faut quand même vérifier que l’initialisation a été effectuée,
ce qui a un coût léger. Utilisez donc quand même l’initialisation ordinaire
chaque fois que c’est possible.
Déplacement en séquentiel : Cell
Un cran de complexité cognitive au-dessus, on a
Cell
, un type qui
permet de stocker et extraire des valeurs via une référence partagée :
#![allow(unused)] fn main() { use std::cell::Cell; // Notez l'absence de mut let x = Cell::new(123usize); // Lecture et écriture normale println!("{}", x.get()); x.set(456); println!("{}", x.get()); // Lecture + écriture combinée let old = x.replace(789); println!("{old}"); }
Ce type n’est généralement utilisé qu’avec des types Copy
, car sinon on perd
la méthode get()
, et tout faire avec replace()
est pénible. Mais Cell
a
l’avantage d’avoir une implémentation triviale, qui ne fait que désactiver les
optimisations de compilateur liées à la supposition que les variables partagées
ne seront pas modifiées.
Cell
ne peut pas être utilisée en multi-thread, car ce n’est pas une bonne
idée de modifier des variables observables par d’autres threads sans
synchronisation…
Emprunt dynamique en séquentiel : RefCell
Encore un cran de complexité au-dessus, lorsqu’on est dans une situation où la
sémantique des références Rust convient, mais le compilateur ne parvient pas à
faire la preuve que le programme la respecte, on peut transposer la
vérification de la compilation à l’exécution avec le type
RefCell
:
#![allow(unused)] fn main() { use std::cell::RefCell; // Déclaration let x = RefCell::new(24u8); // Emprunt dynamique en lecture { let x = x.borrow(); println!("Avant : {x}"); } // Emprunt dynamique en écriture { let mut x = x.borrow_mut(); *x = 42; } // Autre emprunt dynamique en lecture { let x = x.borrow(); println!("Après : {x}"); } }
En interne, le type RefCell
fonctionne en maintenant un compteur de références
partagées avec une valeur sentinelle pour l’emprunt mutable.
A chaque fois que les opérations borrow()
et borrow_mut()
sont appelés,
l’implémentation de RefCell
vérifie que l’emprunt est correct (sinon le code
panique), puis retourne un objet qui se comporte comme une référence du bon
type, mais avec un destructeur qui remodifie le compteur de références dans
l’autre sens. D’où l’utilisation des scopes ci-dessus pour s’assurer que ce
destructeur soit appelé au bon moment.
Dans l’ensemble, l’utilisation de RefCell
rend le code plus difficile à
comprendre et augmente le risque de panique imprévue. De plus, la gestion du
compteur de références n’est pas neutre du point de vue des performances.
Je vous encourage donc fortement à n’utiliser RefCell
que quand une API mal
conçue ne vous laisse pas le choix, et à privilégier les emprunts vérifiés à la
compilation chaque fois que c’est possible, quitte à triturer un peu le code
pour contourner quelques limites connues de l’analyse statique si le code
résultant est moins désagréable à lire qu’un code utilisant RefCell
.
RefCell
ne prend aucune précaution particulière pour synchroniser l’accès aux
données, et n’est donc pas utilisable en multi-thread.
Synchronisation matérielle : std::sync::atomic
Nous avons vu comment on partage des données mutables entre différentes parties du code d’un seul thread, maintenant voyons comment on partage des données mutables entre plusieurs threads.
Tous les CPUs courants offrent des garanties minimales de synchronisation entre threads via la cohérence de cache, qui assure qu’à chaque instant tous les coeurs CPU sont d’accord sur le contenu de la mémoire. Cependant, le CPU et le compilateur pris ensemble ne garantissent pas…
- Que votre programme va faire les accès mémoire que vous avez dit et aucun autre.
- Que les accès mémoire qui seront exécutés seront effectués dans l’ordre au niveau CPU.
- Que le CPU ne va pas effectuer d’autres réordonnancements des lectures et des écritures derrière, voire des spéculations sur la valeur des lectures.
- Qu’entre une lecture de valeur par un CPU et l’écriture de la version modifiée qui suit, un autre coeur CPU n’aura pas modifié la valeur d’une façon qui invalide la modification.
…et bien sûr, Rust a aussi vocation à être portable vers d’autres matériels comme les GPUs où les caches ne sont pas cohérents et pour partager une information avec les autres coeurs il faut le demander explicitement avec des instructions coûteuses.
Pour toutes ces raisons, les accès mémoires non synchronisés sont un comportement indéfini en Rust comme en C++, et on ne peut donc pas en faire sans code unsafe en Rust.
Les opérations de synchronisation qui sont efficaces au niveau matériel sont
exposées en Rust via le module
std::sync::atomic
, qui
est fortement inspiré de l’en-tête <atomic>
de C++11 mais avec quelques
changements importants :
- Si une opération n’est pas disponible au niveau matériel, elle n’est pas
émulée avec des
Mutex
comme en C++, elle n’existe tout simplement pas en Rust. Si on veut écrire du code portable entre matériels, on doit vérifier ce qui est présent avant utilisation, et décider de sa politique de fallback quand l’opération atomique voulue n’est pas présente. - Il n’y a pas d’ordre
SeqCst
par défaut, ni d’opérations atomiques implicites cachées derrière des opérateurs. Comme le code qui utilise ces opérations est difficile à écrire, Rust le rend très explicite, ce qui facilite sa relecture par des experts.
Cela prendrait pas mal de temps d’expliquer comment on utilise bien ces opérations, et je considère que ça dépasse le cadre de ce cours introductif, donc je vous renvoie vers l’excellent cours Rust Atomics and Locks de Mara Bos. Son cours dépasse par ailleurs largement la question des opérations atomiques et offre une très bonne introduction aux fondamentaux de l’écriture de structures de données concurrentes en Rust.
En voici un exemple d’utilisation simpliste (rendez-vous au chapitre sur les
threads pour plus d’explications sur le fonctionnement de
std::thread::scope
) :
#![allow(unused)] fn main() { use std::{sync::atomic::{AtomicBool, Ordering}, time::Duration}; // Variable de synchronisation let ready = AtomicBool::new(false); std::thread::scope(|s| { // Thread secondaire qui attend que "ready" passe à true s.spawn(|| { println!("[Secondaire] Attente active de ready == true..."); while !ready.load(Ordering::Relaxed) {} println!("[Secondaire] Signal reçu !"); }); // Thread principal qui met "ready" à true après une petite temporisation println!("[Principal] Une petite pause..."); std::thread::sleep(Duration::from_millis(30)); println!("[Principal] Envoi du signal"); ready.store(true, Ordering::Relaxed); }); }
Transfert de valeurs : std::sync::mpsc
Avec les opérations de std::sync::atomic
, on a des primitives de partage de
données au plus proche du matériel. Mais souvent, on a quelque envie de quelque
chose de plus haut niveau et agréable à utiliser au quotidien.
Pour partager des données par valeur, Rust fournit la file d’attente
mpsc
, qui permet à un
ou plusieurs threads émetteurs d’envoyer des messages à un thread
destinataire avec une réception dans l’ordre où les messages ont été émis (FIFO).
Inspirée par les fameuses channels du langage Go, cette file d’attente fournit par ailleurs plusieurs autres fonctionnalités dont l’expérience montre qu’on en a souvent besoin quand on utilise des files pour synchroniser des threads :
- On peut borner le nombre de messages en attente et bloquer l’émetteur quand la limite de capacité est atteinte, pour éviter que si l’émetteur est plus rapide que le destinataire, la taille du stock de messages augmente indéfiniment.
- Toutes les opérations qui sont bloquantes par défaut disposent d’une variante qui échoue instantanément et d’une variante qui échoue au bout d’un certain temps d’attente.
- Si tous les threads émetteurs se sont arrêtés (ce qu’on détecte via
l’implémentation
Drop
de la partie émettrice de la file), le thread destinataire est informé, et vice versa.
Si vous avez besoin de gérer manuellement des threads (nous verrons que pour des tâches calculatoires, ce n’est généralement pas nécessaire), je vous recommande fortement d’essayer ce paradigme de communication, il rend le code plus lisible et facile à maintenir que les alternatives que nous allons aborder dans la suite de ce chapitre.
Vous trouverez un exemple d’utilisation de mpsc
dans le chapitre sur les
threads.
Verrouillage exclusif : Mutex
Parfois, il n’est pas acceptable de transférer des données entre threads par valeur :
- La création d’un message peut représenter un coût trop important pour le thread émetteur, par exemple en raison des allocations mémoire requises.
- La synchronisation souhaitée peut bien s’exprimer sous forme de petites modifications sur une grosse structure de données commune.
Pour ce genre de cas, on retrouve au sein de la bibliothèque standard l’éternel
Mutex
, qui permet à
plusieurs threads de partager des données en s’attendant mutuellement : tant
qu’un thread a accès aux données, les autres doivent attendre qu’ils aient fini
pour obtenir l’accès à leur tour.
Cependant, rappelons qu’il y a plein de problèmes connus avec cet outil :
- Il faut que l’attente soit rare, sinon on perd tout le bénéfice d’avoir plusieurs threads (et on tournera même plus lentement qu’un code séquentiel car synchroniser des threads n’est pas gratuit). Donc il faut que les transactions soient assez grosses pour amortir le coût de synchronisation, mais pas trop pour ne pas se retrouver dans une situation d’attente.
- On peut trop facilement se retrouver dans une situation où deux threads s’attendent mutuellement, voire où un thread s’attend lui-même, ce qui bloque le programme (deadlock).
- Il faut gérer le cas où un thread crashe alors qu’il était en train de modifier les données, laissant celles-ci dans un état incohérent.
Contrairement à la plupart des implémentations de mutex, le Mutex
de Rust
gère la troisième erreur, via un mécanisme de poisoning qui signale l’erreur
au moment de l’acquisition du Mutex
. Pour le reste, c’est à vous de gérer.
L’utilisation de Mutex
est similaire à celle de RefCell
, la gestion du
poisoning en plus et la gestion séparée des lectures/écritures en moins :
#![allow(unused)] fn main() { use std::{sync::Mutex, time::Duration}; // Variable de synchronisation let mutex = Mutex::new([1, 2, 3, 4]); std::thread::scope(|s| { // Thread secondaire qui lit deux fois avec une petite pause au milieu s.spawn(|| { println!("[Secondaire] Première lecture..."); let valeur = *mutex.lock() .expect("Empoisonné par le thread principal !"); println!("[Secondaire] La valeur initiale est {valeur:?}"); println!("[Secondaire] Une petite pause..."); std::thread::sleep(Duration::from_millis(40)); println!("[Secondaire] Relecture..."); let valeur = *mutex.lock() .expect("Empoisonné par le thread principal !"); println!("[Secondaire] La nouvelle valeur est {valeur:?}"); }); // Thread principal qui fait une pause, puis rate une mise à jour println!("[Principal] Une petite pause..."); std::thread::sleep(Duration::from_millis(20)); println!("[Principal] Mise à jour en cours..."); { let mut guard = mutex.lock() .expect("Empoisonné par le thread secondaire !"); for idx in 3..=4 { // Oups, je me suis cru en Julia/Fortran ! guard[idx] = 42; } } println!("[Principal] Mise à jour terminée"); }); }
Notifications d’événements : Condvar
Dans les exemples précédents, à chaque fois que deux threads devaient s’attendre mutuellement, nous avons utilisé soit de l’attente active soit des temporisations. Il va de soit qu’aucune de ces méthodes n’est acceptable en production, en-dehors de cas très particuliers.
A la place, on utilise souvent Mutex
en association avec Condvar
. Le
fonctionnement de Condvar
étant identique à celui de
std::condition_variable
en C++, lui-même très proche des condition
variables de pthread
je ne le détaillerai pas plus que ça. Voici un exemple
d’utilisation :
#![allow(unused)] fn main() { use std::sync::{Mutex, Condvar}; let mutex = Mutex::new(0usize); let condvar = Condvar::new(); std::thread::scope(|s| { // Thread secondaire qui attend une nouvelle valeur du thread principal s.spawn(|| { println!("[Secondaire] Attente du signal du thread principal..."); let mut guard = mutex.lock().expect("Empoisonné par le thread principal !"); guard = condvar.wait_while(guard, |valeur| *valeur == 0) .expect("Empoisonné par le thread principal !"); println!("[Secondaire] La valeur est maintenant {}", *guard); }); // Thread principal qui modifie la valeur puis notifie le thread secondaire println!("[Principal] Ecriture de la valeur signal..."); { let mut guard = mutex.lock() .expect("Empoisonné par le thread secondaire !"); *guard = 42; } println!("[Principal] Notification du thread secondaire"); condvar.notify_one(); }); }
Si vous vous demandez pourquoi le thread secondaire doit acquérir un mutex avant
d’attendre la Condvar
, ou plus généralement pourquoi l’API d’une Condvar
est
aussi compliquée, je vous invite à vous documenter sur les problèmes de
spurious wakeup et
lost wakeup
que cette conception d’API vise à éviter. Dans l’ensemble, les Condvar
sont
aussi piégeuses que les Mutex
, et l’utilisation de mpsc
devrait leur être
préférée chaque fois que c’est possible/approprié.
Verrouillage partagé : RwLock
Il arrive souvent qu’une donnée soit beaucoup accédée en lecture, et rarement
modifiée. Pour ce genre de cas, Rust fournit le type
RwLock
, qui
fonctionne presque comme un Mutex
, mais avec des APIs
read()
et
write()
séparées pour que les threads puissent indiquer si ils veulent un accès en
écriture ou un accès en lecture seule.
Au prix d’un coût par transaction de synchronisation un peu plus élevé, cela permet à plusieurs threads d’avoir accès aux données en lecture seule simulatanément.
On peut donc le voir comme une généralisation multi-thread de RefCell
, où
l’erreur est récompensée par une deadlock plutôt qu’une panique, et avec les
problèmes de Mutex
en plus.
Mais on peut aussi le voir comme une forme de Mutex
plus efficace dans le cas
où on a beaucoup de lectures, peu d’écritures, et des transactions suffisamment
grosses. Question de point de vue !
Autres mécanismes de mutabilité interne
Il y a d’autres primitives de synchronisation dans la bibliothèque standard
Rust, qu’on ne pense pas en termes de partage de données mais qui en
impliquent : barrières,
Once
…
Si vous avez envie de structures de données concurrentes plus spécialisées, il existe beaucoup de bibliothèques tierces très intéressantes, mentionnons parmi d’autres…
crossbeam
, une boîte à outils assez générale.parking_lot
, plus spécialisée sur la programmation parMutex
& assimilé.- Et quelques primitives non bloquantes écrites par l’auteur de ce cours :
triple-buffer
pour le partage de données etrt-history
pour la gestion d’historique en flux continu.
Mentionnons pour conclure que toute forme de mutabilité interne en Rust est
basée sur la primitive bas niveau
UnsafeCell
,
qui désactive les optimisations supposant l’absence de mutation via une
référence partagée, et permet un accès au contenu par le biais de pointeurs
bruts.
Comme précédemment, si vous envisagez d’utiliser cette primitive directement, je vous recommande de commencer par réutiliser les bibliothèques déjà écrites par des experts si possible, et si il faut faire les choses vous-même, aller lire le Rustnomicon.
Programmation système
En dehors de println!()
et de quelques apparitions éclair de File
, les
exemples précédents de ce cours ne faisaient qu’exécuter du code sur le CPU,
sans interagir avec le système d’exploitation.
Dans ce chapitre, nous allons maintenant aborder les outils fournis par Rust pour utiliser les fonctionnalités dudit système d’exploitation, tout en gardant un code aussi portable que possible.
Threads
Dans les chapitres sur les pointeurs et la mutabilité interne, nous avons beaucoup parlé de threads. Il est maintenant temps d’aborder comment ceux-ci sont gérés en Rust.
Parallélisme structuré
La programmation parallèle est difficile car on doit concevoir des programmes qui sont corrects quel que soit l’ordre dans lequel les différentes opérations sont effectuées par les différents threads.
Mais il y a des degrés dans la difficulté :
- A un extrême, on a le parallélisme de données, dont vous verrez dans la section “parallélisation” qu’il peut être complètement géré par une bibliothèque sans aucun effort de votre part. Je vous recommande d’utiliser cette option chaque fois que c’est possible.
- A l’autre extrême, il y a des programmes qui lancent des threads qui tournent en tâche de fond pendant toutes la durée d’exécution de l’application, complètement indépendants du thread principal à quelques interactions peu visibles dans le code près.
- Entre ces deux extrêmes, il y a une manière de structurer les programmes
parallèles qui est relativement facile à comprendre, tout en permettant de
bonnes performances sur de nombreux problèmes. C’est de définir au sein du
programme des régions parallèles, avec un début et une fin bien déterminée, et
un objectif relativement clair.
- Au début de la région parallèle, des threads sont lancés.
- Au sein de la région parallèle, les threads s’exécutent ensemble pour atteindre l’objectif donné, avec une synchronisation aussi simple et rare que possible.
- A la fin, on attend que tous les threads aient terminé, on gère les erreurs éventuelles, et on reprend l’exécution en séquentiel jusqu’à la région parallèle suivante.
Cette dernière approche est appelée “parallélisme structuré”, et elle est appropriée quand les régions parallèles ont une charge de travail suffisante pour que les coûts de création/arrêt/synchronisation de threads soient bien amortis et que le poids relatif des régions séquentielles du code soit négligeable par rapport à celui des régions parallèles. Je vous recommande de la privilégier par rapport au parallélisme non structuré, lorsque le parallélisme de données n’est pas applicable.
En Rust, le parallélisme structuré est supporté via la construction
std::thread::scope()
. En
voici un exemple relativement simple que vous pouvez vous amuser à modifier
comme vous voulez :
use std::sync::mpsc; fn main() { // Les variables définies hors de la région parallèle sont accessibles par // référence aux threads de traitement : on a la garantie que les threads // auront terminé avant que cet état soit libéré. let base = 42; // Début de la région parallèle std::thread::scope(move |scope| { // Chaque thread a une file pour recevoir du travail à faire let num_threads = 5; let inputs = std::iter::repeat_with(|| mpsc::channel()) .take(num_threads) .collect::<Vec<_>>(); // Création de quelques threads, avec extraction des interfaces // d'entrée des files pour leur soumettre du travail let inputs = inputs.into_iter() .enumerate() .map(|(thread_idx, (input_send, input_recv))| { // Création d'un thread associé à la région parallèle scope.spawn(move || { // Traitement des demandes jusqu'à ce que le thread // principal ait terminé for entree in input_recv { // Traitement d'une demande utilisant l'état partagé println!("Thread {thread_idx} : Reçu {entree}"); let resultat = entree + base; println!("Thread {thread_idx} : Emis {resultat}"); } }); // On expose l'interface d'entrée au thread principal input_send }) .collect::<Vec<_>>(); // Soumission d'un peu de travail aux threads for i in 0..30 { let thread_idx = i % num_threads; let input = &inputs[thread_idx]; input.send(i).expect("Un thread de travail est mort"); } // Ici, les destructeurs de "inputs" sont appelés, ce qui signale aux // threads de travail que le thread principal à terminé. // L'implémentation de std::thread::scope attend ensuite que les threads // de travail aient terminé, en propageant les paniques éventuelles. }); }
Vous noterez que l’exécution de ce programme d’exemple n’est pas très parallèle.
Cela tient au fait que le calcul est très simple et l’accès à la sortie texte de
println!()
est soumis à synchronisation.
Parallélisme non structuré
Parfois, le parallélisme structuré ne convient pas et on est forcé de créer des threads secondaires vraiment indépendants du thread principal. On parlera alors de parallélisme non structuré.
Dans ce cas, ça devient votre responsabilité d’assurer les bonnes propriétés que le parallélisme structuré garantissait pour vous :
- L’état partagé ne doit pas être libéré avant que l’ensemble des threads
n’aient fini de l’utiliser (on utilise généralement
Arc
pour ça, au prix de nombreuses allocations mémoire). - Lorsqu’une erreur survient au sein d’un thread, les autres threads qui
travaillent avec ce thread doivent en être informés et le gérer (si vous
utilisez
mpsc
, c’est en partie fait pour vous, avec les autres formes de synchronisation vous devez l’implémenter vous-même). - Le thread principal ne doit pas s’arrêter avant que l’ensemble des threads
secondaires n’aient terminé leur travail (en termes
pthread
, ce sont des threads détachés).
Le langage vous fournit quelques aides à la synchronisation même dans ce cas :
- On l’a vu ci-dessus, l’utilisation de
Arc
etmpsc
permet de récupérer une partie des bonnes propriétés du parallélisme structuré. - La primitive de synchronisation
Barrier
vous permet d’attendre qu’un groupe de threads ait terminé un travail avant que l’ensemble de ces threads ne soient autorisés à continuer. - La création d’un thread vous retourne un
JoinHandle
, que vous pouvez utiliser pour attendre que le thread ait fini de s’exécuter et propager les paniques éventuelles. - Et puis il y a les autres outils mentionnés dans le chapitre précédent :
Mutex
,Condvar
, …
Pour créer des threads de façon non structurée, vous pouvez utiliser
std::thread::spawn()
.
Voici une variante de l’exemple précédent qui utilise du parallélisme non
structuré :
use std::sync::{mpsc, Arc}; fn main() { // L'état partagé doit être géré d'une façon qui ne permet pas sa libération // précoce avant que les threads n'aient fini de l'utiliser. Le compilateur // ne peut pas le prouver dans le cas du parallélisme non structuré, donc // on doit utiliser Arc. let base = Arc::new(42usize); // Création des files de travail entrant, comme avant let num_threads = 5; let inputs = std::iter::repeat_with(|| mpsc::channel()) .take(num_threads) .collect::<Vec<_>>(); // Création des threads. Cette fois, on doit récupérer le JoinHandle, au // lieu de laisser la région parallèle le gérer comme avant. let inputs_and_handles = inputs.into_iter() .enumerate() .map(|(thread_idx, (input_send, input_recv))| { // Création d'une copie du Arc associé à l'état partagé, // qui sera spécifique à ce thread : let base = base.clone(); // Création d'un thread associé à la région parallèle let join_handle = std::thread::spawn(move || { // Traitement identique au code précédent, sauf qu'on doit // penser à déréférencer le Arc. for entree in input_recv { println!("Thread {thread_idx} : Reçu {entree}"); let resultat = entree + *base; println!("Thread {thread_idx} : Emis {resultat}"); } }); // On expose l'interface d'entrée au thread principal comme // avant, mais on y ajoute le JoinHandle pour attendre le thread (input_send, join_handle) }) .collect::<Vec<_>>(); // Soumission de travail presque identique au code précédent, à part que // maintenant on a aussi des JoinHandles dans la liste des files entrantes // et ça nécessite un peu de pattern matching. for i in 0..30 { let thread_idx = i % num_threads; let (input, _handle) = &inputs_and_handles[thread_idx]; input.send(i).expect("Un thread de travail est mort"); } // Et enfin, on attend les threads de travail for (input, handle) in inputs_and_handles { // Pour les prévenir qu'on a fini, on doit déclencher précocément le // destructeur de l'interface d'entrée de la file d'attente... std::mem::drop(input); // ...après quoi on peut attendre les thread en toute sécurité handle.join().expect("Un thread de travail est mort"); } }
J’espère que cet exemple simple suffira à vous convaincre que le parallélisme structuré est d’une ergonomie très supérieure à celle du parallélisme non structuré, et devrait donc être utilisé chaque fois qu’il est applicable au problème qu’on veut traiter.
Processus
Les fonctionnalités du module
std::process
de la
bibliothèque standard Rust permettent de…
- Contrôler le processus associé au programme en cours d’exécution.
- Exécuter d’autres programmes en contrôlant leur exécution et en échangeant des données avec eux via leurs entrées/sorties standard.
Si l’on compare aux fonctionnalités standard C++ héritées du
C, les possibilités de
contrôle du processus actif sont comparables, mais la gestion des processus
enfants est beaucoup plus complète et se rapproche plus du module
subprocess
de la
bibliothèque standard Python.
Contrôle du processus actif
Pour arrêter le processus actif, on trouve d’abord les fonctions
exit()
et
abort()
. Elles
fonctionnent comme leur homologues C/++, mais leurs effets pervers sont mieux
documentés.
Contrairement à C/++, Rust n’expose pas de nuances plus fines d’exit()
comme
quick_exit()
, _Exit()
et atexit()
, car l’expérience du C++ montre que ces
missions sont généralement mieux assurées en privilégiant une gestion normale
des erreurs qui se propage jusqu’à main()
, et en réservant l’utilisation de
exit()
aux situations exceptionnelles.
Le module std::process
définit également le trait
Termination
,
qui représente l’ensemble des types pouvant être retournés en résultat de la
fonction main()
.
L’idée générale est qu’on peut soit retourner directement un code de statut
comme en C/++, soit retourner un type qui peut être converti vers deux codes
de statut standard
ExitCode::SUCCESS
et
ExitCode::FAILURE
. La conversion associée aux types erreur affiche la
description Debug
de l’erreur sur stderr
avant d’arrêter le programme :
use std::fmt::{Debug, Formatter, self}; // Type erreur minimal compatible avec Termination // (Un type erreur plus complet implémenterait aussi Error) struct BadMood; // impl Debug for BadMood { fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { writeln!(f, "Sorry, not in the mood to do this today") } } // Démonstration de l'effet sur la sortie de main() fn main() -> Result<(), BadMood> { Err(BadMood) }
Un ajout mineur par rapport à ce qui est disponible en C/++ est la
fonction id()
, qui
permet de récupérer le PID du programme en cours d’exécution.
Et une limitation mineure par rapport au C/++ est que les fonctionnalités de
gestion des signaux Unix et de setjmp
/longjmp
de la bibliothèque standard C
ne sont pas exposées :
- Il est difficile d’exposer la gestion de signaux Unix sans risque de comportement indéfini pour l’utilisateur, et ils n’ont pas leur place dans une bibliothèque standard ayant vocation à être portable vers tous les OS, donc ce travail est sous-traité à des bibliothèques tierces.
setjmp
/longjmp
, qui est plus ou moins équivalent à ungoto
sans restriction, n’est pas du tout supporté en Rust. C’est un comportement indéfini d’appeler ces fonctions, et le compilateur peut mieux optimiser le code en présumant que ça n’arrivera pas.
Exécution de programmes
Le seul outil fourni par la bibliothèque standard C++ pour exécuter d’autres
programmes est system()
, qui prend en paramètre une commande, fait exécuter
cette commande par le shell, et retourne le code de statut résultant. Il y a
de très nombreux problèmes avec cette logique :
- On ne sait pas quel shell est utilisé, et donc comment la commande va être interprétée (par exemple, les shells Windows sont très différents des shells Unix, eux-même assez divers).
- Il faut faire attention au contenu des variables d’environnement (notamment
les
PATH
s), l’interprétation de la commande par le shell peut en dépendre de façon indésirable. - Ce fonctionnement basé sur la génération de code shell, dont l’interprétation est sensible à l’environnement d’exécution, est difficile à sécuriser face à des utilisateurs malveillants.
- On ne peut pas échanger avec le programme en cours d’exécution via
stdin
,stdout
etstderr
, ce qui est nécessaire pour de nombreux outils en ligne de commande. - On est forcé d’attendre la fin du programme, c’est compliqué de l’interrompre en cours de route si il prend trop de temps ou l’utilisateur a changé d’avis.
Pour toutes ces raisons, il est préférable de prendre exemple sur Python et ne pas exposer le shell, mais plutôt la fonctionnalité plus bas niveau du système d’exploitation : créer un processus qui exécute un certain binaire avec certains arguments, dans un certain environnement. Puis suivre l’exécution du programme, échanger via ses entrées/sorties standard, le tuer si il faut…
C’est donc le modèle qu’a repris Rust, en adaptant la conception d’API à ses besoins :
#![allow(unused)] fn main() { use std::process::Command; // Commande équivalente à "ls / /usr", exécutée de façon synchrone et avec // héritage de l'environnement parent pour simplifier cet exemple. // // Notez qu'il faut séparer les arguments nous-même : puisque nous ne passons // pas par le shell, nous devons faire ce travail à sa place. // let sortie = Command::new("ls") .args(["/", "/usr"]) .output() .expect("sortie"); // On s'attend à ce que cette exécution réussisse et ne produise rien sur stderr assert!(sortie.status.success()); assert!(sortie.stderr.is_empty()); // La sortie stdout est une séquence d'octets. Pour la traiter comme une chaîne, // nous devons spécifier comment les octets non UTF-8 seront traités. println!("Octets bruts : {:02x?}", sortie.stdout); let stdout = String::from_utf8_lossy(&sortie.stdout[..]); println!("\n--- Interprétation textuelle ---\n{stdout}--------------------------------"); }
Si vous voulez en savoir plus, le point d’entrée est la création d’un objet
Command
, qui
sert à paramétrer un processus avant exécution. Le style d’API utilisé s’appelle
builder pattern, c’est une des façons
usuelles de gérer des paramètres
optionnels en Rust.
Horloge
La mesure du temps dans un programme est une de ces tâches qui semble simple au
premier abord, mais se révèle complexe quand on y regarde de plus près.
L’évolution des APIs de gestion du temps de C++ en témoigne : parti des
fonctionnalités simples du C, C++
a graduellement accumulé une API très
complexe en essayant de supporter
toutes les utilisations avancées de l’horloge système pour lesquelles time()
et clock()
se révèlent être trop simplistes.
Rust a adopté ici une approche nettement plus minimaliste : la vocation du
module std::time
de la
bibliothèque standard Rust n’est pas de répondre à tous les besoins possibles et
imaginables de gestion du temps,
si
obscurs
soient-ils,
mais de fournir juste ce qu’il faut pour…
- Mesurer précisément le temps écoulé pendant l’exécution du programme.
- Connaître l’heure système UTC et pouvoir la comparer entre deux processus et avec les différentes timestamps d’accès aux fichiers.
Les fonctionnalités plus avancées, comme la gestion des fuseaux horaires ou le
formatage des dates/heures, sont déléguées à des bibliothèques externes
spécialisées comme
time
et
chrono
.
Mesure du temps écoulé
L’équivalent Rust de la fonction
clock()
du C et de
l’horloge
steady_clock
de C++
est le type Instant
.
C’est une horloge monotone : elle part d’un point arbitraire dans le passé,
n’est pas modifiée quand l’heure système est modifiée, et on peut donc
l’utiliser quand on veut savoir combien de temps prennent les opérations
effectuées par le programme.
La conception est similaire à celle de std::chrono
en C++, mais avec une API
qui est beaucoup plus simple, tout en restant assez puissante pour tous les
besoins courants :
- A tout moment, on peut demander l’heure qu’il est du point de vue de cette
horloge avec
Instant::now()
. Cette information est représentée par un objet de typeInstant
. - Connaissant deux
Instant
s, on peut calculer le temps écoulé entre les deux en les soustrayant. Ce temps écoulé est représenté par un objet de typeDuration
. - De façon symétrique, on peut ajouter ou supprimer une
Duration
à unInstant
pour obtenir un autreInstant
qui représente la valeur de l’horloge attendue N secondes avant/après la mesure de temps précédemment effectuée.
Voici un exemple d’utilisation de Instant
et Duration
:
#![allow(unused)] fn main() { use std::time::{Instant, Duration}; // Exécution minutée let debut = Instant::now(); println!("Affichage d'un texte"); let duree = debut.elapsed(); // Analyse du temps d'exécution println!("L'affichage a pris {duree:?}"); assert!(duree < Duration::from_millis(100)); // printf n'est pas si lent ! }
Heure système UTC
Instant
ne permet pas de répondre à la question utilisateur “Quelle heure
est-il ?”, car son point d’origine est arbitraire : ça peut être l’allumage de
l’ordinateur, le lancement du programme, le dernier redémarrage vraiment
complet…
c’est dépendant de l’OS qu’on est en train d’utiliser. Il y a même des
divergences d’opinions entre les OS sur la pertinence de continuer à mesurer ou
pas le temps écoulé quand l’ordinateur passe en veille (la bonne approche dépend
du besoin).
L’horloge Rust qui permet de se raccorder au temps humain, comme la fonction
time()
en C et l’horloge
system_clock
en C++,
s’appelle SystemTime
.
Elle s’utilise un peu comme Instant
, mais elle a en plus un point de référence
UNIX_EPOCH
qui permet d’en déduire la date/heure UTC.
Les précautions usuelles concernant l’heure système s’appliquent :
- Gardez à l’esprit que l’horloge système n’est pas forcément à l’heure. Il peut être dangereux de présumer qu’elle l’est si, par exemple, la sécurité de votre programme en dépend.
- Il est peu probable que UTC soit le fuseau horaire de l’utilisateur. On doit donc éviter d’afficher des heures UTC dans des messages destinés à ce dernier, sinon il y a un risque de confusion.
- L’horloge système est sujette à se décaler brutalement vers le passé ou l’avenir si l’utilisateur ou un système de mise à l’heure automatique change sa valeur. Ce n’est donc pas une horloge adaptée pour mesurer des durées d’exécution au sein du programme.
Voici un exemple d’utilisation de SystemTime
:
#![allow(unused)] fn main() { use std::time::SystemTime; let dt = SystemTime::UNIX_EPOCH .elapsed() .expect("Non, on n'est pas plus tôt que le 1/1/1970 !"); println!("Il s'est passé {dt:?} depuis l'epoch Unix"); }
On le voit, les fonctionnalités de manipulation de l’heure système de la
bibliothèque standard Rust sont minimalistes, et on a fortement intérêt à les
compléter avec des bibliothèques comme
time
et
chrono
si on a l’intention de faire
quoi que ce soit d’un peu complexe comme afficher des dates/heures à
l’utilisateur.
Cette conception différente s’explique par la facilité d’utilisation des bibliothèques tierce partie en Rust, via Cargo, qui évite d’encombrer la bibliothèque standard de fonctionnalités avancées au risque de réduire sa portabilité entre matériels et systèmes d’exploitation.
Entrées/Sorties
Introduction
Pour comprendre les enjeux des entrées/sorties en C++ et en Rust, il faut
d’abord se pencher un peu sur les entrées/sorties standard du C que ces deux
langages utilisent sous le capot. En effet, c’est l’interface standard des
accès aux fichiers et à stdin
/stdout
/stderr
qui met d’accord tous les
systèmes d’exploitation couramment utilisés grâce à la popularité du C.
La bibliothèque d’entrée/sortie standard du C est plutôt bien conçue au niveau
de ses opérations bas niveau comme
fread()
,
fwrite()
et
fseek()
. Mais les couches
supérieures de la pile d’abstraction ont plusieurs problèmes que les langages
plus récents essaient de résoudre :
- Il n’y a pas de support standard des échanges réseau, ce qui est devenu une omission gênante dans le monde d’aujourd’hui où presque tous les ordinateurs sont connectés à Internet.
- Il est difficile d’écrire du code portable manipulant des noms de fichiers via
fopen()
,remove()
etrename()
, au-delà des lectures/écritures basiques dans le répertoire courant à des chemins codés en dur, car la syntaxe des chemins de fichiers varie d’un système d’exploitation à l’autre et la bibliothèque standard C ne fournit aucun outil pour manipuler des chemins de fichiers de façon portable. - Il est laborieux de tester du code faisant des entrées/sorties dans un environnement cloisonné comme un serveur d’intégration continue, car on ne peut pas facilement modifier un programme qui fait des entrées/sorties pour qu’il échange simplement des octets avec des tampons stockés en RAM, sans utiliser des fonctionnalités spécifiques à un OS cible.
- La gestion des erreurs des entrées/sorties standard C est très laborieuse, il est facile de manquer ou mal interpréter un code d’erreur et d’avoir un programme qui poursuit son exécution de façon incorrecte quand une erreur se produit.
- De nombreuses fonctions de la bibliothèque standard C ne devraient plus être
utilisées car elles ont des problèmes insolubles de sécurité (ex : fonctions
produisant des sorties non bornées comme
scanf()
), de performances (ex : fonctions travaillant octet par octet) ou ne règlent pas vraiment les problèmes qu’elles étaient censées régler et n’ont donc pas beaucoup d’intérêt (ex : fonctions basées sur les “wide characters”). Il est donc préférable de ne pas exposer toute l’API C à l’identique, et on peut en profiter pour revoir ladite API en passant.
La bibliothèque standard Rust résout ces problèmes à tous les niveaux
d’abstraction. Dans ce chapitre, nous allons nous concentrer sur les couches
basses, qui concernent les échanges d’octets et de texte avec des sources et
destination. Dans des chapitres ultérieurs, nous aborderons ensuite le cas
particulier des flux standards stdin
/stdout
/stderr
, fichiers et échanges
réseau.
Entrées/sorties binaires avec std::io
Read
, Write
et Seek
Au coeur du module std::io
, on trouve les traits Read
, Write
et Seek
,
qui correspondent comme leur nom l’indique aux fonctions d’entrée/sortie de
base de la bibliothèque standard C : lire des octets, écrire des octets, et
savoir où on se trouve / changer de position dans le fichier.
Rust reprend la logique introduite par C et Unix de manipuler tout ce qui
ressemble à un flux d’octets de la même façon, mais grâce aux traits cette
uniformisation est poussée plus loin. Parmi les types qui implémentent
Read
, on
retrouve ainsi…
- Les fichiers, bien sûr
- Les connexions réseau entrantes
- Les pipes entrants (
stdin
du processus actif,stdout
/stderr
des processus enfants, etc.) - Quelques itérateurs spécialisés du module
std::io
- Différentes collections ordonnées d’octets, parfois avec l’aide d’un wrapper
Cursor
qui mémorise l’information “quel octet suis-je en train de lire/écrire dans la collection”
La situation est similaire du côté des types qui implémentent
Write
, là où
Seek
est implémenté de façon plus
sélective car
peu de flux d’octets ont une notion de position et de déplacement.
Grâce à cette abstraction, les programmes Rust qui manipulent des fichiers sont plus faciles à tester et à faire évoluer. On peut facilement écrire du code générique qui fonctionne aussi bien avec un fichier qu’une collection d’octets en mémoire, et s’en servir pour vérifier dans les tests unitaires que les entrées/sorties sont correctes. Et on peut plus facilement passer d’un programme qui fait des entrées/sorties fichiers à un programme qui fait des communications réseau ou inter-processus.
Par-dessus la fonction de base d’échange d’octets fourniées par chaque
implémentation, les traits Read
, Write
et Seek
fournissent des méthodes
qui simplifient des tâches courantes telles que lire l’intégralité des octets
restants dans un tampon, écrire l’intégralité des octets d’une slice, ou
concaténer plusieurs sources de données entrantes.
Voici une démonstration simple des fonctionnalités de Read
, Write
et
Seek
. Comme nous n’avons pas encore traité les “vrais” sources et drains de
données, nous les appliquerons à des itérateurs et des données en mémoire.
// Import de tous les traits d'E/S standards + type Cursor use std::io::{prelude::*, Cursor}; fn main() { // Itérateur qui répète des octets d'une certaine valeur let mut quarante_deux = std::io::repeat(42); // Lecture de quelques octets let mut tampon = [0; 8]; { let octets_lus = quarante_deux.read(&mut tampon[..]) .expect("Echec de la lecture"); println!( "Lu {octets_lus} octets depuis l'itérateur : {:?}", &tampon[..octets_lus] ); } // Vecteur d'octets utilisé comme destination let mut dest = Vec::new(); // Ecriture de quelques octets dest.write_all(b"Je vous salue bien") .expect("Echec de l'écriture"); println!("Octets ecrits : {dest:?}"); // Lecture aléatoire avec un curseur let mut src = Cursor::new(&mut dest); { let octets_lus = src.read(&mut tampon[..]) .expect("Echec de la lecture"); println!( "Lu {octets_lus} octets depuis l'itérateur : {:?}", &tampon[..octets_lus] ); } // Position actuelle println!("Actuellement à la position {} du flux d'entrée", src.stream_position().expect("Echec de la requête de position")); }
BufRead
, BufReader
et BufWriter
La plupart des implémentations de Read
et Write
ne font aucun buffering en
interne, chaque appel à Read::read()
et Write::write()
correspond
directement à un appel système de l’OS sous jacent qui échange des données avec
un tampon fourni par l’utilisateur. Cela a deux conséquences :
- Il n’est pas possible de fournir au niveau de
Read
des fonctionnalités comme la lecture de fichier texte ligne par ligne, car elles requièrent (si on utilise un tampon de taille >1, ce qu’on devrait toujours faire pour des raisons de performances) de garder des données de côté pour les ressortir lors d’un appel ultérieur. - Il est très facile de se tirer dans le pied au niveau des performances en
utilisant
Read
ouWrite
avec un tampon de taille trop petite, qui correspond aux besoins du moment du programme et pas à la granularité d’échange efficace avec le périphérique sous-jacent.
Pour éviter ces problèmes, on utilise les wrappers BufReader
et BufWriter
,
qui insèrent une couche tampon de taille configurable entre le programme et
l’implémentation Read
/Write
sous-jacente.
Ces types ré-implémentent Read
et Write
en passant par le tampon interne,
mais du côté des lectures, BufReader
implémente aussi le trait
BufRead
, qui permet de
découper le flux de données entrant en sections délimitées par un certain octet
(tel que b'\n'
pour les sauts de ligne sous Unix).
BufRead
est aussi directement implémenté par les implémentations de Read
qui
possèdent déjà un tampon d’octets sous le capot, comme les slices d’octets :
#![allow(unused)] fn main() { use std::io::{prelude::*, Cursor}; // Données d'entrée et Cursor pour la lecture let source = [1, 2, 3, 42, 4, 5, 42, 42, 6, 7, 8]; let curseur = Cursor::new(&source[..]); // Lecture tronçonnée avec BufRead for segment in curseur.split(42) { println!("Segment : {:?}", segment.expect("Echec de la lecture")); } }
Erreurs d’entrées/sorties
Toutes les fonctions de std::fs
et std::io
signalent leurs erreurs avec le
type std::io::Error
. Ce
type est traduit depuis la gestion d’erreur de la libc
(codes d’erreur,
errno
, etc.) et des autres APIs système utilisées. Mais comme il est utilisé
via Result
, on ne peut pas oublier de gérer les erreurs comme en C et en C++.
L’abstraction par rapport aux différents types d’erreurs système est associée
via le mécanisme
ErrorKind
, qui fournit
une classification analogue à celle des valeurs standard de errno
en C. Les
programmes ayant besoin d’analyser plus précisément ce qui se passe peuvent
accéder aux erreurs système brutes, dont l’interprétation est non portable, via
des méthodes comme
raw_os_error()
.
Comme il existe un très grand nombre de fonctions d’entrée/sortie qui retournent
un type Result<T, std::io::Error>
, il existe un raccourci
std::io::Result<T>
pour écrire ce type de façon un peu plus concise.
Entrées/sorties textuelles avec std::fmt
Chaînes UTF-8 pré-existantes
Les traits Read
et BufRead
fournissent des méthodes standard pour traiter
les données entrantes comme de l’UTF-8. Elles se comportent comme des méthodes
de lecture d’octets sauf qu’elles valident que le flux d’octets entrant est
bien encodé en UTF-8, traitent les erreurs de validation comme un cas
particulier d’erreur d’entrée/sortie, et stockent leur résultats dans des
String
:
#![allow(unused)] fn main() { use std::io::prelude::*; // Chaîne de caractères ASCII traitée comme un flux d'octets via Read et BufRead. let mut ascii: &[u8] = b"Bonjour, je suis un texte en ASCII.\nVous pouvez me lire ligne par ligne."; // Itération sur les lignes du flux d'octets via BufRead for ligne in ascii.lines() { println!( "Lu une ligne : {}", ligne.expect("Erreur de lecture") ); } }
Si l’on a une chaîne de caractères pré-encodée en UTF-8, on peut aussi l’écrire
via un flux d’octets sortant Write
. Il suffit d’utiliser la méthode
as_bytes()
du type chaîne utilisé pour accéder à la séquence d’octets UTF-8
sous-jacente :
#![allow(unused)] fn main() { use std::io::prelude::*; // Tampon traité comme un flux d'octets sortant let mut dest = Vec::new(); // Ecriture d'UTF-8 dans le tampon dest.write_all("Je suis encodé en UTF-8".as_bytes()) .expect("Erreur d'écriture"); // Affichage des octets sortants println!("Octets sortants : {dest:02x?}"); }
Mais que se passe-t’il si on n’a pas déjà une chaîne de caractères
pré-existante ? Doit-on commencer par en créer une avant de faire des
entrées-sorties ? On pourrait, mais ce n’est pas ce qu’il y a de plus efficace,
car on doit faire plusieurs passes sur les données. Il y a donc une façon de
faire plus efficace, analogue au ifstream
du C++ et au fprintf()
du C.
Les cousins de println!()
Depuis le début de ce cours, nous utilisons la macro
println!()
pour effectuer
des sorties console. Cette macro peut être vue comme une forme améliorée du
printf()
du C, donc par analogie avec le C, on peut se demander si il n’y a
pas des équivalents aux fonctions fprintf()
et sprintf()
de la libc. Et
en effet, il existe de nombreuses variantes de println!()
en Rust :
print!()
n’inclut pas le saut de ligne final qui est automatiquement inséré parprintln!()
. Il faut être prudent avec cette variante, car sur la plupart des systèmes d’exploitation, les sortiesstdout
ne sont affichées sur la console qu’après chaque saut de ligne.eprintln!()
eteprint!()
sont des variantes deprintln!()
etprint!()
qui écrivent surstderr
plutôt que surstdout
. Traditionnellement, un programme Unix écrit sa sortie normale surstdout
et ses erreurs et messages de statut surstderr
.format!()
fonctionne un peu commeprint!()
, mais construit une chaîne de caractères au lieu d’écrire surstdout
.- Et enfin
writeln!()
etwrite!()
fonctionnent un peu commeprintln!()
etprint!()
, mais peuvent être utilisées pour ajouter du texte à des chaînes de caractères pré-existantes et pour écrire de l’UTF-8 dans des fichiers, la destination étant donné en premier argument.
Ces deux dernières macros sont un peu plus complexes que les autres, nous allons donc donner quelques exemples pour clarifier comment elles fonctionnent.
D’abord, on peut utiliser write!()
et writeln!()
pour écrire du texte
formaté en encodage UTF-8 dans un flux d’octets sortant. Dans cette utilisation,
ces macros utilisent le trait std::io::Write
que nous avons déjà vu, et
retournent un résultat de type std::io::Result<()>
:
#![allow(unused)] fn main() { use std::io::prelude::*; // Tampon accueillant les octets sortants let mut sortie = Vec::new(); // Ecriture de texte formaté write!( &mut sortie, "La réponse est {}", 42 ).expect("Erreur d'entrée/sortie"); // Lecture des octets sortants println!("Octets émis : {:02x?}", sortie); }
On peut également utiliser write!()
et writeln!()
pour écrire du texte
formaté dans une chaîne de caractère (String
, OsString
) ou dans le type
Formatter
qui abstrait différentes sorties texte pour les besoins de
l’implémentation de Display
et Debug
.
Dans cette utilisation, ces macros utilisent un autre trait appelé
std::fmt::Write
et retournent un résultat de type
std::fmt::Result<()>
,
basé sur le type erreur opaque
std::fmt::Error
.
#![allow(unused)] fn main() { use std::fmt::Write; // Chaîne de caractères initiale let mut s = String::from("Du texte"); println!("Chaîne initiale : {s}"); // Ajout de texte write!( &mut s, ", et encore {}", "plus de texte" ).expect("Erreur d'écriture formatée"); // Chaîne de caractères final println!("Chaîne finale : {s}"); }
Le fait que ces opérations retournent toujours un fmt::Result<()>
peut
surprendre, dans la mesure où quand on écrit dans une chaîne de caractères,
aucune erreur ne peut survenir, et donc le cas expect()
ci-dessus ne sera
jamais rencontré. C’est un des cas où la bibliothèque standard Rust utilise un
type erreur un peu trop pessimiste pour éviter la prolifération des types
erreur.
En effet, dans le cas de Formatter
(qui, pour rappel, est la couche
d’abstraction utilisée par les implémentations de Display
et Debug
) la
cible des écritures peut être n’importe quoi, y compris un fichier. L’erreur
est donc possible, et ça a du sens d’avoir un type erreur dans ce cas. C’est
juste le fait d’avoir utilisé le même trait et le même type erreur pour les
écritures infaillibles dans les chaînes de caractères qui est regrettable, et
malheureusement cela ne peut plus être changé maintenant pour des raisons de
compatibilité avec le code Rust existant.
Mais bon. Si on avait l’esprit moins chagrin que moi, on pourrait aussi comparer
le résultat à la situation de C++, où l’API historique <iostream>
reprend tous
les vices de conception de la bibliothèque standard C et en ajoute un paquet
d’autres de son cru, tandis que la nouvelle API <format>
de C++20 est une
mauvaise copie du std::fmt
de Rust qui a été rendue si compliquée par la magie
du design by
committee que personne ne
comprend comment s’en servir. Vu sous cet angle, Rust peut quand même être
plutôt fier de son mécanisme de sortie texte formaté, qui offre un excellent
compromis ergonomie/performance/flexibilité en comparaison.
Fichiers et dossiers
En Rust, les lectures et écriture dans les fichiers se font via l’infrastructure
standard pour les flux d’octets (Read
, Write
et Seek
). Mais il y a aussi
plusieurs utilitaires supplémentaires pour gérer les chemins de fichiers de
façon portable, explorer l’arborescence des dossiers, consulter les métadonnées,
et simplifier quelques opérations courantes. C’est l’objet de ce chapitre.
Chemins de fichiers
La manipulation portable de chemins de fichiers est plus difficile qu’il n’y paraît. Quelques exemples :
- Les préfixes varient entre systèmes d’exploitation. Un chemin de fichier Unix
absolu commence par
/
, alors que sous Windows on trouve d’autres choses commeC:\
et\\server\share
. - Les séparateurs varient entre systèmes d’exploitation. Unix utilise toujours
/
, là où Windows privilégie\
et peut accepter aussi/
ou pas selon l’API que vous êtes en train d’utiliser. - La sensibilité à la casse varie. Unix y est sensible, mais Windows ne l’est pas, et les comparaisons de chemins devraient en tenir compte.
- L’encodage varie entre systèmes d’exploitation. Unix utilise des séquences d’octets non nuls (généralement de l’UTF-8) là où Windows utilise des entiers 16-bits (généralement de l’UTF-16).
- Il est possible de transformer toute chaîne de caractère ne contenant pas de caractère nul vers l’encodage attendu par le système d’exploitation. En revanche, un chemin de fichier issu du système d’exploitation peut contenir de l’Unicode malformé et ne correspond donc pas nécessairement à une chaîne de caractère Unicode valide.
Rust fournit deux outils pour gérer ces différences entre systèmes d’exploitation :
- Une gestion générale des formats de chaînes de caractères spécifiques à chaque
système d’exploitation, avec leur Unicode potentiellement malformé et
potentiellement pas en UTF-8, via les types
std::ffi::OsStr
etOsString
. Ceux-ci peuvent être convertis depuis et vers les chaînes de caractère UTF-8 standard de Rust avec une gestion des erreurs.- Ces types n’ont pas d’équivalent en C++, ils sont pourtant très pratique dès qu’on interagit avec des APIs des systèmes d’exploitation autres que le système de fichiers.
- Une gestion des chemins de fichiers basée sur cette gestion des chaînes de
caractères OS, via les types
std::path::Path
etPathBuf
. C’est très similaire au typestd::filesystem::path
enfin introduit par C++17, sauf que…- On a la distinction
Path
/PathBuf
en Rust, qui est analogue à la distinctionstring_view
/string
en C++ : on peut manipuler des (fragments de) chemins sans avoir besoin de faire une allocation mémoire par fragment. - Ces types exposent un certain nombre de méthodes qui simplifient certains accès au système de fichier (pour résoudre les symlinks et chemins relatifs, interroger les métadonnées de la cible, etc.), là où en C++17 ces fonctionnalités sont uniquement accessibles via des fonctions libres.
- On a la distinction
Voici un exemple d’utilisation de Path
:
#![allow(unused)] fn main() { // Accès au répertoire de travail via std::env (cf chapitre ultérieur) let repertoire_travail = std::env::current_dir() .expect("Accès au répertoire de travail refusé"); // Affichage du chemin dans la console println!("Répertoire de travail : {repertoire_travail:?}"); // Itération sur les composantes du chemin for fragment in repertoire_travail.components() { println!("- {fragment:?}"); } // On vérifie que c'est bien un dossier assert!(repertoire_travail.is_dir()); }
Toutes les APIs qui acceptent des chemins de fichiers sont génériques de façon à
accepter &str
, String
, OsStr
, OsString
, Path
, PathBuf
, etc. Vous
n’êtes donc pas forcés d’utiliser Path
si vous écrivez du code non portable
où une chaîne de caractère convient comme chemin de fichier. Et vous pouvez
récupérer un chemin de fichier d’une API OS et l’utiliser directement sans
devoir passer par une conversion intermédiaire depuis et vers &str
.
Système de fichiers
La bibliothèque standard C ne supporte qu’un faible nombre d’opérations sur les fichiers : à part ouvrir des fichiers, on peut les supprimer, les déplacer, créer des fichiers temporaires, et c’est tout.
C’est loin de répondre aux besoins courants de manipulation de fichiers. Par exemple, il est tout aussi courant de vouloir…
- Canonicaliser des chemins de fichiers (résoudre les chemins relatifs et symlinks pour obtenir une forme normalisée)
- Créer des dossiers, lister leurs contenus, les supprimer.
- Créer des hardlinks et liens symboliques, les différencier du fichier vers lequel ils pointent.
- Interroger des métadonnées telles que les permissions d’accès et les dates de création/dernière modification/dernier accès.
…mais rien de tout ça n’est possible en C standard, il faut se tourner vers des APIs spécifiques à chaque système d’exploitation.
Lorsque le comité de normalisation C++ a tenté de relever le niveau, il a
réussi l’exploit douteux de produire la seule API de la bibliothèque standard
C++17 qu’il est presque impossible utiliser sans risque de comportement
indéfini. En effet, la documentation de
std::filesystem
commence par
poser une contrainte de validité sur les programmes qui utilisent cette API…
The behavior is undefined if the calls to functions in this library introduce a file system race, that is, when multiple threads, processes, or computers interleave access and modification to the same object in a file system.
…et sur tous les systèmes d’exploitation courants, c’est une contrainte qu’un programme ne peut pas respecter avec certitude, puisqu’il est impossible de contrôler ce que les autres programmes en cours d’exécution vont faire avec le système de fichiers en parallèle.
Heureusement, Rust est là pour relever le niveau : le module
std::fs
de la bibliothèque
standard Rust, qui fournit une fonctionnalité à peu près équivalente au
std::filesystem
de C++17, définit précisément à quelles fonctions des
différents systèmes d’exploitation il fait appel, ce qui permet de se référer à
la documentation du système d’exploitation et du système de fichiers utilisé
pour savoir ce qui va se passer dans ce genre de cas tordu. Le comportement en
cas d’accès concurrent est donc non portable, mais bien défini, et le programme
reste valide : c’est plus raisonnable…
Voici un exemple d’utilisation de std::fs
:
#![allow(unused)] fn main() { use std::time::{Duration, SystemTime}; // Equivalent de mkdir -p std::fs::create_dir_all("abc/def") .expect("Echec de création récursive de dossiers"); // On vérifie que la date de création est cohérente let creation = std::fs::metadata("abc/def") .expect("Echec de lecture des métadonnées") .created() .expect("Echec de lecture de la date de création"); let maintenant = SystemTime::now(); assert!( maintenant >= creation, "Erreur: Le fichier a été créé avant la mesure de l'heure ! \ (maintenant {:?} < creation {:?})", maintenant, creation ); // Equivalent de rm -r std::fs::remove_dir_all("abc") .expect("Echec de suppression du dossier"); }
Ouverture, accès, fermeture
En Rust, les fichiers sont représentés par le type
std::fs::File
, analogue
au type FILE
de C. Par rapport à ce dernier, le type File
de Rust a une API
de construction un peu plus élaborée que le fopen()
du C, ce qui simplifie les
utilisations courantes :
File::open()
ouvre un fichier en lecture seule.File::create()
ouvre un fichier en écriture. Si le fichier existe déjà, son contenu est effacé, sinon un nouveau fichier est créé.File::options()
donne accès à l’API plus complèteOpenOptions
, qui utilise une conception de type builder pattern et offre la même flexibilité que les chaînes de caractères defopen()
, la sûreté de typage et la lisibilité en plus.
Une fois qu’on a ouvert un fichier, on a accès à l’API Read
/Write
/Seek
usuelle de std::io
, mais aussi à quelques méthodes spécifiques aux fichiers
qui permettent de…
- Accéder aux métadonnées sans passer par le système de fichier, via
file.metadata()
. - Réduire la taille du fichier ou prévenir le système d’exploitation qu’il aura
une certaine taille à terme, via la méthode
file.set_len()
qui fonctionne comme leftruncate()
de POSIX. - Changer les permissions d’accès via la méthode
file.set_permissions()
, à la manière dufchmod()
de POSIX. - Demander au système d’exploitation de s’assurer que les données et métadonnées
aient été écrites sur le stockage sous-jacent avant de continuer, avec
file.sync_all()
etfile.sync_data()
qui fonctionnent comme lesfsync()
etfdatasync()
de POSIX.
Bref, les capacités de File
sont plus proches de celles de POSIX que de celles
de la bibliothèque standard C, ce qui est bien pratique quand on veut écrire du
code portable qui manipule des fichiers de façon non triviale (bases de données,
code ayant des contraintes de sécurité, etc.).
Comme en C++, les fichiers sont automatiquement fermés quand ils sortent du
scope. Mais cette façon de faire ne permet pas de détecter et gérer les
erreurs d’écriture soulevées par fclose()
, ce qui peut arriver dans quelques
cas tordus. Il est donc préférable d’appeler file.sync_all()
quand on en a
terminé avec un fichier pour gérer ces erreurs aussi.
Raccourcis
La chose la plus courante qu’on puisse vouloir faire avec un fichier, c’est
écrire ou lire la totalité du fichier. Rust fournit donc des raccourcis pour
ces tâches courantes avec les fonctions
std::fs::read()
,
read_to_string()
et
write()
:
#![allow(unused)] fn main() { // write() permet d'enregistrer du texte ou une autre séquence d'octets const NOM_FICHIER: &str = "test.txt"; std::fs::write(NOM_FICHIER, "J'ai écrit des trucs dans un fichier") .expect("Echec de l'écriture du fichier"); // read() permet de rélire la séquence d'octets println!( "Octets du fichier : {:02x?}", std::fs::read(NOM_FICHIER).expect("Echec de lecture des octets") ); // read_to_string() traite les octets comme de l'UTF-8 (avec validation) println!( "Texte du fichier : {}", std::fs::read_to_string(NOM_FICHIER).expect("Echec de lecture de texte") ); std::fs::remove_file(NOM_FICHIER).expect("Echec de nettoyage"); }
Pipes
Comme le C et en accord avec la conception générale d’Unix, Rust permet de
manipuler les flux d’entrée/sortie standard stdin
, stdout
et stderr
comme
si c’étaient des fichiers : ils implémentent Read
et Write
comme tous les
flux d’octets.
Si l’on souhaite écrire du code générique qui peut utiliser ces flux standards
parmi d’autres flux d’octets, on peut y avoir accès via les fonctions
std::io::stdin()
,
stdout()
et
stderr()
. C’est en
particulier la seule façon d’utiliser stdin
en Rust : il n’y a pas
d’équivalent du scanf()
de C et du std::cin
de C++ dans la bibliothèque
standard Rust.
Mais ces flux ont aussi quelques spécificités qui justifient un chapitre dédié dans ce cours :
- Ils peuvent ou non être connectés à un terminal, ce qui affecte un peu leurs propriétés.
- Ce sont des ressources globales qui peuvent être utilisées depuis plusieurs threads, et leur utilisation naïve est donc soumise à une synchronisation implicite.
Détection du terminal
La gestion du terminal de la bibliothèque standard Rust est très sommaire et se restreint à la réponse à une seule question : est-ce que les flux standard sont, ou non, connectés à un terminal ?
Il est important de connaître la réponse à cette question pour deux raisons :
- Sous Windows, quand ces flux sont connectés à un terminal, ils ne peuvent échanger que du texte. Tenter d’y écrire des octets arbitraires déclenchera une erreur d’entrée/sortie. C’est donc une pratique non portable que l’on doit éviter.
- Tous les terminaux usuels supportent des fonctionnalités plus complexes que les entrées/sorties texte simple : coloration du texte, modification d’un texte déjà émis, gestion de la souris… La façon d’utiliser ces fonctionnalités dépend de l’OS utilisé, mais dans tous les cas, une application qui les utilise doit gérer correctement le cas où les entrées/sorties standard ne sont pas connectées à un terminal, mais redirigées vers un fichier.
La bibliothèque standard Rust fournit donc un trait
IsTerminal
,
implémenté par les flux standard ainsi que par le type File
et ses équivalents
spécifiques à chaque système d’exploitation. Il permet de répondre à cette
question importante, à la manière du
isatty()
de POSIX.
#![allow(unused)] fn main() { use std::io::IsTerminal; println!("stdin est un terminal : {}", std::io::stdin().is_terminal()); println!("stdout est un terminal : {}", std::io::stdout().is_terminal()); println!("stderr est un terminal : {}", std::io::stderr().is_terminal()); }
Pour toute utilisation plus complexe d’un terminal (coloration du texte,
formatage, etc), on se tournera vers des bibliothèques dédiées comme
termion
(facile, mais spécifique au
monde Unix) et
crossterm
(supporte aussi
Windows, mais plus complexe à cause de l’abstraction ajoutée).
Synchronisation entre threads
Les flux d’entrée/sortie standard sont des ressources globales qui peuvent être
utilisées par tous les threads. Il faut donc gérer la possibilité d’accès
concurrents. Rust le gère comme le C : toutes les opérations sur ces flux
exposées par la bibliothèque standard sont implémentées en verrouillant un
Mutex
global, en effectuant l’opération, puis en relâchant le Mutex
.
Cette politique par défaut a deux inconvénients :
- Si on fait de nombreuses opérations sur les entrées/sorties standard, le coût
en performances associé à la manipulation du
Mutex
global peut devenir important. - Parfois la granularité de synchronisation par défaut est trop faible, et on
voudrait pouvoir acquérir le
Mutex
pour une période plus prolongée afin d’éviter les collisions entre threads.
Pour cette raison, les types
Stdin
,
Stdout
et
Stderr
retournés par les fonctions stdin()
, stdout()
et stderr()
fournissent tous
une méthode lock()
qui permet d’acquérir un contrôle exclusif temporaire du
flux d’entrée/sortie associé.
Ces fonctions fonctionnent un peu comme un Mutex
, mais sans poisoning :
elles retournent directement un type
StdinLock
,
StdoutLock
ou
StderrLock
qui donne l’accès exclusif au flux, et rétablissent la sémantique normal d’accès
partagé quand ils sortent du scope :
#![allow(unused)] fn main() { use std::io::prelude::*; // Exemple d'utilisation d'une fonction pour alléger la gestion des erreurs fn rataxes() -> std::io::Result<()> { // Acquisition du contrôle exclusif de stdout let mut stdout = std::io::stdout().lock(); // Aucun autre thread ne peut afficher du texte tant que je détiens le // StdoutLock, donc ces lignes de texte resteront groupées dans la sortie. writeln!(&mut stdout, "Vive Rataxès !")?; writeln!(&mut stdout, "Notre seul maître !")?; writeln!(&mut stdout, "Le roi du mooooonde !") } rataxes().expect("Echec d'écriture"); // Les autres threads peuvent à nouveau écrire sur stdout ici }
Il faut cependant être prudent quand on utilise cette fonctionnalité, car il
existe un risque de deadlock. Par exemple, on ne doit pas utiliser
println!()
lorsqu’on possède une StdoutLock
, car l’implémentation de
println!()
tenterait implicitement d’acquérir un deuxième exemplaire de la
StdoutLock
, ce qui causerait un bloquage permanent du thread actif (et à
terme de tous les threads qui essaient d’utiliser stdout
à leur tour).
L’utilisation de StdinLock
a l’avantage supplémentaire de donner accès au
tampon global d’entrée du programme, ce qui permet d’avoir accès à l’interface
BufRead
là où
Stdin
n’expose qu’une interface
Read
. Cependant,
dans le cas courant où on souhaite un accès à ce tampon global pour lire des
lignes de texte depuis l’entrée standard, on peut aussi utiliser directement les
raccourcis Stdin::read_line()
et Stdin::lines()
prévus à cet effet :
#![allow(unused)] fn main() { for ligne in std::io::stdin().lines() { println!( "Lu une ligne de stdin : {}", ligne.expect("Echec de lecture depuis stdin") ); } }
Réseau
Pour le meilleur et pour le pire, nous vivons dans un monde connecté à l’extrême où même les petits appareils électroménagers proposent des fonctionnalités basées sur Internet. Pourtant, ni C ni C++ n’offrent un moyen standardisé de communiquer en réseau : dans ces deux langages, on est encore condamné à utiliser des APIs spécifiques à chaque système d’exploitation pour ça.
Rust, en bon citoyen du monde moderne, fournit dans le module
std::net
de sa bibliothèque
standard ce qu’il faut pour répondre au besoin le plus courant : résoudre des
noms d’hôtes et communiquer avec les protocoles TCP et UDP sur des réseaux IPv4
et IPv6.
Adressses IP
Sur le principe, une addresse IP pourrait être représentée au niveau du langage
comme un simple tableau d’octets : 4 octets pour IPv4, 16 octets pour IPv6. Mais
Rust choisit d’utiliser à la place des types spécifiques
Ipv4Addr
et
Ipv6Addr
car cela
permet…
- De renforcer la discipline de typage : on ne peut pas utiliser accidentellement un tableau d’octets qui n’a rien à voir avec une adresse IP comme adresse IP.
- D’exposer facilement les adresses spéciales
LOCALHOST
(127.0.0.1
et::1
),UNSPECIFIED
(0.0.0.0
et::
) etBROADCAST
(255.255.255.255
, en IPv4 seulement). - D’exposer facilement des fonctions de classification comme
is_link_local()
. - De supporter facilement de nombreuses conversions : entre tableaux d’octets, chaîne de caractères et adresses IP; entre adresses IPv4 et IPv6, entre adresses IPv6 et tableaux de segments 16-bits…
Voici un exemple d’utilisation de ces types :
#![allow(unused)] fn main() { use std::net::Ipv4Addr; assert_eq!("192.168.0.1".parse(), Ok(Ipv4Addr::from([192, 168, 0, 1]))); assert_eq!(Ipv4Addr::LOCALHOST.to_string(), "127.0.0.1"); assert!(Ipv4Addr::BROADCAST.is_broadcast()); println!("localhost IPv4 -> {} IPv6", Ipv4Addr::LOCALHOST.to_ipv6_mapped()); }
Par ailleurs, alors que le déploiement de IPv6 continue de se poursuivre dans la
douleur, on doit de plus en plus souvent jongler entre adresses IPv4 et IPv6.
Rust fournit donc le type énuméré
IpAddr
, qui peut être
construit à partir d’une adresse IPv4 ou IPv6 (sous leurs diverses formes) et
fournit une couche d’abstraction simple pour manipuler ces deux types
d’adresses de façon homogène :
#![allow(unused)] fn main() { use std::net::{IpAddr, Ipv6Addr}; let addr = IpAddr::from(Ipv6Addr::LOCALHOST); assert!(addr.is_loopback()); }
Ports réseaux
Il est courant de vouloir exposer plusieurs services réseaux sur un serveur unique. On utilise pour ça le vénérable système des numéros de ports 16-bits de TCP et UDP.
Comme un numéro de port ne veut rien dire pris isolément (son interprétation
dépend du serveur cible), Rust n’expose pas de type port dédié, mais des types
SocketAddrV4
,
SocketAddrV6
et
SocketAddr
qui
représentent la combinaison d’une adresse IP et d’un port :
#![allow(unused)] fn main() { use std::net::{SocketAddr, SocketAddrV4, Ipv4Addr, Ipv6Addr}; // Décodage d'une paire (ip, port) en format textuel assert_eq!("127.0.0.1:80".parse(), Ok(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 80))); // Conversion d'une paire (ip, port) en addresse de socket générique let socket = SocketAddr::new(Ipv6Addr::LOCALHOST.into(), 443); // Affichage de l'adresse générique et ses composantes println!("{socket} -> ip {}, port {}", socket.ip(), socket.port()); }
Résolution DNS
Les adresses IP sont faciles à manipuler pour les ordinateurs, mais difficiles à mémoriser pour les humains. On a donc inventé le Domain Name System (DNS), un énorme annuaire mondial qui associe des noms textuels plus mémorables aux adresses IP des serveurs.
Mais avec le DNS est aussi venue une problématique de sécurité : protéger le
service de résolution de nom des tentatives d’usurpation d’identité. En effet,
si le premier script kiddie
venu pouvait associer des noms de domaines réputés comme mail.google.com
à
l’adresse IP d’un serveur qu’il contrôle, les conséquences pour la sécurité des
services web seraient catastrophiques.
Pour éviter ce genre d’incident, il est important que les paramètres DNS soient gérés de façon centralisée au niveau du système d’exploitation, et que les applications s’en remettent toutes aux APIs de l’OS pour résoudre des noms de domaine. La bibliothèque standard Rust expose donc, de façon standardisée, la fonctionnalités de résolution de nom de domaine de l’OS sous-jacent.
L’interface actuellement fournie sur cette fonctionnalité est minimaliste et
basée sur le trait
ToSocketAddrs
.
Parmi les
implémentations, on trouve…
- Un tuple (adresse IP, port), où l’adresse IP peut être donnée aux formats
IpAddr
,Ipv4Addr
,Ipv6Addr
et textuels (suivant le standard IETF RFC 6943). - Une adresse de socket combinée
SocketAddr
,SocketAddrV4
ouSocketAddrV6
, ou sa représentation textuelle standardisée. - Un tuple (nom d’hôte, port), où le nom d’hôte sera résolu par une requête DNS.
- Une chaîne de caractères au format
<nom d'hôte>:<port>
usuel.
Il n’aura pas échappé au lecteur attentif qu’un nom d’hôte peut correspondre à
plusieurs adresses IP (typiquement une adresse IPv4 et une adresse IPv6). C’est
pourquoi la conversion ToSocketAddrs
produit en sortie un itérateur d’adresse
IP, et pas une adresse IP unique. Si on n’a pas besoin de résolution de nom
d’hôte, on peut également passer plusieurs SocketAddr
en entrée de la
conversion
ToSocketAddrs
,
et elles seront ré-émises en sortie.
#![allow(unused)] fn main() { // Cet exemple ne peut pas être exécuté sur le Rust Playground en raison des // restrictions réseau appliquées aux machines virtuelles utilisées. use std::net::ToSocketAddrs; const CIBLE: &str = "duckduckgo.com:443"; let addrs = CIBLE.to_socket_addrs().expect("Echec de la résolution DNS"); println!("Résultats de la résolution de {CIBLE} :"); for addr in addrs { println!("- {addr}"); } }
Les constructeurs de sockets TCP et UDP acceptent en paramètre l’ensemble des
types qui implémentent ToSocketAddrs
, ce qui offre une grande flexibilité
pour l’utilisateur. Lorsque l’implémentation ToSocketAddrs
produit plusieurs
SocketAddr
, elles seront essayées les unes après les autres par le
constructeur de socket jusqu’à en trouver une qui fonctionne.
Connexions TCP
L’API TCP de la bibliothèque standard Rust se compose de deux types
TcpListener
et
TcpStream
. Le
premier permet d’attendre des connexions entrantes, le deuxième représente une
connexion active via laquelle on peut échanger des données. Dans l’ensemble,
l’API est assez similaire à celle des sockets POSIX et les utilisateurs
de ces derniers devraient s’y retrouver facilement.
Pour accepter des connections entrantes, on commence par créer un TcpListener
en donnant une adresse d’écoute et un numéro de port à écouter. Le numéro de
port peut être 0, dans ce cas le système d’exploitation attribue automatiquement
un port accessible et non utilisé :
#![allow(unused)] fn main() { use std::net::TcpListener; // Activation de l'écoute TCP let ecoute = TcpListener::bind(("localhost", 0)) .expect("Echec de création du TcpListener"); // Récupération de l'adresse et du port utilisé let socket = ecoute.local_addr() .expect("Echec de récupération de l'adresse locale"); println!("Prêt à recevoir du trafic sur {socket}"); }
Ensuite, on peut commencer à attendre des clients, soit un par un via la
méthode
accept()
,
soit indéfiniment via l’itérateur de connexions
incoming()
:
#![allow(unused)] fn main() { use std::net::TcpListener; let ecoute = TcpListener::bind(("localhost", 0)) .expect("Echec de création du TcpListener"); for connexion in ecoute.incoming() { let connexion = connexion.expect("Echec de l'établissement de la connexion"); println!( "Connexion TCP établie avec {}", connexion.peer_addr() .expect("Echec de récupération de l'adresse distante") ); } }
Parfois, il n’est pas acceptable d’attendre indéfiniment l’arrivée d’une
connexion entrante. Dans ce cas, on peut configurer le TcpListener
en mode
non bloquant avec la méthode
set_nonblocking()
.
Dans ce mode, si il n’y a pas de connexion entrante, les appels à accept()
échoueront immédiatement avec une erreur
WouldBlock
:
#![allow(unused)] fn main() { use std::net::TcpListener; let ecoute = TcpListener::bind(("localhost", 0)) .expect("Echec de création du TcpListener"); // Activation du mode non bloquant ecoute.set_nonblocking(true) .expect("Echec d'activation du mode non bloquant"); // Tentative de récupération de connexion entrante println!( "Résultat de accept() : {:?}", ecoute.accept() ); }
Mais à l’heure actuelle, Rust ne fournit pas d’équivalent standard aux
primitives pour attendre des connexions sur N sockets différents comme
epoll()
sous Linux
et kqueue()
sous BSD. Il faut encore
utiliser les APIs spécifiques à chaque OS, ou des bibliothèques basées sur ces
dernières.
Les connexions entrantes sont représentées par le type
TcpStream
, qui
peut aussi être construit directement pour établir une connexion sortante avec
la méthode
connect()
:
#![allow(unused)] fn main() { use std::net::{Ipv4Addr, TcpStream}; // Echouera sur le Rust Playground pour des raisons de politique réseau let connexion = TcpStream::connect(("duckduckgo.com", 443)); println!("Résultat de la tentative de connexion : {connexion:?}"); }
TCP offre une abstraction de flux d’octets continu, donc TcpStream
implémente
Read
et Write
et peut être utilisé comme n’importe quel autre flux
d’octets implémentant ces traits. Mais les personnes familières avec la
programmation réseau savent que ce n’est pas toujours suffisant :
- En cas de problème de connexion, le programme peut se retrouver à attendre indéfiniment l’arrivée d’octets entrants ou le départ d’octets sortants, ce qui n’est pas toujours un comportement acceptable pour l’utilisateur.
- En communication réseau, il existe un compromis entre débit et latence. Pour maximiser le débit, il faut mettre les données sortantes en mémoire tampon et attendre qu’il y ait un volume suffisant avant de les envoyer véritablement sur le réseau. Alors que pour minimiser la latence, on peut parfois être amené à préférer envoyer les données sortantes dès que possible.
Par conséquent, TcpStream
permet entre autres…
- D’abandonner une tentative de connexion au bout d’un certain temps d’attente
en utilisant une variante de
connect()
appeléeTcpStream::connect_timeout()
. - De fixer une limite de temps d’attente lors de l’envoi et de la réception de
données avec
set_read_timeout()
etset_write_timeout()
, ou de rendre l’envoi et la réception complètement non bloquants avecset_nonblocking()
. - De contrôler le compromis débit/latence au niveau noyau avec
set_nodelay()
.
Sockets UDP
Le protocole TCP est facile à utiliser, car il permet de dissimuler la complexité des échanges réseau derrière une abstraction simple de flux d’octets. Mais cette abstraction a un coût. Comme la couche IP sous-jacente est non fiable et désordonnée, le trafic TCP doit être rendu fiable via un système d’accusés de réception et réordonné via un système de numérotation et buffering complexe.
Pour certains types de services réseau, les coûts associés à TCP sont
inacceptables. Il faut alors travailler à niveau d’abstraction inférieur avec le
protocole UDP, qui expose la non-fiabilité et le caractère désordonné de la
couche IP à l’utilisateur. En Rust, on utilise pour cela le type
UdpSocket
.
On se prépare à accepter des paquets UDP entrants avec la méthode
UdpSocket::bind()
,
qui ressemble à TcpListener::bind()
dans sa signature. Mais une fois le
socket UDP créé, son comportement sera différent de celui de TcpListener
.
En effet, en UDP, il n’y a pas de notion de connexion, juste des paquets reçus
depuis différentes adresses sources. Donc on n’a pas d’équivalent des méthodes
accept()
et incoming()
de TcpListener. A la place, on utilise
recv_from()
ou
peek_from()
avec un tampon suffisamment gros (la taille de paquet maximum
doit être négociée avec l’hôte distant et le réseau intermédiaire) pour recevoir
des paquets entrants et obtenir l’adresse source associée au passage :
#![allow(unused)] fn main() { use std::net::UdpSocket; // Début de l'écoute UDP let socket = UdpSocket::bind(("localhost", 0)) .expect("Echec de la création du UdpSocket"); // Réception d'un paquet let mut buf = [0; 9000]; // Assez grand pour des jumbo frames typiques let (taille, source) = socket.recv_from(&mut buf[..]) .expect("Echec de la réception d'un paquet"); let paquet = &buf[..taille]; // Affichage du contenu du paquet println!("Reçu de {source} : {paquet:02x?}"); }
Une fois un socket UDP créé, on peut aussi prendre l’initiative d’envoyer des
paquets à un hôte distant. Il est possible de le faire directement avec
send_to()
…
#![allow(unused)] fn main() { use std::net::UdpSocket; let socket = UdpSocket::bind(("localhost", 0)) .expect("Echec de la création du UdpSocket"); // Emission d'un paquet (échouera sur le Rust Playground) const TEXTE: &str = "GET / HTTP/1.1\nHost: www.perdu.com"; let resultat = socket.send_to(TEXTE.as_bytes(), ("www.perdu.com", 80)); println!("Resultat de l'émission d'un paquet : {resultat:?}"); }
…mais si on a l’intention d’échanger de nombreux paquets avec un même hôte
distant, ce n’est pas la façon la plus efficace de procéder. A la place, mieux
vaut enregistrer les paramètres de l’hôte distant avec la méthode
connect()
,
ce qui donne accès à des méthodes
send()
et
recv()
et
peek()
où l’hôte distant est implicite. Ainsi, cet exemple de code est équivalent au
précédent :
#![allow(unused)] fn main() { use std::net::UdpSocket; let socket = UdpSocket::bind(("localhost", 0)) .expect("Echec de la création du UdpSocket"); const TEXTE: &str = "GET / HTTP/1.1\nHost: www.perdu.com"; // Enregistrement de l'hôte distant (échouera sur le Rust Playground) let resultat = socket.connect(("www.perdu.com", 80)); println!("Résultat de la connexion : {resultat:?}"); // Envoi d'un paquet if let Ok(()) = resultat { let resultat = socket.send(TEXTE.as_bytes()); println!("Resultat de l'émission d'un paquet : {resultat:?}"); } }
Attention, UdpSocket::connect()
n’est pas équivalent à TcpStream::connect()
:
puisqu’il n’y a pas de connexions au niveau du protocole UDP, on ne peut pas
vérifier qu’il y a bien un serveur à l’adresse cible. Donc si on fournit
plusieurs adresses de destination, connect()
va sélectionner la première
adresse qui est joignable avec la configuration réseau active (même réseau ou
route IPv4/IPv6 connue vers le réseau cible), et ne vérifiera pas si il y a
réellement un serveur qui écoute.
Comme avec TcpStream
, on peut configurer des timeouts d’envoi et réception
et des entrées/sorties non bloquantes. Mais on ne retrouve pas les notions de
timeout de connexion et d’envoi immédiat, qui n’ont de sens qu’en TCP. En
revanche, on trouve de nouvelles fonctions permettant de contrôler les
paramètres broadcast
et
multicast, ce qui n’a de sens qu’en
UDP.
Couches supérieures
C’est très bien d’avoir accès au DNS, à TCP et à UDP dans la bibliothèque standard de son langage de programmation. Mais pour la plupart des applications, ce n’est pas suffisant. On va généralement avoir aussi besoin d’implémentations de protocoles de plus haut niveau basés sur TCP et UDP, par exemple le protocole HTTP des sites web et APIs REST et sa couche de sécurité TLS.
A l’heure actuelle, la bibliothèque standard Rust ne supporte pas directement de tels protocoles, et leur implémentation est déléguée à des bibliothèques tierces telles que…
hyper
pour l’utilisation directe de HTTP, et son extensionhyper-tls
pour le TLS.reqwest
pour un client HTTP de plus haut niveau.- De très (trop ?) nombreux frameworks serveur, tels que
axum
etactix
.
Beaucoup de ces bibliothèques utilisent des communications asynchrones. Nous donnerons donc un exemple qui les utilise dans le chapitre associé.
Environnement
Notre tour des fonctionnalités de programmation système de Rust va se terminer
vers le module std::env
.
Comme vous pouvez le deviner, il permet notamment de manipuler des variables
d’environnement. Mais on y trouve aussi d’autres spécificités des systèmes
d’exploitation sur lesquelles un programme Rust est susceptible de s’exécuter,
qui n’ont pas trouvé leur place ailleurs : arguments d’un exécutable,
répertoire de travail, etc.
Variables d’environnement
En C++, le seul utilitaire standard disponible pour interroger l’environnement
est std::getenv()
,
une fonction qui prend un nom de variable d’environnement en paramètre et
retourne un pointeur vers la valeur de la variable en sortie. Si la variable
n’a pas de valeur, le pointeur retourné est nul.
Cette conception a plusieurs conséquences :
- On n’a pas accès à la liste complète des variables d’environnement. Il faut
utiliser pour ça des utilitaires spécifiques à chaque OS, comme la variable
globale
environ
et le paramètre optionelenvp
sur les systèmes POSIX. - On ne peut pas modifier une variable d’environnement de façon portable. Il
faut utiliser pour ça des utilitaires spécifiques à chaque OS, comme la
fonction
setenv()
de POSIX. - L’une des utilisations les plus courantes des variables d’environnement est
la famille des variables
PATH
. Mais la bibliothèque standard C++ ne fournit aucun utilitaire pour les manipuler d’une façon indépendante du système d’exploitation. - La fonction
getenv()
ne peut pas être utilisée de façon sécurisée dans un programme multi-thread, car rien n’empêche un autre thread d’appeler des fonctions commesetenv()
en parallèle, et la conception degetenv()
ne permet pas de synchroniser implicitement les accès aux variables d’environnement au niveau de l’implémentation de la bibliothèque standard.
En comparaison, l’API fournie par Rust pour manipuler des variables d’environnement est à la fois plus complète et plus sécurisée :
- On peut lister les variables d’environnement avec
vars()
, lire une variable unique avecvar()
, modifier une variable avecset_var()
, et supprimer une variable avecremove_var()
. - Par défaut, les fonctions ci-dessus supposent que les variables
d’environnement contiennent du texte Unicode valide, tentent de le décoder en
UTF-8, et retournent une erreur si ça échoue. On peut accéder aux données
brutes des variables d’environnement avec des variantes des fonctions de
lecture ayant un suffixe
_os
, commevars_os()
. - Les fonctions
split_paths()
etjoin_paths()
permettent de manipuler une variable d’environnementPATH
de façon indépendante du système d’exploitation. - Les accès aux variables d’environnement par la bibliothèque standard Rust sont synchronisés entre threads. Le comportement indéfini observé en C++ n’est malheureusement pas complètement éliminé, mais n’est possible que si les threads utilisent directement les fonctions de l’OS sans passer par la bibliothèque standard Rust, ce qui réduit le risque d’accident.
Voici un exemple de manipulation de variables d’environnements en Rust :
#![allow(unused)] fn main() { println!("Mes variables d'environnement :"); for (cle, valeur) in std::env::vars() { print!("- {cle} : "); if cle.ends_with("PATH") { let chemins = std::env::split_paths(&valeur).collect::<Vec<_>>(); println!("{chemins:#?}"); } else { println!("{valeur:?}"); } } }
Arguments et chemin d’exécutable
En C++, la manière normale d’accéder aux arguments du programme est d’utiliser
les arguments spéciaux argc
et argv
de la fonction main()
, qui contiennent
respectivement le nombre d’arguments et un pointeur vers un char**
contenant
les valeurs des arguments. L’implémentation de la bibliothèque standard C
peut mettre un pointeur nul en premier argument, si elle ne le fait pas elle
est censée y mettre le nom du programme.
En résumé…
- On doit manipuler un tableau C, avec le risque habituel de se tromper sur l’itération et de lire en-dehors des bornes du tableau.
- On doit gérer la possibilité qu’il y ait des pointeurs nuls dans la liste des arguments.
- Seul l’exécutable peut avoir accès à la liste des arguments. Une bibliothèque
ne peut y avoir accès que si on lui transmet
argc
etargv
ou utilise des extensions spécifiques à un OS. - La liste des arguments est modifiable, et on ne peut pas en dépendre dans du
code opérant sous contraintes de sécurité. Par exemple, si un programme
setuid veut exécuter récursivement une copie de lui-même, il ne peut pas
utiliser
argv[0]
pour savoir comment il s’appelle, car c’est une donnée sous le contrôle de l’attaquant qui appelle le programme. - Plus généralement, le premier argument est difficile à interpréter car…
- Il peut contenir un chemin absolu ou relatif.
- Si le programme a été invoqué via un symlink, il peut contenir le nom du symlink ou le nom du programme (donc celui de la cible du symlink).
- Si le programme est renommé, il ne correspondra plus au réel chemin vers le programme sur le système de fichiers.
La bibliothèque standard Rust ne peut pas corriger l’ensemble de ces problèmes,
car certains se situent au niveau du contrat d’interface de base entre la libc
et les programmes. Mais elle en corrige autant que possible :
- On itère sur les arguments via l’itérateur haut niveau
args()
ou sa variante bas niveauargs_os()
, la nuance étant identique à celle entrevars()
etvars_os()
. - Les pointeurs nuls sont gérés par l’implémentation de la bibliothèque
standard, le programme Rust ne voit que des
String
(éventuellement vides). - Les arguments sont accessibles en tous points du programme et ne peuvent pas être modifiés via la bibliothèque standard.
- Les risques liés à l’interprétation du premier argument sont documentés, et
une fonction dédiée
current_exe()
est disponible pour tenter de déterminer le chemin absolu vers l’exécutable du programme (même si on ne peut toujours pas en dépendre dans du code opérant sous contraintes de sécurité).
Voici un exemple d’utilisation :
#![allow(unused)] fn main() { print!("Commande utilisée, selon la libc : "); for arg in std::env::args() { print!("{arg} "); } println!(); println!("Chemin d'exécutable, selon la libc : {:?}", std::env::current_exe()); }
Répertoires spéciaux
Jusqu’en C++17, il n’y avait pas de manière standard de lire et modifier le répertoire de travail du processus actif en C++, ni de connaître le chemin du répertoire standard des fichiers temporaires.
Dès sa première version stable en 2015, Rust a résolu ces problèmes avec les
fonctions
current_dir()
,
set_current_dir()
et
temp_dir()
de
std::env
. En 2017, C++ a rattrapé ce retard en intégrant des fonctions
similaires à sa nouvelle API std::filesystem
.
#![allow(unused)] fn main() { println!("Répertoire de travail : {:?}", std::env::current_dir()); println!("Répertoire temporaire : {:?}", std::env::temp_dir()); }
Au moment de la sortie de Rust v1, ça avait aussi semblé être une bonne idée de
fournir l’emplacement du répertoire de travail de l’utilisateur avec
home_dir()
. Mais un
examen plus approfondi survenu après la stabilisation de Rust a révélé que la
méthode utilisée pour obtenir cet emplacement était moins fiable que prévue
sous Windows (notamment en présence de couches d’émulation Unix comme MinGW et
Cygwin), et qu’il n’existait pas de méthode fiable sur cet OS.
La fonction home_dir()
est donc aujourd’hui dépréciée et ne devrait plus être
utilisée. A la place, il est recommandé de lui préférer des bibliothèques
dédiées comme dirs
.
Constantes diverses
Le module std::env
contient enfin un sous-module std::env::consts
, qui
contient quelques constantes spécifiques à la plate-forme cible :
ARCH
,FAMILY
etOS
identifient la plate-forme cible en suivant la même syntaxe que les options de configurationcfg!()
(voir le chapitre dédié pour plus d’informations). Cela permet de gérer ces options de configuration à l’exécution plutôt qu’à la compilation.EXE_SUFFIX
etEXE_EXTENSION
indiquent la manière standard de terminer un nom d’exécutable sur l’OS cible, avec ou sans le.
d’extension.- De même,
DLL_PREFIX
,DLL_SUFFIX
etDLL_EXTENSION
indiquent la manière standard de commencer et terminer un nom de bibliothèque partagée sur l’OS cible.
#![allow(unused)] fn main() { println!("Compilé pour les CPUs {} et l'OS {} (famille {})", std::env::consts::ARCH, std::env::consts::OS, std::env::consts::FAMILY); println!("Exemple de nom d'exécutable : echo{}", std::env::consts::EXE_SUFFIX); println!("Exemple de nom de bibliothèque : {}SDL{}", std::env::consts::DLL_PREFIX, std::env::consts::DLL_SUFFIX); }
Passage à l’échelle
Les fonctionnalités de Rust que nous avons vu jusqu’à présent suffisent pour écrire des programmes simples. Mais dès qu’un programme gagne en complexité, on a vite besoin d’outils supplémentaires pour organiser le code à grande échelle, encapsuler les détails d’implémentation, éviter d’écrire du code redondant, et utiliser des bibliothèques tierces partie. C’est l’objet de ce chapitre.
Modules
Introduction
Jusqu’à présent, nos exemples tenaient bien en un seul fichier de code Rust. Mais avec l’introduction du parallélisme dans le chapitre sur les threads, on se rapprochait de la limite du raisonnable.
Il est donc temps d’aborder le système de modules de Rust, qui sert à…
- Grouper nos déclarations en ensembles logiques
- Encapsuler les détails d’implémentation des fonctionnalités
En revanche contrairement à ce qui se passe avec les fichiers source C++, un module Rust n’est pas une unité de compilation. Ca, c’est la fonction des crates, que nous allons aborder un peu plus tard.
Cela signifie que la façon dont nous décidons d’organiser notre code en modules n’affecte pas les décisions d’optimisation du compilateur. Nous n’avons donc besoin…
- Ni de choisir entre organisation logique et performances d’exécution
- Ni de tout fourrer dans une seule unité de compilation, ce qui ne passe pas à l’échelle dans les gros programmes (pas de parallélisation dans GCC et clang actuellement, et le temps de compilation et la consommation mémoire explosent vite avec la combinatoire).
- Ni d’avoir un recours systématique à l’optimisation à l’édition de liens, qui impacte fortement le temps de compilation avec une efficacité aléatoire.
Tout ceci est un gros point positif par rapport au modèle de compilation du C++.
En revanche, il y a un prix à payer, qui est qu’un programme Rust idiomatique a beaucoup moins d’unités de compilation qu’un programme C++ classique, et peut donc moins paralléliser la compilation en distribuant le travail entre unités de compilation. La compilation parallèle de code Rust doit donc utiliser des mécanismes plus sophistiqués de parallélisation automatique à l’intérieur d’une unité de compilation. Mais heureusement, le compilateur sait plutôt bien faire ça pour vous, et la plupart du temps vous n’aurez pas à vous préoccuper de ce détail d’implémentation.
Création d’un module
On peut d’abord créer un module directement au sein d’un fichier de code. C’est très pratique quand on fait de la compilation conditionnelle, pour grouper les différentes déclarations qui dépendent d’une même condition. C’est par ailleurs aussi utile pour contourner les limite techniques des exemples exécutables de ce cours, qui ne peuvent contenir qu’un fichier de code unique :
#![allow(unused)] fn main() { mod module { /* ... déclarations ... */ } }
En dehors des cas particuliers mentionnés ci-dessus, la façon normale de créer
un module est d’ajouter une déclaration mod module;
puis…
- Soit créer un fichier
module.rs
qui contient l’implémentation du module. - Soit créer un dossier “module”, contenant un fichier
mod.rs
qui contient lui-même l’implémentation du module.
La deuxième manière de faire sert lorsqu’on veut créer des sous-modules au sein des modules que nous avons créé. Il est facile, et usuel quand la complexité du code croît au fil du temps, de passer de la première à la seconde configuration au moment où le besoin s’en fait sentir.
Contrairement aux modules C++20, il y a un lien simple entre noms de modules et hiérarchie du système de fichiers. Cela clarifie le code, et surtout simplifie énormément l’implémentation du compilateur : contrairement à l’implémentation des modules C++20 de GCC, le compilateur Rust n’a pas besoin d’exécuter un serveur IPv6 parlant un protocole maison en tâche de fond pour savoir à quel nom de fichier correspond un nom de module dans le code…
Visibilité
Par défaut, toutes les déclarations d’un module sont privées et ne peuvent pas être utilisées de l’extérieur, donc ce code ne compile pas :
#![allow(unused)] fn main() { mod module { static VALEUR: u32 = 42; } println!("{}", module::VALEUR); }
Pour qu’il compile, il faut rendre la déclaration publique avec le mot-clé pub
:
#![allow(unused)] fn main() { mod module { pub static VALEUR: u32 = 42; } println!("{}", module::VALEUR); }
Cela ne concerne pas que les entités directement déclarées au sein du module,
mais aussi leur structure interne, comme les membres de struct
:
#![allow(unused)] fn main() { mod module { pub struct S { pub x: u32, y: f32, } impl S { pub fn new() -> S { S { x: 42, y: 2.4 } } } } let s = module::S::new(); // Ok, constructeur public println!("{}", s.x); // Ok, membre public /* println!("{}", s.y); */ // Erreur, membre privé }
Notez qu’il n’est pas nécessaire que le module soit public pour qu’on puisse
accéder à son contenu. Tout ce qui est défini au sein du module actif est
visible, public ou pas, y compris les sous-modules. Le mot-clé pub
n’affecte
que la visibilité depuis l’extérieur du module.
Il est possible de nuancer une déclaration de visibilité avec des variantes du
pub
comme…
pub(super)
, qui ne rend une déclaration visible qu’au sein du module parent.pub(crate)
, qui rend une déclaration visible au sein de la crate (~bibliothèque) active, mais pas pour les clients extérieurs à la crate.pub(in <chemin>)
, qui rend une déclaration visible dans un module bien précis du programme, à l’exclusion de tous les autres.
Cependant, d’un point de vue de couplage entre vos modules de code, j’aurais tendance à dire que…
- L’idéal est d’avoir uniquement une distinction
pub
/non-pub
bien claire. pub(crate)
est un compromis acceptable pour des détails communs à l’ensemble du code d’une bibliothèque, comme l’interface FFI dans un binding.pub(super)
et surtoutpub(in)
sont suspects et doivent vous amener à vous poser des questions sur la qualité du découpage de votre code en modules indépendants.
Chemins et import
Dans les exemples ci-dessus, vous avez pu voir que les modules se comportent un
peu comme des namespaces en C++. On peut désigner une entité enfant du module
actif au sein de la hiérarchie des modules avec des chemins relatifs du style
chemin::vers::<entité>
:
#![allow(unused)] fn main() { mod A { pub mod B { pub mod C { pub static VALEUR: u32 = 42; } } } println!("{}", A::B::C::VALEUR); }
…et on peut importer des entité au sein du scope actuel avec la syntaxe
use chemin::vers::<entité>
, qui peut prendre plusieurs noms d’entité en
paramètre pour plus de concision :
#![allow(unused)] fn main() { mod A { pub mod B { pub mod C { pub static X: u32 = 42; pub static Y: u32 = 24; } } } use A::B::C::{X, Y}; println!("{X} {Y}"); }
Toutes les bibliothèques (crates) dont dépend le programme sont également
visibles depuis tous les modules du programme. C’est pourquoi nous avons pu
faire des use std::xyz
depuis le début.
Il n’aura pas échappé aux personnes attentives qu’il peut donc exister une collision entre le nom d’une entité déclarée au sein du programme actif et le nom d’une bibliothèque. Il existe des syntaxes pour contourner ces collisions, mais pour garder le code lisible, je vous encourage fortement à ne pas vous en servir. Evitez de créer volontairement des collisions, et résolvez-les en renommant l’entité sous votre contrôle si ça se produit après coup.
Pour conclure, au sein d’un sous-module, on peut aussi désigner le module parent
par super::
et la racine du code source de la crate active avec
crate::
:
mod A { pub mod B { pub mod C { pub static X: u32 = super::Y; } pub static Y: u32 = crate::Z; } } pub const Z: u32 = 42; fn main() { use A::B::{Y, C::X}; println!("{X} {Y} {Z}"); }
Traits et généricité
Après un long chemin, nous en arrivons enfin au point où nous pouvons aborder confortablement la généricité en Rust et son ingrédient essentiel, le trait.
Introduction
Une bonne première approche des traits en Rust, c’est de se rappeler l’utilisation de base d’une interface en Java. Comme une interface Java, un trait Rust peut notamment…
- Définir un certains nombres de méthodes qu’un type doit implémenter quand il implémente le trait, ainsi que la sémantique que l’utilisateur de ces méthodes peut en attendre.
- Demander par “héritage” que le type implémente d’autres traits, dont la définition du trait et ses utilisateurs pourront supposer la présence.
…mais cette analogie connaît ses limites, car l’interface définie par un trait Rust ne s’arrête pas à des méthodes, et peut contenir d’autres choses comme des constantes associées et des types associées. Et les traits permettent du polymorphisme aussi bien à la compilation qu’à l’exécution.
Définissons, pour faire une première démonstration, un trait qui permet de découper des données d’un type quelconque en éléments plus simples.
#![allow(unused)] fn main() { /// Outil servant à découper les objets en petits morceaux trait Hachoir { /// Petit morceau de Self type Morceau; /// Découpe `self` en petits morceaux fn hacher(&self) -> Vec<Self::Morceau>; } }
Avec un nouveau type de bloc impl
dont la syntaxe est impl Trait for Type
,
on peut créer des implémentations du trait pour différents types, qui
respectent la sémantique spécifiée dans la définition du trait :
#![allow(unused)] fn main() { trait Hachoir { type Morceau; fn hacher(&self) -> Vec<Self::Morceau>; } impl Hachoir for &'_ str { type Morceau = char; fn hacher(&self) -> Vec<char> { self.chars().collect() } } impl Hachoir for usize { type Morceau = bool; fn hacher(&self) -> Vec<bool> { let mut acc = *self; (0..Self::BITS) .map(|x| { let bit = (acc & 1) != 0; acc >>= 1; bit }) .collect() } } }
Le fait que ces implémentations soient explicites permet d’éviter qu’un type
implémente accidentellement un trait alors qu’il ne devrait pas. Si les traits
étaient implémentés automatiquement pour tous les types ayant des méthodes qui
portent le bon nom, on pourrait facilement imaginer que Hachoir
soit
implémenté automatiquement pour un type préexistant avec une méthode hacher()
visant à implémenter une table de hachage par le biais d’une fonction de
hachage, ce qui n’a absolument rien à voir avec ce que nous essayons de faire
ici. Les traits évitent ce problème, contrairement aux concepts de C++20.
Une fois un trait implémenté, il devient possible pour tout code qui a le trait dans son scope d’utiliser les méthodes définies au sein du trait sur tous les types qui implémentent le trait :
#![allow(unused)] fn main() { trait Hachoir { type Morceau; fn hacher(&self) -> Vec<Self::Morceau>; } impl Hachoir for &'_ str { type Morceau = char; fn hacher(&self) -> Vec<char> { self.chars().collect() } } let morceaux = "bonjour".hacher(); println!("{morceaux:?}"); }
Comme on le voit, il est possible de créer des implémentations de ses traits pour tous les types, y compris des types d’autres bibliothèques comme la bibliothèque standard. On peut donc utiliser des traits pour ajouter des fonctionnalités à ces types tiers, du moment que ces fonctionnalités ne nécessitent pas l’ajout de champs de données supplémentaires.
On peut également créer des implémentations de traits d’autres bibliothèques pour ses propres types, et c’est notamment comme ça qu’on crée des types avec un comportement similaire à celui des types de la bibliothèque standard.
Mais en revanche, il n’est possible d’implémenter le trait d’une autre bibliothèque pour un type d’une autre bibliothèque que dans de rares cas. Les règles associées sont un peu complexes, mieux vaut commencer par se dire que ce n’est pas possible en première approximation, et raffiner sa compréhension plus tard après avoir un peu pratiqué les bases.
L’objectif de ces règles est d’éviter au maximum les situations d’incohérence où deux bibliothèques se retrouvent à implémenter un même trait pour un même type. En effet, dans ce cas, le compilateur ne sait pas quelle implémentation utiliser et doit rejeter le code. Cela nuit à l’interopérabilité entre bibliothèques, donc ça doit se produire aussi rarement que possible, et les règles du langage sont donc conçues pour que ça se produise rarement.
Généricité contrainte
En Rust, comme en C++, on peut définir des types et fonctions génériques, qui stockent et manipulent respectivement des valeurs de différents types.
#![allow(unused)] fn main() { /// Peut contenir des données de n'importe quel type struct Wrapper<T>(T); /// Accepte des données de n'importe quel type en entrée fn identite<T>(x: T) -> T { x } }
Mais contrairement aux templates de C++ et au duck typing de Python, la généricité est contrainte en Rust : nos fonctions génériques ne peuvent pas faire tout et n’importe quoi avec les données qu’on leur a passé en entrée sans prévenir l’utilisateur à l’avance.
Cela évite qu’un utilisateur qui les utilise avec des données d’un type non prévu par l’auteur du code générique soit récompensé de sa créativité par un message d’erreur incompréhensible quelque part dans les profondeurs de l’implémentation, au moment où une opération non supportée est utilisée.
Prenons un exemple de code qui ne peut pas compiler :
#![allow(unused)] fn main() { pub mod bidouilles { // API publique pub fn manipuler<T, U>(x: &mut T, y: U) { triturer(x, y); } // Détails d'implémentation sans intérêt pour l'utilisateur fn triturer<T, U>(x: &mut T, y: U) { transmogrifier(x, y); } fn transmogrifier<T, U>(x: &mut T, y: U) { *x -= y; } } // Utilisation incorrecte (cf implémentation de transmogrifier) let mut x = 123usize; bidouilles::manipuler(&mut x, "abc"); }
Si nous étions en C++, la compilation de ce code échouerait au moment de la
compilation du code utilisateur, avec un message d’erreur dans le détail
d’implémentation transmogrifier()
comme quoi usize
ne possède pas
d’opérateur -=
prenant un &str
en paramètre.
Le message d’erreur contiendrait aussi une backtrace indiquant que
transmogrifier()
est appelé par triturer()
, lui-même appelé par la fonction
manipuler()
que l’utilisateur invoque. L’utilisateur serait ensuite sensé
aller étudier ces différentes fonctions de l’implémentation de manipuler()
pour comprendre comment on en est arrivé à soustraire une chaîne de
caractères d’un entier, et essayer de déduire de l’implémentation et des choix
de nommages et commentaires plus ou moins clairs de l’auteur du code quels
types manipuler()
accepte vraiment en paramètre.
A la place, en Rust, c’est la définition de la fonction générique qui est considérée comme incorrecte. Une fonction générique doit spécifier tout ce qu’elle est susceptible de faire avec les données qu’on lui passe en entrée, via des contraintes sur les types appelées trait bounds…
#![allow(unused)] fn main() { use std::ops::SubAssign; // Une clause "where" est la syntaxe la plus flexible et verbeuse fn transmogrifier<T, U>(x: &mut T, y: U) // Interprétation compilateur: T doit implémenter le trait SubAssign<U> // Sens pour l'utilisateur: On peut soustraire U de T avec l'opérateur -= where T: SubAssign<U> { *x -= y; } // Ce raccourci syntaxique peut parfois être utilisé à la place de "where" fn transmogrifier2<U, T: SubAssign<U>>(x: &mut T, y: U) { *x -= y; } }
…en échange de quoi, l’appelant aura un message d’erreur beaucoup plus clair
indiquant que manipuler()
n’accepte pas des paires de types T et U sans
opérateur de soustraction. Ce message d’erreur ne fera pas
référence aux fonctions de l’implémentation de manipuler()
:
#![allow(unused)] fn main() { pub mod bidouilles { use std::ops::SubAssign; // API publique pub fn manipuler<U, T: SubAssign<U>>(x: &mut T, y: U) { triturer(x, y); } // Détails d'implémentation sans intérêt pour l'utilisateur fn triturer<U, T: SubAssign<U>>(x: &mut T, y: U) { transmogrifier(x, y); } fn transmogrifier<U, T: SubAssign<U>>(x: &mut T, y: U) { *x -= y; } } // Appel invalide let mut x = 123usize; bidouilles::manipuler(&mut x, "abc"); }
C’est ensuite à l’auteur du code générique de décider si il souhaite plutôt…
- Avoir un contrat d’interface très précis comme ci-dessus, qui permet
d’accepter le maximum de types en entrée au prix de contraintes fortes sur
l’implémentation (actuellement, changer l’opération utilisée en
+=
changerait la borne enT: AddAssign<U>
et donc briserait l’API publique en rejetant des appels auparavant valides avec un type T qui implémente SubAssign mais pas AddAssign). - Fixer un contrat sur-spécifié du style “le type d’entrée doit implémenter toutes les opérations arithmétiques de base”, ce qui réduira un peu le nombre de types acceptés en entrée mais donnera en contrepartie plus de liberté au niveau de l’évolution future de l’implémentation.
On peut comparer cette utilisation des traits à celle des concepts en C++20, qui vise à clarifier les contrats d’interfaces des types et fonctions génériques de la même façon. Mais il y a une différence majeure : en Rust, le compilateur ne vérifie pas seulement que l’utilisation des traits est correcte du côté du code utilisateur, il vérifie aussi qu’elle est correcte du côté de l’implémentation.
Cela garantit que l’implémentation respecte bien le contrat qu’elle affiche dans son interface, et ne produira donc pas de légendaires messages d’erreurs à la C++98, contrairement à ce qui se passe en C++20 où les concepts ne garantissent l’absence de messages d’erreurs illisibles qu’en l’absence d’erreurs d’implémentation qui ne sont pas détectées à la compilation.
Cliquez ici pour un argumentaire plus détaillé si le sujet vous intéresse
Les concepts C++20 sont plus proches des annotations de type de Python que des traits de Rust car leur bonne utilisation n’est pas vérifiée par le compilateur. Après avoir spécifié un contrat d’interface avec des concepts, l’auteur d’un code générique est ensuite tout à fait libre de violer ce contrat en utilisant dans son implémentation des opérations qui n’en font pas partie, sans que le compilateur ne traite cela comme une erreur et le signale.
Ce que ça implique en pratique, c’est qu’un code C++ générique dont le contrat d’interface est mal spécifié compilera sans problème, passera les tests (qui ne sont effectués qu’avec les types d’entrée auxquels l’auteur du code générique a pensé) et ne posera problème qu’au niveau du code utilisateur qui fait appel au code générique avec un type que l’auteur du code n’a pas prévu. Dans ce cas, on aura comme avant un message d’erreur au fond de l’implémentation : l’utilisation des concepts ne résout donc pas complètement le problème du duck typing des templates C++.
Qui plus est, dans un code vivant en évolution constante, toute déclaration faite au niveau d’une interface tend tôt où tard à se désynchroniser avec le code écrit au niveau de l’implémentation. Les concepts C++20 étant purement déclaratifs et non vérifiés par le compilateur lors de la compilation de l’implémentation, ils ne protègent pas les auteurs de l’implémentation de cette tendance naturelle en leur signalant qu’une telle dérive est en train de se produire.
Donc même quand la spécification d’origine est correcte, on peut s’attendre à ce que les implémentations de code génériques C++20 “dérivent” au fil du temps dans une direction qui ne correspond plus au contrat d’interface initialement spécifié par les concepts, sans qu’aucun auteur de la bibliothèque ne s’en rende compte.
Cette situation est, de mon point de vue, encore pire que la situation initiale d’où nous partions : avant, l’utilisateur ne savait pas ce que le code générique acceptait en entrée. Maintenant, il croit savoir, mais l’implémentation est en fait susceptible de trahir sa promesse.
De ce point de vue, on peut considérer les concepts C++20 comme un échec, puisque tout en étant très difficiles à utiliser ils ne constituent pas vraiment une amélioration significative par rapport à un bon code C++98 où le contrat d’interface est spécifié dans la documentation, régressant même sur certains points faute de réelle vérification à la compilation.
Const generics
Comme en C++, on peut définir du code générique paramétré par une constante de compilation :
#![allow(unused)] fn main() { // Type générique par rapport à une constante struct Entiers<const N: usize>([u32; N]); // Fonction générique par rapport à une constante fn entiers<const N: usize>(valeur: u32) -> Entiers<N> { Entiers([valeur; N]) } // Fonction générique qui délègue à une autre fonction générique fn par_defaut<const N: usize>() -> Entiers<N> { entiers(42) } }
Cependant cette fonctionnalité a été introduite récemment en Rust et est encore soumise à des restrictions importantes, qui ont vocation à être assouplies à l’avenir. Voici les deux plus gênantes :
- Seuls les paramètres de types primitifs entiers,
char
oubool
sont actuellement acceptés. - Si une fonction générique veut construire un tableau ou appeler une autre
fonction générique, elle ne peut le faire qu’en leur transmettant ses
paramètres génériques tels quels, sans les utiliser au sein d’une expression.
Par exemple, ce code est actuellement illégal :
#![allow(unused)] fn main() { fn valeur<const N: usize>() -> [u32; { N - 1 }] { [42; { N - 1 }] } }
La première restriction permet de contourner temporairement le problème de la comparabilité en attendant qu’il ait été suffisamment étudié par l’équipe de conception du langage.
Pour vous donner une idée de ce problème, demandez-vous comment le compilateur
peut savoir si Entiers<N>
et Entiers<M>
sont du même type lorsque N et M
appartiennent à un type bizarre comme f32
où il existe des valeurs x telles
que x != x
. Ou ce qu’il doit se passer quand deux valeurs distinctes d’un
type sont considérées comme égales, ce qui peut arriver en présence de
références et autres pointeurs intelligents. C’est le genre de question auquel
l’équipe de conception de Rust va devoir répondre avant que des données de
types plus complexes comme les références puissent être acceptés en paramètre
des const generics.
La deuxième restriction tient aussi au problème de la comparabilité (le compilateur doit souvent déterminer si deux types sont les mêmes bien avant d’avoir suffisamment digéré le code pour pouvoir évaluer des expressions arbitraires). Mais s’y ajoute aussi le choix fait par Rust d’avoir une généricité contrainte où les erreurs d’instantiation de code génériques doivent être signalées à l’interface et pas en plein milieu de l’implémentation.
On ne veut donc pas que ce genre de code soit légal…
#![allow(unused)] fn main() { pub mod bidouilles { // API publique sans contrainte apparente sur le paramètre N pub fn manipuler<const N: usize>() { triturer::<N>(); } // Détails d'implémentation qui dépendent de la condition N > 0 fn triturer<const N: usize>() { assert!(transmogrifier::<N>().into_iter().all(|i| *i > 24)); } fn transmogrifier<const N: usize>() -> [u32; { N - 1 }] { [42; { N - 1 }] } } // Appel invalide : N == 0 n'est pas accepté par l'implémentation bidouilles::manipuler::<0>(); }
…et par conséquent il faut introduire une syntaxe analogue aux
trait bounds pour que manipuler
puisse poser des conditions sur son
paramètre constant N
et que le compilateur puisse vérifier que ces
conditions sont suffisantes pour qu’il n’y ait pas d’erreur possible au moment
de l’instantiation de l’implémentation. Pour l’instant, cette syntaxe n’existe
pas encore, et c’est un prérequis pour que des expressions arbitraires soient
utilisables dans les const generics.
Retenez donc de tout ça qu’à l’heure actuelle, le support de Rust pour ce type de généricité est suffisant pour des tâches simples comme écrire du code qui accepte des tableaux de toutes les tailles en entrée, mais qu’il est encore trop limité pour des utilisations plus complexes comme l’écriture d’algorithmes récursifs, qui nécessitent un support plus poussé du langage encore à venir.
Implémentations par défaut et blanket impl
Revenons maintenant aux traits, car nous sommes très loin d’avoir fait le tour de leurs possibilités.
Il est possible de définir des implémentations par défaut des méthodes d’un
trait. Le trait
Iterator
de la
bibliothèque standard utilise cette fonctionnalité pour implémenter
automatiquement l’ensemble des opérations d’itérateurs de la bibliothèque
standard sur les itérateurs définis par l’utilisateur, qui n’ont besoin de
supporter que l’opération Iterator::next()
de base :
#![allow(unused)] fn main() { // Itérateur simple qui descend d'une valeur initiale à 1, puis s'arrête struct Compteur(usize); // impl Iterator for Compteur { type Item = usize; // Recette pour produire l'élément suivant de l'itérateur, si il y en a un fn next(&mut self) -> Option<usize> { let resultat = self.0; self.0 = self.0.checked_sub(1)?; Some(resultat) } // Pas besoin d'implémenter les autres méthodes d'Iterator, les // implémentations par défaut conviennent. } // Toutes les opérations usuelles d'Iterator sont disponibles : let v = Compteur(30) .filter(|x| x % 2 == 0) .collect::<Vec<_>>(); println!("{v:?}"); }
Une implémentation par défaut est définie en utilisant d’autres méthodes
définies par le trait, ou par d’autres traits dont le trait hérite. Par
exemple, si on en avait marre de la syntaxe utilisée pour spécifier les types
dans Iterator::collect()
, on pourrait définir une méthode collect_vec()
qui
retourne toujours un Vec (et donc permet l’inférence de type), via le
sous-trait suivant :
#![allow(unused)] fn main() { // La syntaxe "trait Trait : A + B { ... }" signifie que pour implémenter Trait, // un type doit aussi implémenter les autres traits A et B. // // En contrepartie, on a accès aux fonctionnalités de A et B dans les // implémentations par défaut des méthodes de Trait et dans le code générique // qui prend un T: Trait en paramètre. // // La borne Sized est requise pour pouvoir manipuler self par valeur. Elle // impose que le type ait une taille connue à la compilation. trait CollectToVec : Iterator + Sized { fn collect_vec(self) -> Vec<Self::Item> { self.collect() } } }
On peut ensuite implémenter ce trait de façon générique pour tous les
itérateurs compatibles avec un bloc d’implémentation générique (on parle de
blanket impl), et il devient alors utilisable sur tout itérateur par tout
code qui a le trait CollectToVec
dans son scope :
#![allow(unused)] fn main() { trait CollectToVec : Iterator + Sized { fn collect_vec(self) -> Vec<Self::Item> { self.collect() } } // Pas besoin de donner une implémentation de collect_vec(), // celle par défaut convient pour tous les itérateurs impl<T: Iterator + Sized> CollectToVec for T {} // Et voilà, plus beosin de guider l'inférence de type quand on veut un Vec ! let v = (0u8..10).collect_vec(); println!("{v:?}"); }
Spécialisation
Lorsqu’on implémente un trait pour un type, on peut aussi remplacer l’implémentation par défaut d’une méthode par une autre implémentation de notre choix. Cette possibilité est souvent utilisée pour spécialiser le code afin d’optimiser ses performances.
Par exemple, la documentation du trait Iterator
standard recommande,
chaque fois que c’est possible, de remplacer l’implémentation par défaut de la
méthode size_hint()
, qui donne une borne inférieure et supérieure du nombre
d’éléments produits par l’itérateur.
En effet, par défaut ces bornes sont très pessimistes (entre 0 et l’infini). Et
elles sont utilisées par l’implémentation de collect()
pour préallouer le
stockage du conteneur cible, donc plus elles sont précises moins
collect()
effectuera d’allocations.
On remplace l’implémentation par défaut d’une méthode de trait exactement comme on implémenterait n’importe quelle autre méthode d’un trait :
#![allow(unused)] fn main() { // Itérateur simple qui descend d'une valeur initiale à 1, puis s'arrête struct Compteur(usize); // impl Iterator for Compteur { type Item = usize; // Recette pour produire l'élément suivant de l'itérateur, si il y en a un fn next(&mut self) -> Option<usize> { let resultat = self.0; self.0 = self.0.checked_sub(1)?; Some(resultat) } /* ... */ // Remplace l'implémentation size_hint par défaut de Iterator // par des bornes inférieures et supérieures précises. fn size_hint(&self) -> (usize, Option<usize>) { (self.0, Some(self.0)) } } }
On le voit, le remplacement des méthodes par défaut permet une forme limitée de spécialisation des implémentations de trait pour un type donné. Par contre, il reste une forme plus générale de spécialisation qui est encore instable en Rust : à l’heure actuelle, on ne peut pas fournir une implémentation par défaut d’un trait complet pour un grand nombre de types (avec une blanket impl comme ci-dessus), puis décider plus tard de réimplémenter le trait pour un type précis de façon plus spécialisée, à la manière des spécialisations de templates en C++.
Une version expérimentale de cette fonctionnalité est implémentée au niveau du compilateur, et est utilisée pour optimiser certaines opérations de la bibliothèque standard. Mais la fonctionnalité n’est pas encore exposée aux développeurs Rust tiers car avec l’implémentation actuelle, il est possible de causer du comportement indéfini sans code unsafe avec des implémentations génériques par rapport aux lifetimes, ce qui viole les principes de conception de base de Rust. Il y a donc encore du travail à faire avant que cette fonctionnalité puisse être rendue accessible à tous.
Autres éléments d’un trait
Les traits que nous avons créés jusqu’ici définissent des méthodes, ainsi que des types associés permettant de spécifier les types de retour de ces méthodes. Mais un trait peut aussi contenir…
- Des fonctions associées qui ne sont pas des méthodes : constructeurs, etc.
- Des constantes, pour pouvoir associer des valeurs à des types ou retourner des tableaux de taille fixe des méthodes.
Les méthodes et types associés peuvent aussi être génériques, et les méthodes
peuvent être unsafe, mais pas encore const
et async
car le travail de
conception associé n’est pas terminé.
Un trait peut aussi être générique, ce qui permet de définir des opérations entre types :
#![allow(unused)] fn main() { // Produit au sens de l'algèbre linéaire trait AlgebraProduct<Other> { type Result; fn algebra_mul(self, other: Other) -> Self::Result; } struct Matrix; struct Vector; struct Scalar; impl AlgebraProduct<Matrix> for Matrix { type Result = Matrix; fn algebra_mul(self, other: Matrix) -> Matrix { todo!() } } impl AlgebraProduct<Vector> for Matrix { type Result = Vector; fn algebra_mul(self, other: Vector) -> Vector { todo!() } } impl AlgebraProduct<Scalar> for Matrix { type Result = Matrix; fn algebra_mul(self, other: Scalar) -> Matrix { todo!() } } impl AlgebraProduct<Vector> for Vector { type Result = Scalar; fn algebra_mul(self, other: Vector) -> Scalar { todo!() } } }
…et comme, dans ce genre d’opérations, il y a généralement un type privilégié correspondant à un opérateur interne, il est aussi possible de préciser un type par défaut pour que l’utilisateur n’ait pas besoin de spécifier le type à chaque fois :
#![allow(unused)] fn main() { trait AlgebraProduct<Other = Self> { /* ... */ type Result; fn algebra_mul(self, other: Other) -> Self::Result; } // Que les mathématiciens me pardonnent... fn internal_product<T: AlgebraProduct>(x: T, y: T) -> T::Result { x.algebra_mul(y) } }
Deux remarques s’imposent à la lecture de l’exemple ci-dessus :
- On aimerait spécifier au niveau de l’interface de
internal_product
queT::Result
doit êtreT
. C’est possible avec la trait bound suivante :#![allow(unused)] fn main() { trait AlgebraProduct<Other = Self> { type Result; fn algebra_mul(self, other: Other) -> Self::Result; } fn internal_product<T>(x: T, y: T) -> T where T: AlgebraProduct<Result = T> { x.algebra_mul(y) } }
- On sent bien qu’avec des noms de associés simples comme
T::Result
, il va un jour y avoir des collisions de noms entre traits. On résout ces ambiguités avec la syntaxe<T as Trait>::Type
. De même, on peut faire référence à une méthode d’un trait avec<T as Trait>::methode()
.
Enfin, mentionnons qu’un trait peut être unsafe, ce qui veut dire qu’un bloc
impl
qui l’implémente doit commencer par unsafe impl
. On utilise cela quand
l’implémentation d’un trait pour un type doit vérifier certaines propriétés
pour que le programme reste sûr, et le compilateur ne peut pas les prouver
automatiquement donc c’est au programmeur qui implémente le trait de le faire.
Les traits unsafe sont utilisés pour le code unsafe générique, qui ne peut pas dépendre du fait que les traits soient bien implémentés par des types arbitraires pour assurer l’absence de comportement indéfini. Sinon, on pourrait causer du comportement indéfini sans écrire de code unsafe en passant un objet avec des traits mal implémentés à l’interface réputée sûre de ce code unsafe générique…
impl Trait
La syntaxe impl Trait1 + Trait2 + ...
peut être employée à la fois dans les
paramètres et arguments des fonctions libres et associées (pas encore les
fonctions de traits malheureusement) pour représenter un type quelconque qui
implémente les traits désignés.
- En argument d’une fonction, c’est juste une syntaxe plus légère pour déclarer
une fonction générique, un peu comme
auto
en argument en C++ :#![allow(unused)] fn main() { use std::fmt::Debug; // Les deux déclarations qui suivent sont presque équivalentes... fn afficher_impl(x: impl Debug) { println!("{x:?}"); } // fn afficher_gen<T: Debug>(x: T) { println!("{x:?}"); } // ...mais la seconde forme est plus puissante, car elle permet à // l'utilisateur de spécifier le type T. Par conséquent, passer de la seconde // variante à la première dans une API publique peut casser du code client. afficher_gen::<u32>(123); }
- En valeur de retour d’une fonction,
impl Trait
permet de dire qu’on retourne un type implémentant certains traits sans donner le type exact, ce qui laisse l’implémentation libre de changer le type plus tard sans casser le code client (qui ne peut utiliser que les traits indiqués) :#![allow(unused)] fn main() { fn iteration() -> impl Iterator<Item = u32> { (0..5) } for i in iteration() { println!("{i}"); } }
Traits de la bibliothèque standard
La plupart des opérationss utiles des types de la bibliothèque standard sont implémentées via des traits, dont voici quelques exemples :
- Les opérateurs arithmétiques de base délèguent le travail aux traits du
module
std::ops
. En implémentant ces traits pour vos propres types, vous pouvez donc surcharger ces opérateurs.- Comme d’habitude en présence de surchage d’opérateurs, un peu de self-control s’impose. Assurez-vous que vos implémentations ont des propriétés mathématiques très similaires à celles de la bibliothèque standard. Ne soyez pas cette personne qui surcharge l’opérateur de décalage de bits pour effectuer des entrées/sorties.
- Un cas particulier important est l’opérateur d’appel de fonction
f()
. Celui-ci est basé sur les traitsFn()
,FnMut()
etFnOnce()
qui sont spéciaux de plusieurs façons : ils ont une syntaxe dédiée (ex :Fn(A, B) -> C
) et à l’heure actuelle vous ne pouvez pas les implémenter vous-même, il faut utiliser des closures si vous avez besoin d’un objet avec un opérateur d’appel de fonction sous votre contrôle.
- Les comparaisons délèguent aux traits du module
std::cmp
, qui permettent de définir un opérateur d’égalité et une relation d’ordre. Vous pouvez préciser si votre relation d’ordre est partielle (comme les flottants, oùf32::NAN != f32::NAN
) ou totale (comme les entiers), et cela affectera les opérations disponibles : pas de tri sur un tableau de flottants. - L’affichage textuel de types par des méthodes comme
println!()
est assuré par le biais des traitsDebug
etDisplay
du modulestd::fmt
, que nous avons déjà rencontrés plusieurs fois. - Si votre type a une bonne valeur par défaut (ex :
0
pour les entiers,Vec::new()
pourVec
…), vous pouvez l’indiquer avec le traitDefault
. - Pour convertir entre des valeurs de différents types, il y a les
traits de
std::convert
.
…et ainsi de suite. Une part importante du développement d’une bibliothèque Rust est consacrée à s’assurer que tous les traits de la bibliothèque standard sont bien implémentés pour vos types chaque fois que ça à du sens, afin que vos types aient une interface familière et soient utilisables dans tous les contextes où un type standard similaire le serait.
Souvent, pour les types structurés, il y a une implémentation évidente, qui
consiste à appeler récursivement l’implémentation du trait pour toutes les
données membres avec un enrobage dépendant du trait. Pour éviter d’avoir à
écrire ce genre de mécanique inintéressante soi-même comme on le devrait le
faire en C++, on utilise les macros derive()
:
#![allow(unused)] fn main() { #[derive(Debug)] struct S { x: u32, y: f32, } let s = S { x: 12, y: 3.4 }; println!("{s:?}"); }
Les implémentations de derive()
fournies par la bibliothèque standard sont
assez conservatrices et ne couvrent pas des traits dont l’implémentation
évidente “je délègue à tous les membres” aurait une sémantique un peu plus
discutable du point de vue utilisateur. Par exemple, il n’y a pas de derive()
standard pour les opérateurs arithmétiques. Mais en cas de besoin, on peut en
trouver des implémentations tierces dans des bibliothèques comme
derive_more
.
Traits implémentés automatiquement
Dans l’ensemble, Rust évite d’implémenter des traits standard automatiquement, parce que les traits font partie de l’API publique d’un type, et c’est important de laisser les programmeurs minimiser l’API exposée par leur type quand ils anticipent des changements d’implémentation.
Mais il y a quelques exceptions, qui concernent des propriétés très
fondamentales des types, principalement représentées par les traits du module
std::marker
. En voici
quelques exemples :
Sized
indique qu’un type a une taille connue à la compilation. Ce trait est implémenté pour presque tous les types, à l’exception des types[T]
,str
,dyn Trait
et des types structurés qui contiennent une valeur de ces types.Sync
indique que plusieurs threads peuvent accéder à une même variable par le biais d’une référence partagée. Ce trait est implémenté automatiquement par tous les types sans mutabilité interne non synchronisée.Send
indique qu’une valeur d’un type peut être transmise à un autre thread. Ce trait est implémenté automatiquement pour les types ne contenant pas de références partagées vers un état à mutabilité interne non synchronisée.
Ces traits sont assez importants pour mériter un traitement de faveur, car un
type doit être Sized
pour être passé en argument à une fonction ou retourné
en résultat d’une fonction. Et les traits Send
et Sync
sont à la base du
multi-threading transparent et sûr de Rust.
Polymorphisme statique et dynamique
Toutes les formes de généricité que nous avons abordé jusqu’à présent sont résolues à la compilation par un mécanisme moralement équivalent à un copier-coller de code, comme lorsqu’on utilise des templates en C++. On parle de polymorphisme statique de façon générale, et la communauté Rust utilise aussi souvent le terme plus exotique de “monomorphisation”.
En polymorphisme statique, chaque fois qu’on appelle une fonction générique avec
un nouveau type d’arguments, une copie du code de la fonction est créée,
spécialisée pour ce nouveau type d’argument. De même, à chaque fois qu’on
instantie un type générique Generique<T>
avec un nouveau paramètre U
, le
compilateur crée une copie de la définition du type et des méthodes utilisées,
qui sont tous spécialisés pour le type U
.
Il existe aussi une autre façon d’implémenter le polymorphisme, qui est le
polymorphisme dynamique. Dans ce modèle, on exploite le fait qu’on ne va
utiliser que certaines méthodes de l’objet dans le code générique en créant une
table de ces méthodes pour chaque type d’objet supporté. Le code générique
n’est compilé qu’une seule fois, et il reçoit en paramètre une référence vers
l’objet cible et une table d’implémentations des méthodes (vtable) pour le
type d’objet cible. Le comportement s’adapte ensuite à chaque type en appelant
les implémentations spécifiques des méthodes via les pointeurs de la vtable.
C’est le modèle utilisé par les méthodes virtual
en C++.
Ces deux façons de faire ont leurs avantages et leurs inconvénients :
- Un programme qui utilise du polymorphisme statique est généralement plus performant à l’exécution, car il est spécialisé pour le type de valeur manipulée (ce qui permet davantage d’optimisations) et évite l’indirection liée au passage de valeurs et de pointeurs de fonctions par référence. En polymorphisme statique, il est aussi beaucoup plus facile de stocker les données sur la pile, là où en polymorphisme dynamique on doit souvent stocker les valeurs sur le tas pour s’abstraire de leur taille.
- Un programme qui utilise du polymorphisme dynamique compile généralement plus rapidement et en consommant moins de mémoire, a un code plus petit qui peut mieux tenir dans le cache instructions du CPU, peut manipuler des collections de données de type hétérogène, et plus généralement peut s’adapter plus facilement à des données dont le type ne sera pas connu avant l’exécution.
En voyant ces deux listes, on devine que le choix entre les deux formes de polymorphisme n’est pas trivial, et qu’on sera souvent amené à utiliser les deux formes de polymorphisme au sein d’un programme donné, voire à basculer de l’une à l’autre en fonction de l’évolution des besoins.
Malheureusement, en C++, le polymorphisme statique et dynamique utilisent deux syntaxes complètement incompatibles avec des règles de fonctionnement très différentes, et basculer de l’un à l’autre est un travail de longue haleine.
En Rust, en revanche, on a déjà brièvement mentionné qu’il existe &dyn Trait
:
#![allow(unused)] fn main() { use std::fmt::Display; let mut r: &dyn Display = &42u8; println!("{r}"); r = &"bonjour"; println!("{r}"); r = &123.456f32; println!("{r}"); }
&dyn Trait
est implémenté sous forme de “gros pointeur”, composé d’un pointeur
vers la valeur cible et d’un pointeur vers l’implémentation des différentes
méthodes du traits pour le type cible. C’est donc un peu analogue à
l’implémentation de l’orienté objet en C++, sauf que la vtable est dans le
pointeur et pas dans l’objet pointé, ce qui est plus adapté à Rust car on peut
implémenter des traits pour des types externes en Rust.
Si on a besoin de passer des objets implémentant un trait par valeur, on peut
aussi utiliser Box<dyn Trait>
, Arc<dyn Trait>
, etc. avec des règles
similaires.
L’utilisation de dyn Trait
n’est cependant pas parfaitement transparente, car
on doit composer avec le fait que le code qui utilise dyn Trait
ne connaît
pas la taille de l’objet ni de ses types associés, et que la vtable doit être
de taille finie. Ce qui signifie entre autres que…
- On ne peut pas utiliser des méthodes qui prennent
self
par valeur, ou qui retournent des valeurs du typeSelf
ou d’un type associéSelf::Xyz
. - On ne peut pas utiliser des méthodes génériques via
dyn Trait
(il faudrait une vtable infinie).
Une liste complète des règles qu’un trait doit respecter pour être utilisable via
dyn Trait
est disponible dans la section “Object
Safety”
de la documentation de référence du langage.
Afin d’éviter une explosion de la taille des pointeurs, on ne peut non plus
utiliser plusieurs traits à la fois via dyn Trait1 + Trait2 + Trait3...
, à
l’exception de quelques traits privilégiés comme Sized
dont la vtable est de
taille nulle. Mais il est facile de contourner cette limitation :
trait Composite : Trait1 + Trait2 + Trait3 {}
impl<T: Trait1 + Trait2 + Trait3> Composite for T {}
dyn Trait
ou types sommes ?
Si vous êtes attentifs, vous avez peut-être remarqué que dyn Trait
n’est pas
le seul mécanisme permettant de traiter des objets de plusieurs types comme si
ils étaient d’un seul type en Rust. On peut aussi utiliser des types sommes :
#![allow(unused)] fn main() { enum Variable { Int(u32), Float(f32), } fn carre(x: Variable) -> Variable { match x { Variable::Int(i) => Variable::Int(i * i), Variable::Float(f) => Variable::Float(f * f), } } }
Quand choisir l’une ou l’autre de ces approches ?
enum
représente un ensemble fermé, complètement connu quand le code est compilé.dyn Trait
représente un ensemble ouvert, extensible par des bibliothèques tierces.- Par conséquent,
enum
facilite un peu plus le travail d’optimisation du compilateur, même si il reste plus difficile qu’en polymorphisme statique, là oùdyn Trait
est plus flexible.
- Par conséquent,
enum
peut être manipulé par valeur,dyn Trait
ne peut être manipulé que par référence.- Si les types à l’intérieur de l’
enum
sont de taille très différente,enum
est de la taille de la plus grosse alternative, ce qui conduit à un gaspillage de mémoire et des copies longues. On peut parfois limiter la casse par une utilisation judicieuse deBox
.
- Si les types à l’intérieur de l’
enum
facilite le support de nouvelles opérations, alors quedyn Trait
facilite le support de nouveaux types (dans l’autre cas, il faut à chaque fois modifier beaucoup de code).dyn Trait
est un peu moins facile à utiliser queenum
, toutes choses étant égales.
Génération de code
Introduction
La génération de code fait partie de ces pratiques que tout le monde aime mépriser, mais qui font inévitablement leur entrée dès qu’un code dépasse une certaine taille.
Les concepteurs de langages de programmation sont particulièrement en froid avec elle, car elle est souvent utilisée pour contourner des limites du langage qui conduisent à un code stupide et redondant, ce qu’ils tendent à prendre comme un reproche et une incitation à améliorer le langage plutôt que son support de la génération de code.
Pourtant, la génération de code a encore de nombreuses applications aujourd’hui, par exemple…
- L’implémentation automatique de comportements par défaut (comme
derive()
en Rust). - La sérialisation de données dans des formats standardisés, et la reconstruction de données typées depuis ces formats, avec gestion des erreurs de typage.
- La génération automatique de schémas de bases de données et de requêtes SQL à partir d’une représentation du modèle de données dans le système de types du langage.
- La production de traces d’exécution indiquant les valeurs d’entrées et de sorties d’une fonction à chaque fois que celle-ci est appelée.
En C++, le support de la génération de code se limite aux macros du préprocesseur C, qui ne sont qu’une machine à copier coller du texte sans modification. Avec ça, on ne va pas loin, par conséquent, on doit souvent avoir recours à des générateurs de code externes au langage comme ROOT et le MOC de Qt. Cela implique une intégration laborieuse à l’environnement de compilation et une importante duplication du travail déjà effectué par le compilateur du langage puisque le générateur doit ré-analyser le code.
En Rust, en revanche, la génération de code est un membre pleinement reconnu de l’écosystem. Il existe des outils pleinement intégrés au langage pour consommer du code pré-digéré par le compilateur (arbre de tokens) et réémettre un code différent, qui est celui qui sera consommé par le compilateur. Il est donc beaucoup plus facile d’utiliser la génération de code en Rust, et elle est donc logiquement plus souvent utilisée dans ce langage qu’en C++.
Mécanismes
Rust fournit trois mécanismes de génération de code, de difficulté et d’expressivité croissante :
- Les macros déclaratives, dites “macros par l’exemple”, utilisent un petit
langage déclaratif intégré à Rust, basé sur la reconnaissance de motifs (un
peu comme
match
). - Les macros procédurales utilisent un type particulier de bibliothèque, qui
contiennent des fonctions Rust ordinaires prenant un arbre de tokens en
paramètre et émettant un autre arbre de tokens en sortie. C’est l’approche
utilisée par
#[derive(Trait)]
. - Les scripts de compilation ne font pas partie de Rust à proprement parler,
mais de son environnement de compilation standard
cargo
. Ils permettent d’exécuter du code arbitraire avant même que le compilateur ne commence à parser le code. C’est souvent utilisé pour la gestion de dépendances externes.
Maîtriser chacun de ces mécanismes nécessite un temps d’apprentissage important, il me semble donc qu’ils sortent du périmètre de ce cours introductif.
Mais pour vous donner un “Hello World”, voici une macro déclarative qui implémente un trait pour un tuple de taille choisie par l’utilisateur. C’est quelque chose que l’on est actuellement forcé de faire en Rust, en attendant l’arrivée des génériques variadiques qui sont un des rares domaines ou C++ a de l’avance. On voit donc ici une utilisation classique des macros pour contourner une fonctionnalité manquante du langage de programmation sous-jacent.
trait TraitVide {} // Définition de la macro macro_rules! impl_trait_tuple { ( // Accepte en entrée une liste d'identifiants séparée par des virgules, // qui serviront à nommer les types internes au tuple. $( $t:ident ),* ) => { // Bloc d'implementation générique pour ce type de tuple. // // Notez la syntaxe de répétition un peu différente sur la droite, qui // permet de générer une virgule terminale pour le tuple unaire (T,). // // Notez aussi l'utilisation de $crate pour avoir un chemin relatif à la // racine de la crate active, indispensable si la macro a vocation à // être utilisable dans d'autres crates. impl< $( $t ),* > $crate::TraitVide for ( $( $t, )* ) {} }; } // Utilisation de la macro pour implémenter TraitVide jusqu'à une taille de // tuple maximale de notre choix impl_trait_tuple!(); impl_trait_tuple!(A); impl_trait_tuple!(A, B); impl_trait_tuple!(A, B, C); impl_trait_tuple!(A, B, C, D); fn main() {}
Pour apprendre les macros déclaratives, deux bonnes sources sont…
- “The Little Book of Rust Macros” pour une introduction gentille.
- La documentation de référence, dont on a besoin pour savoir ce qui est légal dès qu’on essaie de faire des choses un peu avancées.
Si vous voulez plus tard vous essayer aux macros procédurales, votre réflexe de
base devrait être d’utiliser les excellentes bibliothèques
syn
et
quote
de David Tolnay, qui permettent
respectivement de transformer en entrée l’arbre de tokens fourni par le
compilateur en arbre syntaxique (Abstract Syntax Tree ou AST), et de générer
en sortie un arbre de tokens avec une syntaxe ressemblant à du code Rust
ordinaire.
La raison pour laquelle le compilateur ne nous expose pas directement son AST est que les auteurs du compilateur souhaitent garder la possibilité de le faire évoluer avec l’implémentation du compilateur. Par exemple, le front-end de rustc est actuellement en train d’être parallélisé, et cela n’aurait sans doute pas été possible sans briser l’API si les types de l’AST avaient été exposés.
Il existe aussi d’autres bibliothèques basées sur syn
et quote
qui
simplifient des pratiques récurentes, comme l’implémentation de macros
derive()
par exploration récursive d’un type structuré. C’est un écosystème
plus mouvant, donc j’hésite à faire des recommandations qui ont de fortes
chances de devenir obsolètes en quelques années seulement.
Quand aux scripts de compilation, ce sont des programmes Rust ordinaires qui
sont juste compilés et exécutés par cargo
selon un protocole bien
précis avant
d’entamer le processus de compilation du reste du programme.
Les crates avec Cargo
On l’a évoqué précédemment, les modules de Rust permettent d’organiser son code et de créer des barrières d’encapsulation, mais ils ne sont pas une unité de compilation. Ce qui joue ce rôle en Rust, ce sont les crates, un concept général qui regroupe tous les produits possibles de la compilation.
Que vous soyez en train de travailler sur un programme ou une bibliothèque, du point de vue de Rust, le source associé est composé d’une ou plusieurs crates, chacune de ces crates est traitée comme une unité de compilation, et le but du processus de compilation est de transformer chaque crate en un objet binaire du bon type (exécutable, archive statique, shared object/DLL…).
Rôle de Cargo
Si vous avez installé un environnement de développment Rust en
local, alors vous avez d’ors et déjà créé une ou
plusieurs crates avec la commande cargo new
.
En Rust, la façon recommandée de créer, gérer et compiler des crates est d’utiliser Cargo. Il s’agit d’un outil qui remplit plusieurs fonctions normalement assurées par des outils séparés dans l’environnement de développement C++ traditionnel :
- Il permet de sélectionner de bonnes versions des dépendances de votre projet, les télécharger et les compiler automatiquement, à la manière de Spack et des gestionnaires de paquets plus orientés systèmes d’exploitation (APT, DNF, Zypper, Brew, portage…).
- Il permet de configurer le processus de compilation, à la manière de CMake et Meson.
- Il détecte ce qui doit être (re-)compilé, dans quel ordre, et gère l’ordonnancement et l’exécution du processus de compilation parallèle, à la manière de GNU Make et Ninja.
- Il s’interface avec les fonctionnalités de documentation (analogues à Doxygen), de test unitaire et de benchmarking du compilateur Rust pour permettre de lancer tout ça facilement.
- Il s’interface également avec les outils d’analyse statique
rustfmt
etclippy
fournis avec le compilateur Rust pour fournir du formatage automatique et des lints plus poussés.
Grâce à cette centralisation des fonctionnalités, et à certains choix de conception plus heureux que ceux de la pile C++ traditionnelle (configuration déclarative, accent sur l’édition de lien statique et la réduction des dépendances vis à vis des bibliothèques/outils de l’OS hôte…), cargo rend le processus de compilation typique beaucoup plus simple et sans bavure que ce à quoi on est habitué en C++.
Ainsi, lorsqu’on compile un programme ou une bibliothèque Rust, la norme est que
ça fonctionne du premier coup, sans aucune étape préparatoire, en une simple
commande cargo build
ou cargo install
. Et les rares fois où l’on rencontre
des problèmes, ils sont quasiment toujours liés aux dépendances C/++ auxquelles
on n’a pas réussi à se soustraire, comme openSSL ou HDF5. Cela peut expliquer
en partie, sans l’excuser, la tendance souvent observée des personnes
nouvellement formées à Rust à vouloir réécrire l’intégralité de leurs
dépendances dans ce langage.
Utilisation de Cargo
Nous avons déjà présenté brièvement l’utilisation de Cargo dans le chapitre sur
l’installation d’un environnement de développement local, il est maintenant
temps de détailler un peu plus. Voici un petit tour d’horizon des principales
commandes disponibles après avoir fraîchement installé un environnement de
développement Rust via rustup
:
cargo new
permet de créer une nouvelle crate avec un squelette de code et une configuration minimale. Par défaut on crée un exécutable, l’option--lib
permet de créer une bibliothèque.- Un grand nombre de commandes permettent de construire et exécuter des
binaires :
cargo build
compile le programme sans l’exécuter.cargo run
compile le programme, puis l’exécute.cargo test
compile et exécute les tests unitaires, d’intégration, et les exemples de la documentation intégrée au code.cargo bench
compile et exécute les microbenchmarks du code.
- Plusieurs outils d’analyse statique sont intégrés d’office :
cargo check
vérifie que le programme passe les vérifications de typage et les lints du compilateur, sans essayer de construire un binaire.cargo fix
permet d’appliquer automatiquement les suggestions fournies par certaines lints decargo check
à votre code.cargo clippy
applique des lints plus agressives, qui voient des choses que les lints de base ne voient pas mais au prix d’un taux de faux positifs plus élevé.cargo fmt
formate votre code selon des normes communément admises par la communauté Rust.cargo doc
génère automatiquement une documentation HTML de référence à partir de commentaires spéciaux dans votre code, à la manière de Doxygen.
- D’autres commandes relèvent plutôt de la gestion de paquets, elles
fonctionnent par défaut en utilisant le dépôt public de paquets
crates.io.
cargo install
permet d’installer des exécutables depuis leur code source et les mettre dans lePATH
, etcargo uninstall
permet de les désinstaller.cargo add
permet d’ajouter facilement des dépendances à son projet, etcargo remove
permet de les enlever.cargo tree
permet de visualiser l’arbre des dépendances de son projet.cargo update
permet de mettre à jour ses dépendances en respectant SemVer.- D’autres commandes permettent de publier facilement ses bibliothèques.
En sus de ça, cargo
dispose d’un système de plug-ins qui permettent
d’ajouter d’autres sous-commandes cargo. On peut par exemple mentionner…
cargo-outdated
pour visualiser l’intégralité des mises à jour de dépendances disponibles, y compris celles qui changent d’API et nécessitent des adaptations manuelles.cargo-criterion
pour exécuter plus efficacement des microbenchmarks basés surcriterion
(l’analyse est centralisée au lieu d’être dupliquée dans chaque benchmark).cargo-show-asm
pour visualiser l’assembleur de ses fonctions quand on optimise son code.cargo-miri
qui analyse dynamiquement le code unsafe en vérifiant l’absence de comportement indéfini, à la manière de Valgrind et des sanitizers en C++.cargo-llvm-lines
qui permet de détecter le code bloat associé aux fonctions génériques quand on abuse du polymorphisme statique, pour éliminer plus facilement les désagréments associés (compilation lente, gros binaires…).
Configuration de Cargo
A la racine du code source de votre projet, cargo new
crée un fichier
Cargo.toml
qui permet de configurer le comportement de Cargo. On peut par
exemple y spécifier…
- Des options de compilation (ex : activer les informations de déboguage en
mode release pour pouvoir profiler son code avec
perf
ou VTune). - Des métadonnées (ex : nom d’auteur, contact, version, licence…) qui sont par exemple utilisées quand on publie son projet sur crates.io.
- Des fonctionnalités optionnelles (features) que l’utilisateur peut choisir d’activer ou non au moment de la compilation.
- La liste de ces dépendances, même si celle-ci peut aussi être gérée
avec
cargo add
etremove
.- Les dépendances peuvent être déclarées comme optionnelles, auquel cas cela crée automatiquement une feature qui porte le même nom. Une feature déclarée manuellement peut aussi activer des dépendences optionnelles. Plus d’informations ici.
Pour plus d’informations sur ce qui peut être configuré, et sur les autres façons possibles de le configurer (notamment via des fichiers dans son dossier personnel ou des variables d’environnement), ainsi que sur d’autres fonctionnalités plus avancées que je ne couvre pas dans cette introduction (gestion conjointe d’un ensemble de crates via les workspaces, profilage de la compilation…), je vous invite à consulter le manuel de Cargo.
Code spécifique
Jusqu’à présent, les outils du langage et de la bibliothèque standard que nous avons vu ont vocation à produire du code portable, qui s’exécute sur un maximum de matériels et systèmes d’exploitation.
Cependant, il y a un prix à payer pour cette portabilité, qui est qu’on ne peut pas tirer parti des spécificités du matériel/système sur lequel on s’exécute. Cela peut parfois être nécessaire, donc nous allons maintenant voir comment on procède.
Features et cfg()
Dans un chapitre précédent, nous avons mentionné que les crates peuvent avoir des fonctionnalités optionnelles appelées features. Dans ce chapitre, nous allons explorer les mécanismes de compilation conditionnelle de Rust, qui permettent de gérer ces fonctionnalités optionnelles ainsi que les quelques parties du code qui dépendent du matériel/OS hôte.
Options de configuration
Le compilateur Rust expose un certain nombre d’options de
configuration
qui permettent de connaître l’architecture CPU cible (ex : x86_64
,
aarch64
…), les fonctionnalités CPU optionnelles activées à la compilation
(ex : avx
, fp16
), le système d’exploitation cible (ex : linux
, windows
),
les opérations atomiques suportées, les features activées via Cargo, etc.
La façon la plus simple d’interroger ces options de compilation est la macro
cfg!()
, qui prend en paramètre une option de configuration et retourne true
si cette option est activée et false
sinon :
#![allow(unused)] fn main() { // Test de l'OS cible let os = if cfg!(target_os = "linux") { "Linux" } else if cfg!(target_os = "macos") { "macOS" } else if cfg!(target_os = "windows") { "Windows" } else { "autre chose" }; println!("J'ai été compilé pour {os}"); }
Cependant, cette macro ne suffit pas à répondre à tous les besoins de
compilation conditionnelle. En effet, en dehors de la macro cfg!()
, le code
ci-dessus reste analysé comme d’habitude par le compilateur, en compilant
toutes les branches du if
.
On ne pourrait donc pas utiliser dans ces différentes branches des fonctions
spécifiques à Linux comme
io_uring_enter()
ou des
fonctions spécifiques à Windows comme
GetQueuedCompletionStatus()
,
car la compilation échouerait sur les autres systèmes d’exploitation où ces
fonctions n’existent pas :
#![allow(unused)] fn main() { // On vérifie si la dépendance optionnelle "schmilblik" est activée if cfg!(feature = "schmilblik") { // ERREUR : La dépendance optionnelle "schmilblik" n'est pas activée, // mais ce code qui l'utilise est compilé quand même. use schmilblik::COULEUR; println!("Le schmilblik est {COULEUR}"); } }
A la place, on doit utiliser une approche plus radicale qui amène le code spécifique à être complètement ignoré par le compilateur.
L’attribut #[cfg()]
L’attribut #[cfg()]
peut être appliqué à toutes sortes d’éléments du code :
imports use x::y::z;
, déclarations de types et de fonctions, blocs
d’instructions…
Comme la macro cfg!()
, cet attribut prend en paramètre une option de
compilation. Si l’option de compilation est activée, l’attribut n’a aucun
effet. Mais si l’option de compilation n’est pas activée, le code auquel
l’attribut #[cfg()]
s’applique est supprimé du code source.
Comme la directive préprocesseur #ifdef
en C/++, cet attribut est donc
appliqué au code spécifique à un matériel, un système d’exploitation… pour
assurer que ce code ne soit pas compilé quand ses conditions de bon
fonctionnement ne sont pas remplies :
#![allow(unused)] fn main() { // Ouverture d'un fichier en écriture (cf chapitre système) use std::fs::File; let fichier = File::create("/tmp/test.txt") .expect("Echec de création du fichier"); // Instructions spécifiques aux systèmes Unix (Linux, macOS...) #[cfg(unix)] { use std::os::unix::io::AsRawFd; let fd = fichier.as_raw_fd(); println!("On utilise le descripteur de fichier numéro {fd}"); } }
Il est courant de vouloir appliquer un même attribut #[cfg()]
à plusieurs
déclarations. La façon la plus courante de faire est de grouper l’ensemble de
ces déclarations au sein d’un même module, auquel on applique l’attribut
#[cfg()]
:
#![allow(unused)] fn main() { #[cfg(unix)] pub mod signals { use std::ffi::c_int; pub const SIGHUP: c_int = 1; pub const SIGINT: c_int = 2; pub const SIGQUIT: c_int = 3; /* ... et ainsi de suite ... */ } }
Opérations logiques
Le fait que #[cfg()]
soit un attribut a pour conséquence fâcheuse qu’on ne
peut pas utiliser des opérations booléennes standard quand on interroge les
options de compilation. On n’a même pas d’équivalent au #else
du préprocesseur
C/++.
A la place, il faut utiliser une syntaxe dédiée qui devient très rapidement désagréable :
#![allow(unused)] fn main() { // Sélection de code selon qu'on est sous Windows ou pas #[cfg(windows)] println!("On est sous Windows"); #[cfg(not(windows))] // Notez la répétition de la condition println!("On n'est pas sous Windows"); // Test que nous sommes sous Linux et utilisons la glibc (équivalent à &&) #[cfg(all(target_os = "linux", target_env = "gnu"))] println!("On est sous Linux et on utilise la glibc"); // Test qu'on utilise des pointeurs 32 ou 64 bits (équivalent à ||) #[cfg(any(target_pointer_width = "32", target_pointer_width = "64"))] println!("On utilise des pointeurs 32 ou 64 bits"); }
Des crates comme cfg_if ou
cfg_aliases s’emploient donc
à améliorer l’ergonomie de cfg()
de différentes manières, afin de se
rapprocher de l’ergonomie du préprocesseur C sans ramener tous les
inconvénients dudit préprocesseur au passage : séquences if ... else if ... else
, définition d’aliases courts pour éviter de répéter des expressions
compliquées dans le code, etc.
L’attribut #[cfg_attr()]
Une deuxième conséquence du fait que #[cfg()]
soit un attribut est qu’il y a
besoin d’une syntaxe dédiée pour appliquer un attribut de façon conditionnelle.
Prenons par exemple l’attribut
windows_subsystem
. Il s’applique à l’intégralité d’une crate
exécutable, et il indique si l’application est destinée à s’exécuter en ligne de
commande ou de façon graphique, afin que Windows décide si il doit ouvrir un
terminal ou pas lorsqu’on lance l’application depuis l’explorateur de fichiers.
C’est donc une notion extrêmement spécifique à Windows, et on a besoin
d’utiliser la compilation conditionnelle pour que cet attribut ne soit pas
utilisé quand on compile pour d’autres OSes comme Linux et macOS.
On utilise pour ça cfg_attr(configuration, attribut)
, qui indique qu’un
attribut doit être appliqué si et seulement si une option de configuration est
activée :
// Notez la syntaxe #![] qui signifie que l'attribut s'applique à l'entité // englobante, ici la crate entière. #![cfg_attr(windows, windows_subsystem = "windows")] // Autre exemple d'utilisation. Il est bien connu que les utilisateurs macOS // n'ont pas besoin de Debug car leurs applications ne plantent jamais ;) #[cfg_attr( not(target_os = "macos"), derive(Debug) )] struct Buggy; fn main() {}
Accès au CPU
La plupart des constructions du langage Rust aspirent à une portabilité parfaite entre matériels. Si votre programme marche bien sur un CPU, il devrait marcher tout aussi bien sur un autre CPU sans qu’il y ait besoin de le modifier, ou même sans le recompiler si l’architecture CPU est identique.
Mais il peut arriver qu’on ait besoin de rendre le programme moins portable en utilisant des fonctionnalités plus spécifiques à un CPU donné. Nous en avons eu un bref aperçu quand nous avons évoqué les opérations atomiques dans le chapitre sur la mutabilité interne, il est maintenant temps de rentrer dans le détail.
Compiler avec de nouvelles instructions
Les fabricants de CPU font régulièrement évoluer leurs architectures en ajoutant de nouvelles instructions, qui ajoutent de nouvelles fonctionnalités (ex : source d’aléatoire RDRAND) ou permettent de faire de certaines tâches plus efficacement (ex : vectorisation large AVX, accélération de primitives cryptographiques comme AES et SHA).
Comme ces instructions ne sont pas disponibles sur les anciens CPUs, un programme qui aspire à la portabilité totale entre matériels ne peut pas les utiliser. Par conséquent, les compilateurs ne les utilisent pas par défaut dans le code qu’ils génèrent. Mais il est possible de les configurer pour qu’ils le fassent, au prix d’une perte de portabilité des binaires générés.
Dans le cas du compilateur rustc
, cela se fait via les options de génération de
code
target-cpu
et
target-feature
,
qui permettent respectivement de cibler une gamme spécifique de CPU cibles
(comme Intel Skylake ou
AMD Zen 4) et d’activer à grain fin des
extensions individuelles du jeu d’instruction (comme
AVX,
FMA et
AES-NI).
La façon la plus simple de tester l’effet de ces options de génération de code
est via la variable d’environnement RUSTFLAGS
. Par exemple, en exécutant la
commande shell export RUSTFLAGS="-C target-cpu=native"
, on s’assure que
toutes commandes cargo ultérieures exécutées dans le même shell compileront
des binaires spécialisés pour le CPU où cargo
s’exécute. C’est un
moyen simple d’évaluer le bénéfice en termes de performances d’une
génération de code spécialisée.
Pour rendre ce choix plus permanent, il est préférable d’utiliser un fichier de
configuration Cargo
contenant une section [build]
avec une entrée rustflags
contenant les
options choisies. Attention, ces options doivent être fournies sous une forme
pré-digérée, par exemples l’équivalent de la variable d’environnement
RUSTFLAGS
ci-dessus est…
[build]
rustflags = ["-C", "target-cpu=native"]
Fonctions intrinsèques
Parfois, on n’est pas satisfait du code généré par le compilateur, et on est forcé de l’aider un peu en lui indiquant quelle instruction CPU il doit utiliser. La façon la plus simple de le faire est d’utiliser une abstraction simple des instructions CPU, des fonctions appelée “intrinsèques CPU”.
En Rust, ces intrinsèques sont disponibles sous forme de sous-modules du module
std::arch
de la bibliothèque
standard. Par exemple, le sous-module
std::arch::x86_64
contient l’ensemble des intrinsèques associées à l’architecture x86_64
.
Si vous cliquez sur les liens ci-dessus, vous allez rapidement constater trois choises :
- Je vous parle de
std::arch::x86_64
, mais les pages de documentation vous parlent decore::arch::x86_64
. La différence entre les deux sera expliquée dans le chapitre surno_std
, pour l’heure retenez juste que les modulesstd::arch::xyz
sont des alias vers d’autres modulescore::arch::xyz
de la bibliothèque standard. - Beaucoup d’intrinsèques sont encore instables (non utilisables via la version stables du compilateur Rust, ce qui est signalé par l’avertissement “Experimental” dans la documentation) ou manquantes. Cela est lié au très grand nombre d’intrinsèques matérielles relativement à la faible main d’oeuvre disponible pour les intégrer au sein de la bibliothèque standard.
- Toutes les intrinsèques CPU sont des fonctions
unsafe
. Cela est lié au fait que si vous les utilisez sur un CPU où elles ne sont pas supportées, cela causera du comportement indéfini. Il faut l’éviter en vérifiant que le programme s’exécute bien sur un CPU qui supporte l’instruction.
Voyons donc comment on peut vérifier qu’une intrinsèque est supportée avant de l’utiliser.
Détection du support CPU
A la compilation
Lorsque vous compilez un programme avec des options de génération de code
comme target-cpu
et target-feature
, vous supposez d’ors et déjà qu’il va
s’exécuter sur un CPU supportant certaines instructions. Par conséquent, il
serait redondant de vérifier pendant l’exécution que c’est le cas.
A la place, vous pouvez utiliser des attributs cfg()
comme
#[cfg(target_feature = "avx")]
pour vérifier que le programme est bien compilé
pour des CPUs supportant l’extension du jeu d’instruction souhaitée, avant
d’utiliser les intrinsèques dans un bloc de code unsafe.
#![allow(unused)] fn main() { let i = 42i32; #[cfg(target_feature = "popcnt")] let popcnt = unsafe { std::arch::x86_64::_popcnt32(i) }; #[cfg(not(target_feature = "popcnt"))] let popcnt = i.count_ones(); println!("The population count of {i} ({i:#b}) is {popcnt}"); }
Pour beaucoup d’intrinsèques (mais pas toutes), le support de l’instruction par
le CPU est la seule précondition qui rend l’intrinsèque unsafe. La
bibliothèque safe_arch
facilite
l’utilisation de ces intrinsèques de plusieurs façons :
- Elles rend les fonctions concernées safe, mais soumet leur déclaration à une
directive
#[cfg()]
. Donc si le support n’est pas activé au niveau du compilateur, la fonction n’est pas présente au sein de la bibliothèque, et tenter de l’utiliser est une erreur de compilation. - Elle utilise une convention de nommage plus claire que celle de la bibliothèque standard (elle-même héritée d’Intel), ce qui permet de savoir plus souvent ce qu’une intrinsèque va faire sans devoir consulter la documentation toutes les 5 secondes.
- Elle fournit quelques utilitaires absents des intrinsèques standard :
implémentation de
Default
et des opérations binaires pour les types SIMD, variantes des intrinsèques qui utilisent normalement un pointeur basées sur des références…
A l’exécution
Parfois, il n’est pas acceptable de perdre en portabilité matérielle pour gagner en performance. Dans ce cas, l’approche classique est de compiler le code en plusieurs exemplaires, correspondant à différents niveaux de fonctionnalités CPU, puis de sélectionner à l’exécution le code le plus performant en fonction de ce que le matériel supporte.
Rust fournit en standard deux outils permettant cela :
- L’attribut
#[target_feature]
permet de définir des fonctions qui sont compilées séparément avec les options-C target-feature
qui vont bien. Les fonctions résultantes doivent être unsafe puisqu’on ne peut les utiliser que sur des CPUs supportant les instructions utilisées. - La famille de macros
is_xyz_feature_detected!()
permet d’interroger le CPU (si possible) ou le système d’exploitation (sinon) pour savoir quelles instructions sont supportées à l’exécution.
En voici un exemple simple : une somme de tableaux qui est vectorisée par le compilateur, mais en tirant parti de la vectorisation large AVX quand le CPU la supporte.
#![allow(unused)] fn main() { // Point d'entrée de la somme optimisée pub fn optimized_sum(in1: &[f32], in2: &[f32], out: &mut [f32]) { // Si AVX est supporté, alors on utilise une version du code d'addition // optimisée pour les CPUs avec support AVX. #[cfg(any(target_arch = "x86", target_arch = "x86_64"))] if is_x86_feature_detected!("avx") { // SAFETY: OK car on a vérifié qu'AVX est supporté ci-dessus return unsafe { sum_avx(in1, in2, out) }; } // Sinon, on fait appel à la version par défaut // (qui utilisera SSE sur tous les CPUs x86_64) sum_autovec(in1, in2, out); } // Cette fonction est compilée avec le flag -C target-feature=+avx. // Le compilateur utilisera donc AVX quand il vectorisera la boucle de // sum_autovec(), qui est inlinée à l'intérieur de cette fonction. #[target_feature(enable = "avx")] unsafe fn sum_avx(in1: &[f32], in2: &[f32], out: &mut [f32]) { println!("AVX sera utilisé pour ce calcul"); sum_autovec(in1, in2, out); } // Addition basée sur une boucle vectorisée automatiquement. #[inline] fn sum_autovec(in1: &[f32], in2: &[f32], out: &mut [f32]) { for ((x, y), z) in in1.iter().zip(in2.iter()).zip(out.iter_mut()) { *z = *x + *y; } } // Exemple d'utilisation let in1 = vec![0.0; 1024]; let in2 = vec![0.0; 1024]; let mut out = vec![0.0; 1024]; optimized_sum(&in1[..], &in2[..], &mut out[..]); }
La détection à l’exécution des jeux d’instructions supportés est relativement coûteuse, vous devez donc vous assurer que cela ne soit pas fait fréquemment. Sinon, il faut garder en cache le résultat de la requête quelque part pour garder de bonnes performances.
De plus, puisque les fonctions target_feature
sont compilées séparément, elles
ne peuvent pas être inlinées. Cela augmente un peu le coût des appels de
fonction et surtout réduit les possibilités d’optimisations, il vaut mieux que
ces fonctions ne soient appelées qu’avec une quantité de travail relativement
importante pour compenser.
Si tout ça vous semble trop manuel, la crate
multiversion
permet d’automatiser ce processus, gérer un cache des features supportées
automatiquement pour vous, et aussi améliorer la portabilité matérielle en
supportant automatiquement toutes les instructions vectorielles de toutes les
architectures CPUs courantes. Avec elle, le code ci-dessus devient :
use multiversion::multiversion;
// Définition de optimized_sum
#[multiversion(targets = "simd")]
fn optimized_sum(in1: &[f32], in2: &[f32], out: &mut [f32]) {
for ((x, y), z) in in1.iter().zip(in2.iter()).zip(out.iter_mut()) {
*z = *x + *y;
}
}
// Exemple d'utilisation
let in1 = vec![0.0; 1024];
let in2 = vec![0.0; 1024];
let mut out = vec![0.0; 1024];
optimized_sum(&in1[..], &in2[..], &mut out[..]);
Assembleur inline
Dans des cas rares, même l’utilisation de fonctions intrinsèques ne suffit pas à obtenir un contrôle suffisant sur ce que fait le matériel, et il faut écrire soi-même l’assembleur pour une partie du code. On est typiquement amené à le faire quand…
- On écrit du code manipulant des données secrètes, et on veut s’assurer que le compilateur ne fasse pas des copies de ces données à d’autres endroits de la mémoire et n’optimise pas les boucles avec des techniques de type early exit qui révèlent indirectement des informations sur la teneur de ces données secrètes (via des variations du temps d’exécution).
- On doit faire des opérations qui sortent du modèle machine de Rust (ex : premières instructions du gestionnaire d’interruption dans un système d’exploitation) ou on veut utiliser des instructions CPU qui ne sont pas encore disponibles sous formes d’intrinsèques.
- On vise la performance maximale permise par le matériel (ex : codec vidéo) et on n’arrive vraiment pas à faire générer du code de bonne qualité au compilateur.
Ayant décidé de faire de l’assembleur, on pourrait décider d’écrire un fichier de code assembleur, le compiler, et le lier à notre programme sous forme de fonction externe, mais…
- Ca complique le processus de compilation (fichiers assembleur à gérer)
- Appeler une fonction a un coût, parfois excessif quand on est très sensible aux performances.
Et c’est pour cette raison que Rust supporte l’assembleur inline, qui dépasse le cadre de ce cours introductif, mais permet d’injecter directement de l’assembleur au milieu de son code Rust en ayant la possibilité de lire et modifier la valeur des variables locales de “l’appelant”.
Les personnes ayant déjà utilisé l’assembleur inline de GCC et Clang seront ravies d’apprendre que la syntaxe proposée par Rust est beaucoup plus lisible et que les valeurs par défaut des paramètres sont beaucoup moins piégeuses. Il est donc beaucoup plus facile d’écrire de l’assembleur inline en Rust qu’en C/++, même si cela reste un outil complexe de dernier recours.
Accès à l’OS
Dans les chapitres sur la programmation système, nous avons étudié des primitives ayant vocation à être portables d’un système d’exploitation à l’autre.
En échange, le prix à payer est que nous ne pouvions pas tirer parti des spécificités de chaque système d’exploitation. Par exemple, nous ne pouvions pas interroger les inoeuds et permissions de fichiers sous Linux, ni avoir accès aux identifiants bruts de fichiers ouverts, connexions réseau, … afin de pouvoir appeler des fonctions spécifiques à chaque OS sur nos objets Rust.
Pour avoir accès à ces fonctionnalités et concepts non portables, il suffit
d’importer dans le scope les traits du module
std::os
. Ceux-ci ajoutent aux
types standard portable des fonctionnalités non-portables spécifiques à un
système d’exploitation donné.
Par exemple, dans un exemple du chapitre précédent sur #[cfg()]
, nous avons
utilisé un de ces traits pour obtenir le numéro de descripteur
d’un fichier ouvert :
#![allow(unused)] fn main() { // Ouverture d'un fichier en écriture (cf chapitre système) use std::fs::File; let fichier = File::create("/tmp/test.txt") .expect("Echec de création du fichier"); // Instructions spécifiques aux systèmes Unix (Linux, macOS...) #[cfg(unix)] { use std::os::unix::io::AsRawFd; let fd = fichier.as_raw_fd(); println!("On utilise le descripteur de fichier numéro {fd}"); } }
Notez l’utilisation de #[cfg(unix)]
: le trait AsRawFd
n’est disponible que
quand on compile pour un système Unix. Tenter d’utiliser quoi que ce soit du
module std::os::unix
sous un autre OS tel que Windows causera une erreur de
compilation : si on n’est pas sur le bons système, le code associé de la
bibliothèque standard n’est tout simplement pas compilé.
Les systèmes d’exploitation qui exposent un grand nombre de ces “traits
d’extension” fournissent également un module prelude
qui permet d’importer
facilement l’ensemble des traits d’un coup. Par exemple, le code ci-dessus
pourrait aussi s’écrire de la façon suivante :
#![allow(unused)] fn main() { // Ouverture d'un fichier en écriture (cf chapitre système) use std::fs::File; let fichier = File::create("/tmp/test.txt") .expect("Echec de création du fichier"); // Instructions spécifiques aux systèmes Unix (Linux, macOS...) #[cfg(unix)] { use std::os::unix::prelude::*; // Inclut notamment AsRawFd let fd = fichier.as_raw_fd(); println!("On utilise le descripteur de fichier numéro {fd}"); } }
Les traits de std::os
ne visent cependant qu’à compléter les types de la
bibliothèque standard par des fonctionnalités non portables. Ils n’ajoutent pas
de nouvelles notions complètement spécifiques à chaque système d’exploitation.
Donc si on veut utiliser des fonctionnalités complètement spécifiques à un
système d’exploitation, telles que
io_uring
sous Linux ou
Direct3D sous Windows, il faudra
utiliser des bibliothèques spécifiques à chaque système d’exploitation pour y
avoir accès.
Deux bons points d’entrée sont libc
pour
les systèmes Unix et windows
pour
Windows.
Infrastructure qualité
On l’a mentionné dans le chapitre sur Cargo, Rust fournit en standard un support pour l’infrastructure qualité de base d’un projet logiciel moderne : des tests automatisés, de la documentation de référence générée automatiquement à partir du code, et des microbenchmarks pour guider l’optimisation des performances et détecter les régressions de performances.
Dans ce chapitre, nous allons voir comment utiliser cette infrastructure standard pour rendre ses projets logiciels en Rust plus robustes.
Tests
En Rust, il n’y a pas besoin d’installer un outil tiers pour tester son code,
l’environnement de développement officiel du projet fournit tout ce qu’il faut
pour les besoins courants. Toutefois, les outils tiers peuvent apporter des
compléments appréciables comme le support du test basé sur les propriétés, et
grâce à cargo
ils sont très simples à utiliser.
Tests unitaires et exemples
Pour écrire des tests unitaires (fonctions par fonction), l’usage est
de commencer par écrire à la fin d’un de ses modules de code le squelette
suivant. Il assure que les tests ne seront compilés que pendant le
développement, et écartés du binaire final, tout en vous gardant l’accès direct
à tous les symboles du module parent via l’import global use super::*
.
#![allow(unused)] fn main() { #[cfg(test)] mod tests { use super::*; // ... vos tests vont ici ... } }
Un test unitaire est ensuite une simple fonction surmontée d’une macro
#[test]
qui génère le code d’initialisation et finalisation nécessaire :
#![allow(unused)] fn main() { #[test] fn nom_du_test() { todo!(); } }
Au sein d’un test, vous pouvez pour une fois vous lâcher sur l’utilisation des paniques, puisque l’objectif n’est pas d’écrire du code qui gère soigneusement ses erreurs avec possibilité de récupération et message clair pour l’utilisateur final en cas d’échec. On veut juste un programme qui crashe avec une backtrace si un problème est détecté, et les paniques sont très bien pour ça :
#![allow(unused)] fn main() { #[test] fn test_egalite() { assert_eq!(42, 42); } }
Néanmoins, la gestion d’erreur via Result
reste possible, donc vous pouvez
écrire un test qui renvoie Result<(), E>
avec E un type qui implémente le
trait Error
, et le test échouera en affichant la description de l’erreur si
la variante Result::Err
est émise par le test.
#![allow(unused)] fn main() { #[test] fn test_ouverture() -> std::io::Result<()> { let mut fichier = File::open("/chemin/invalide.lol")?; writeln!(fichier, "Bonjour")?; Ok(()) } }
Une fois vos tests écrits, vous pouvez les lancer avec cargo test
. Cette
commande exécutera l’ensemble de vos tests en parallèle sur plusieurs threads,
et si vous avez des exemples dans votre documentation, elle vérifiera également
qu’ils compilent et s’exécutent correctement. Un rapport sur l’exécution
sera affiché ce faisant…
Cliquez ici pour afficher un exemple de rapport
running 23 tests
test bitmaps::tests::empty ... ok
test bitmaps::tests::full ... ok
test bitmaps::tests::empty_op_range ... ok
test bitmaps::tests::empty_op_index ... ok
test bitmaps::tests::from_range_op_range ... ok
test bitmaps::tests::full_op_range ... ok
test objects::types::tests::should_compare_object_types ... ok
test bitmaps::tests::from_range_op_index ... ok
test bitmaps::tests::from_range ... ok
test bitmaps::tests::empty_extend ... ok
test bitmaps::tests::from_iterator ... ok
test bitmaps::tests::full_extend ... ok
test bitmaps::tests::empty_op_bitmap ... ok
test bitmaps::tests::full_op_index ... ok
test bitmaps::tests::from_range_extend ... ok
test bitmaps::tests::from_range_op_bitmap ... ok
test bitmaps::tests::arbitrary_op_index ... ok
test bitmaps::tests::arbitrary_extend ... ok
test bitmaps::tests::arbitrary_op_bitmap ... ok
test bitmaps::tests::arbitrary_op_range ... ok
test bitmaps::tests::full_op_bitmap ... ok
test bitmaps::tests::arbitrary ... ok
test topology::support::tests::should_support_cpu_binding_on_linux ... ok
test result: ok. 23 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.02s
Doc-tests hwlocality
running 54 tests
test src/bitmaps/indices.rs - bitmaps::indices::BitmapIndex::count_zeros (line 130) ... ok
test src/bitmaps/mod.rs - bitmaps::Bitmap::full (line 206) ... ok
test src/bitmaps/indices.rs - bitmaps::indices::BitmapIndex::count_ones (line 116) ... ok
test src/bitmaps/indices.rs - bitmaps::indices::BitmapIndex::from_str_radix (line 101) ... ok
test src/bitmaps/mod.rs - bitmaps::Bitmap::is_empty (line 559) ... ok
test src/bitmaps/mod.rs - bitmaps::Bitmap::intersects (line 764) ... ok
test src/bitmaps/mod.rs - bitmaps::Bitmap::fill (line 290) ... ok
test src/bitmaps/mod.rs - bitmaps::Bitmap::clear (line 274) ... ok
test src/bitmaps/mod.rs - bitmaps::Bitmap::invert (line 746) ... ok
test src/bitmaps/mod.rs - bitmaps::Bitmap::copy_from (line 254) ... ok
test src/bitmaps/mod.rs - bitmaps::Bitmap::is_full (line 578) ... ok
test src/bitmaps/mod.rs - bitmaps::Bitmap::includes (line 789) ... ok
test src/bitmaps/mod.rs - bitmaps::Bitmap::from_range (line 227) ... ok
test src/bitmaps/mod.rs - bitmaps::Bitmap::first_unset (line 686) ... ok
test src/bitmaps/mod.rs - bitmaps::Bitmap::first_set (line 599) ... ok
test src/bitmaps/mod.rs - bitmaps::Bitmap::new (line 186) ... ok
test src/bitmaps/mod.rs - bitmaps::Bitmap::iter_unset (line 708) ... ok
test src/bitmaps/mod.rs - bitmaps::Bitmap::last_set (line 639) ... ok
test src/bitmaps/mod.rs - bitmaps::Bitmap::last_unset (line 724) ... ok
test src/bitmaps/mod.rs - bitmaps::Bitmap::is_set (line 529) ... ok
test src/bitmaps/mod.rs - bitmaps::Bitmap::iter_set (line 621) ... ok
test src/bitmaps/mod.rs - bitmaps::Bitmap::weight (line 663) ... ok
test src/bitmaps/mod.rs - bitmaps::Bitmap::set (line 364) ... ok
test src/bitmaps/mod.rs - bitmaps::Bitmap::singlify (line 510) ... ok
test src/bitmaps/mod.rs - bitmaps::Bitmap::set_range (line 393) ... ok
test src/bitmaps/mod.rs - bitmaps::Bitmap::set_all_but (line 335) ... ok
test src/bitmaps/mod.rs - bitmaps::Bitmap::set_only (line 306) ... ok
test src/bitmaps/mod.rs - bitmaps::Bitmap (line 64) ... ok
test src/bitmaps/mod.rs - bitmaps::Bitmap::unset_range (line 459) ... ok
test src/bitmaps/mod.rs - bitmaps::Bitmap::unset (line 430) ... ok
test src/cpu/caches.rs - cpu::caches::Topology::cpu_cache_stats (line 22) ... ok
test src/objects/mod.rs - objects::Topology::type_at_depth (line 340) ... ok
test src/objects/mod.rs - objects::Topology::depth_for_type (line 112) ... ok
test src/lib.rs - (line 72) ... ok
test src/objects/mod.rs - objects::Topology::depth_or_below_for_type (line 151) ... ok
test src/objects/mod.rs - objects::Topology::depth_for_cache (line 276) ... ok
test src/objects/mod.rs - objects::Topology::size_at_depth (line 370) ... ok
test src/objects/mod.rs - objects::Topology::objects_with_type (line 472) ... ok
test src/objects/mod.rs - objects::Topology::root_object (line 443) ... ok
test src/objects/mod.rs - objects::Topology::memory_parents_depth (line 78) ... ok
test src/objects/mod.rs - objects::Topology::depth_or_above_for_type (line 210) ... ok
test src/objects/mod.rs - objects::Topology::depth (line 51) ... ok
test src/objects/mod.rs - objects::Topology::objects_at_depth (line 392) ... ok
test src/topology/builder.rs - topology::builder::TopologyBuilder::build (line 66) ... ok
test src/topology/builder.rs - topology::builder::TopologyBuilder::with_flags (line 336) ... ok
test src/topology/builder.rs - topology::builder::TopologyBuilder::new (line 43) ... ok
test src/topology/mod.rs - topology::Topology::build_flags (line 191) ... ok
test src/topology/mod.rs - topology::Topology::is_abi_compatible (line 164) ... ok
test src/topology/mod.rs - topology::Topology::feature_support (line 238) ... ok
test src/topology/mod.rs - topology::Topology::new (line 107) ... ok
test src/topology/mod.rs - topology::Topology::supports (line 271) ... ok
test src/topology/mod.rs - topology::Topology::is_this_system (line 211) ... ok
test src/topology/mod.rs - topology::Topology::builder (line 140) ... ok
test src/topology/mod.rs - topology::Topology::type_filter (line 294) ... ok
test result: ok. 54 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 2.36s
…et en cas de problème avec un test, vous aurez un rapport d’erreur à la fin :
failures:
---- bitmaps::tests::empty stdout ----
thread 'bitmaps::tests::empty' panicked at 'assertion failed: `(left != right)`
left: `0-`,
right: `0-`', src/bitmaps/mod.rs:2027:13
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
failures:
bitmaps::tests::empty
test result: FAILED. 22 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.03s
Je vous recommande fortement d’écrire vos tests pour qu’ils s’exécutent très
rapidement (moins d’une seconde) et que ça soit viable pour vous les lancer tous
très régulièrement. Néanmoins, si vous ne parvenez pas à adopter cette bonne
pratique, sachez qu’il est possible de ne lancer qu’un sous-ensemble de vos
tests en passant une expression régulière de filtrage à cargo test
:
cargo test -- '(abc|def).*ijk'
Si vous aimez déboguer en affichant les valeurs de variables sur stdout
ou
stderr
, vous apprécierez aussi la macro
dbg!()
de la bibliothèque
standard. Celle-ci prend en paramètre une valeur implémentant Debug
et réémet
cette valeur en sortie après l’avoir affichée ainsi que l’emplacement du code
source où la macro dbg!()
est située. Par exemple, ce genre de code…
#![allow(unused)] fn main() { fn generation() -> u32 { 42 } fn traitement(_: u32) {} traitement(dbg!(generation())); }
…générera ce genre de sortie :
[src/main.rs:6] generation() = 42
Bien sûr, l’inconvénient de cette méthode de déboguage est qu’il ne faut pas
oublier d’enlever toutes les utilisations de la macro dbg!()
du code une fois
que vous aurez trouvé le problème.
Tests basés sur les propriétés
Force est de constater que les développeurs écrivent peu de tests, et que quand ils en écrivent ils les écrivent mal avec des cas tests qui manquent trop de diversité. Les humains sont de très mauvais générateurs d’aléatoire, leurs choix tendent à suivre des tendances très prévisibles parce que c’est moins fatiguant pour le cerveau. Par exemple, avez vous remarqué combien de fois les nombres placeholder 42 et 123 apparaissent dans ce cours ?
Un professeur taquin avait ainsi tenté un jour de demander à une classe d’une quarantaine d’étudiants universitaires d’implémenter l’algorithme de recherche par dichotomie, et observé qu’à la fin aucun n’avait pensé spontanément à tester l’ensemble des cas tordus (tableau vide, gros tableau avec débordement d’entiers lors du calcul de l’indice moyen…), et donc aucune des implémentations produites n’était correcte pour l’ensemble des tableaux d’entrée possibles.
Il existe cependant une technique très puissante pour limiter l’ampleur de ce problème, que je vous encourage fortement à utiliser, c’est le test basé sur les propriétés (property-based testing). En Rust, deux bibliothèques en fournissent une implémentation :
quickcheck
va droit à l’essentiel, est triviale à apprendre, et suffit souvent.proptest
offre un contrôle plus fin qui peut être utile dans certains cas.
Puisque ce cours est une introduction et ne vise pas à faire de vous des experts du test basé sur les propriétés, nous n’allons parler que de quickcheck.
Pour l’utiliser, on ajoute quickcheck
comme dépendance de développement avec
un simple cargo add
, avec le complément quickcheck_macros
qui rend
l’utilisation encore plus facile…
cargo add --dev quickcheck quickcheck_macros
…puis, dans un module de test, on met la macro qui va bien dans le scope…
#[cfg(test)]
mod tests {
use super::*;
use quickcheck_macros::quickcheck;
// ... vos tests vont ici ...
}
Et enfin, on peut écrire un test qui vérifie une propriété, par exemple la distributivité de la multiplication entière :
#[quickcheck]
fn distributivite(x: u32, y: u32, z: u32) {
assert_eq!(
x * (y + z),
x * y + x * z
);
}
Lorsqu’on lance ce test avec cargo test
, quickcheck
va l’exécuter
automatiquement sur une série de valeurs d’entrée aléatoires, le générateur étant
biaisé pour couvrir préférentiellement les cas tordus auxquels les développeurs
ne pensent pas. En l’occurence, le test va échouer :
running 1 test
test tests::distributivite ... FAILED
failures:
---- tests::distributivite stdout ----
thread 'tests::distributivite' panicked at 'attempt to add with overflow', src/main.rs:12:24
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
thread 'tests::distributivite' panicked at 'attempt to add with overflow', src/main.rs:12:24
thread 'tests::distributivite' panicked at 'attempt to add with overflow', src/main.rs:12:24
thread 'tests::distributivite' panicked at 'attempt to add with overflow', src/main.rs:12:24
thread 'tests::distributivite' panicked at 'attempt to add with overflow', src/main.rs:12:24
thread 'tests::distributivite' panicked at 'attempt to add with overflow', src/main.rs:12:24
thread 'tests::distributivite' panicked at 'attempt to add with overflow', src/main.rs:12:24
thread 'tests::distributivite' panicked at 'attempt to add with overflow', src/main.rs:12:24
thread 'tests::distributivite' panicked at 'attempt to add with overflow', src/main.rs:12:24
thread 'tests::distributivite' panicked at 'attempt to add with overflow', src/main.rs:12:24
thread 'tests::distributivite' panicked at 'attempt to add with overflow', src/main.rs:12:24
thread 'tests::distributivite' panicked at 'attempt to add with overflow', src/main.rs:12:24
thread 'tests::distributivite' panicked at 'attempt to add with overflow', src/main.rs:12:24
thread 'tests::distributivite' panicked at 'attempt to add with overflow', src/main.rs:12:24
thread 'tests::distributivite' panicked at 'attempt to add with overflow', src/main.rs:12:24
thread 'tests::distributivite' panicked at 'attempt to add with overflow', src/main.rs:12:24
thread 'tests::distributivite' panicked at 'attempt to add with overflow', src/main.rs:12:24
thread 'tests::distributivite' panicked at 'attempt to add with overflow', src/main.rs:12:24
thread 'tests::distributivite' panicked at 'attempt to add with overflow', src/main.rs:12:24
thread 'tests::distributivite' panicked at 'attempt to add with overflow', src/main.rs:12:24
thread 'tests::distributivite' panicked at 'attempt to add with overflow', src/main.rs:12:24
thread 'tests::distributivite' panicked at 'attempt to add with overflow', src/main.rs:12:24
thread 'tests::distributivite' panicked at 'attempt to add with overflow', src/main.rs:12:24
thread 'tests::distributivite' panicked at '[quickcheck] TEST FAILED (runtime error). Arguments: (0, 2153329322, 2141637974)
Error: "attempt to add with overflow"', /home/hadrien/.cargo/registry/src/index.crates.io-6f17d22bba15001f/quickcheck-1.0.3/src/tester.rs:165:28
Et nous découvrons alors avec stupeur que nous avons oublié de gérer le débordement d’entiers dans notre code. C’est le genre de problème que l’on ne trouve jamais avec des tests écrits à la main, parce que les développeurs ne pensent pas à mettre des grands nombres dans leurs tests.
Ce qui vous a peut-être un peu surpris, en revanche, c’est que le test a échoué
plusieurs fois. La raison est que lorsque la bibliothèque quickcheck
trouve
un problème, elle essaie de réduire les données d’entrée à la configuration la
plus simple qui cause le problème, en réduisant la taille des entiers, la taille
des collections, etc, jusqu’à ce que le problème ne se présente plus.
Le message d’erreur final explique quelle est la configuration finalement obtenue :
[quickcheck] TEST FAILED (runtime error). Arguments: (0, 2153329322, 2141637974)
Error: "attempt to add with overflow"
…où l’on constate qu’il n’y a même pas besoin de multiplier pour avoir un débordement d’entiers, une addition suffit.
Il se trouve que les entiers non signés ont cette bonne propriété que même en
présence de wraparound, le calcul reste distributif. On peut donc adapter
notre code pour clarifier au compilateur que le débordement est prévu et
souhaité ici, via l’utilisation du type Wrapping
…
use std::num::Wrapping;
#[quickcheck]
fn distributivite(x: Wrapping<u32>, y: Wrapping<u32>, z: Wrapping<u32>) {
assert_eq!(
x * (y + z),
x * y + x * z
);
}
…et le test passera :
running 1 test
test tests::distributivite ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Cela nous permet de poser ici une leçon générale sur le test : le but de la procédure de test est d’en arriver à un point où la vision du monde contenue dans le test (qui est basée sur les propriétés recherchées) est cohérente avec la vision du monde contenue dans le code (qui est basée sur les besoins de l’implémentation). Ce qui a deux conséquences concrètes :
- Un test qui échoue ne signifie pas nécessairement qu’il y a un problème dans le code qui est testé, ça peut aussi être un problème avec le code du test.
- Un test qui ne fait que répéter le code qui est testé ne sert à rien. Pour être utile, le test doit être écrit d’un point de vue différent de l’implémentation, typiquement celui de l’utilisateur final de l’application. C’est une gymnastique mentale qui demande un peu de pratique au début.
Autres types de tests
En complément des tests unitaires, il est bon d’avoir des tests qui ne testent pas l’implémentation du programme à grain fin, mais l’interface externe et la façon dont les différents blocs du programme fonctionnent bien ensemble (ou pas). On parle de tests d’intégration ou de validation.
En dehors des exemples de documentation testés automatiquement, qui sont un test
simple des interfaces externes, Cargo supporte l’écriture de tests d’intégration
plus complexes sous la forme de binaires qui n’ont accès qu’à l’interface
externe de la bibliothèque et qui sont construits et appelés automatiquement par
cargo test
. Je vous renvoie vers le tutoriel
officiel
pour plus d’informations.
Documentation
Rust permet d’intégrer la documentation de référence d’une bibliothèque
directement dans le code. L’idée et la syntaxe ressemblent à Doxygen, mais
contrairement à Doxygen le parsing du code Rust par rustdoc
est impeccable.
On ne se retrouve donc jamais à devoir maintenir de nombreux
fragments de son code en
double,
une version que le générateur de documentation sait bien digérer et une version
que le compilateur utilise vraiment pour générer du code… Cela tient au fait
que rustdoc
est développé en tandem avec le compilateur Rust, et le code
pour déchiffrer le code Rust en AST est commun aux deux. Donc tout ce que
rustc
comprend, rustdoc
le comprend aussi.
Vous avez déjà vu beaucoup d’exemples de la sortie HTML de rustdoc durant ce cours, puisque l’intégralité de la documentation de la bibliothèque standard est générée avec cet outil. J’espère donc que vous conviendrez que la présentation par défaut est aussi plus moderne, esthétique et lisible que celle de Doxygen, et que c’est globalement plus facile de naviguer dedans (davantage de liens hypertextes bien placés, barre latérale remplie plus judicieusement).
Pour tester rustdoc, il vous suffit de…
- Documenter vos fonctions, types, constantes… avec des commentaires de
documentation, reconnaissables à leur triple slash :
#![allow(unused)] fn main() { /// Calcul de la somme de x et y fn addition(x: f32, y: f32) -> f32 { x + y } }
- Documenter vos fichiers modules en les commençant par des commentaires de
documentation internes, ou le troisième slash devient un point
d’exclamation :
#![allow(unused)] fn main() { //! Gestion des entrées/sorties }
- Lancer
cargo doc --open
, jeter un premier coup d’oeil au résultat, et itérer longuement jusqu’à ce qu’il soit satisfaisant. Vous n’avez pas besoin de réutiliser--open
pendant l’itération, ce qui ouvrirait plein d’onglets dans votre navigateur web : relancercargo doc
sans argument et rafraîchir l’onglet suffit.
Quelques conseils pour écrire des commentaires de documentation plus utiles au lecteur :
- Un commentaire de documentation s’écrit comme un message de commit
git
: d’abord un résumé court puis deux retours à la ligne et des précisions éventuelles si besoin.#![allow(unused)] fn main() { /// Représentation métaphorique du vide /// /// Non, vraiment, ce type ne représente rien. Il ne peut être construit. Il /// ne peut être manipulé. Il n'a vocation qu'à plonger le compilateur dans /// une angoisse existentielle qui le poussera à supprimer tout le code qui /// l'utilise pour oublier la finitude de son existence. enum Void {} }
- Ensuite, donnez si possible un exemple d’utilisation. Pour cela, vous
écrivez le code Rust entre deux séries de trois backticks : ```. Ce code
sera testé par
cargo test
, il doit donc être complet (n’oubliez pas les clausesuse
, si ça fait trop lourd vous pouvez les cacher ainsi que l’initialisation des variables test avec un#
au début de la ligne de code). - Plus généralement, vous avez droit à une forme étendue de Markdown dans les
commentaires de documentation, incluant notamment la possibilité de générer
facilement des liens vers la documentation d’autres entités avec des syntaxes
comme
[Type]
,[Type::methode()]
… N’hésitez pas à consulter la documentation derustdoc
pour en savoir plus. - Si votre fonction a des conditions d’erreur (retour
Result<T, Erreur>
, paniques, domaine de validité pour les abstractionsunsafe
…), documentez-les en utilisant respectivement les en-têtes conventionnels# Errors
,# Panics
et# Safety
.
Si vous cherchez un catalogue tout fait de conventions de ce genre qui reviennent dans de nombreuses bibliothèques Rust, consultez les Rust API Guidelines et inspirez vous sans limite de la documentation de la bibliothèque standard.
Et si vous aimez bien le rendu HTML des outils de documentation du projet Rust,
vous pourriez aussi vouloir explorer
mdbook
, l’outil utilisé pour écrire ce
cours, qui est plus généralement couramment utilisé dans toutes les
documentations de type manuel/tutoriel du projet Rust parce qu’il produit un
rendu sympa et est très agréable à utiliser.
Microbenchmarks
Une autre bonne pratique quand on développe du code sensible aux performances est de mettre en place un système de tests de performances aux résultats relativement reproductibles d’une exécution à l’autre, qui permettent de vérifier rapidement si, quand on modifie du code, les performances changent dans le bon sens ou pas. On parle de microbenchmark.
Conseils d’écriture
Il est important de garder à l’esprit que la reproductibilité et la rapidité de mesure est un compromis que l’on fait au détriment d’autres considérations comme le réalisme. Pour avoir ces propriétés, on doit extraire des petits fragments de code qu’on fait tourner en boucle en faisant des statistiques sur les temps d’exécution. Et les caractéristiques d’un fragment de code qu’on fait tourner en boucle sont souvent un peu différentes de celles du programme complet dont le fragment est extrait.
J’encourage donc les développeurs (et les utilisateurs) à ne pas trop s’intéresser aux nombres absolus qui sortent des microbenchmarks, mais seulement à leur variation relative entre deux implémentations. La performance finale ne peut être mesurée que sur l’application complète, et tout comme un programme développé dans les règles de l’art devrait idéalement avoir à la fois des tests unitaires et des tests d’intégration, vos programmes devraient avoir à la fois des microbenchmarks et des benchmarks complets sur jeux de données réels.
criterion
Il existe une infrastructure de microbenchmark standardisée au sein du compilateur Rust, qui est notamment utilisée pour le développement du compilateur et de la bibliothèque standard. Mais l’équipe de développement de Rust n’est actuellement pas pleinement satisfaite de sa conception, et n’est donc pas encore prête à la stabiliser en l’ouvrant au monde extérieur. Nous ne pouvons donc pas encore l’utiliser sur les versions stables du compilateur Rust.
A la place, je vous incite fortement à utiliser la bibliothèque tierce partie
criterion
, qui est
un portage d’une bibliothèque tierce partie équivalente en Haskell et fait un
bon usage des statistiques pour essayer de réduire au maximum le bruit de mesure
lié aux autres activités qui se déroulent en tâche de fond sur votre ordinateur
pendant que vos microbenchmarks s’exécutent.
L’ergonomie, sans être parfaite, est satisfaisante : en quelques lignes de code…
use criterion::{black_box, criterion_group, criterion_main, Criterion};
use mycrate::fibonacci;
pub fn criterion_benchmark(c: &mut Criterion) {
c.bench_function("fib 20", |b| b.iter(|| fibonacci(black_box(20))));
}
criterion_group!(benches, criterion_benchmark);
criterion_main!(benches);
…vous pouvez obtenir un jeu de microbenchmarks que vous lancez facilement
soit avec cargo bench
, l’analogue officiel de cargo test
, soit avec l’outil
spécialisé cargo criterion
qui permet d’obtenir des temps de compilation plus
court en dédupliqquant le code commun à tous les benchmarks. Comme avec
cargo test
, vous pouvez choisir quels benchmarks vous lancez en utilisant
une expression régulière, et à la fin vous avez des petits rapports dans votre
terminal…
fib 20 time: [26.029 us 26.251 us 26.505 us]
Found 11 outliers among 99 measurements (11.11%)
6 (6.06%) high mild
5 (5.05%) high severe
slope [26.029 us 26.505 us] R^2 [0.8745662 0.8728027]
mean [26.106 us 26.561 us] std. dev. [808.98 ns 1.4722 us]
median [25.733 us 25.988 us] med. abs. dev. [234.09 ns 544.07 ns]
…qui sont complétés par une version plus complète en HTML pour les experts.
Si par la suite vous modifiez votre code et relancez les benchmarks, vous obtiendrez une analyse statistique de l’évolution de la performance par rapport à l’exécution précédente :
fib 20 time: [353.59 ps 356.19 ps 359.07 ps]
change: [-99.999% -99.999% -99.999%] (p = 0.00 < 0.05)
Performance has improved.
Found 6 outliers among 99 measurements (6.06%)
4 (4.04%) high mild
2 (2.02%) high severe
slope [353.59 ps 359.07 ps] R^2 [0.8734356 0.8722124]
mean [356.57 ps 362.74 ps] std. dev. [10.672 ps 20.419 ps]
median [351.57 ps 355.85 ps] med. abs. dev. [4.6479 ps 10.059 ps]
Pour plus d’information sur la mise en place, consultez la documentation
officielle de criterion
.
Interface console
Motivation
Si vous avez déjà écrit des programmes qui acceptent des arguments de l’utilisateur en ligne de commande, vous savez que c’est un travail de longue haleine :
- Il faut gérer mille variantes de la même syntaxe (ex:
-xyz
vs-x -y -z
,--param=valeur
vs--param valeur
). - Dès qu’on essaie d’écrire une commande à la
perf
oudocker run
qui retransmet une partie des arguments un autre programme, les ambiguités de parsing par rapport à nos propres arguments arrivent très vite. Même au sein d’un même programme, elles peuvent être un problème, pensez par exemple à l’ambiguité entre noms de branches et de fichiers dansgit
. - On doit être prêt à ce que l’utilisateur écrive absolument n’importe quoi, y compris de l’Unicode invalide là où un entier est attendu. Sur des petits utilitaires, le code dédié à la gestion de ces erreurs de syntaxe et la production de messages d’erreurs propres pour l’utilisateur peut très facilement prendre plus de place que le reste du programme.
- Maintenir un texte
--help
et sa version raccourcie-h
en cohérence avec le reste du code au fur et à mesure de son évolution demande une vigilance de tous les instants. - Et je ne vous parle pas de l’autocomplétion, qui nécessite l’écriture de code shell spécifique à chaque shell que vos utilisateurs pourraient vouloir utiliser.
En C et en C++, si vous ne tenez pas à la portabilité vers les OS exotiques, le
getopt()
de POSIX est un outil trop peu exploité qui peut faire une partie du
travail pour vous.
Mais même après le parsing de getopt()
il en reste encore bien trop à faire
pour obtenir une interface en ligne de commande ergonomique et en cohérence
avec les conventions des utilitaires Unix usuels, par rapport à l’intérêt que
le programmeur moyen a pour cette tâche.
C’est pourquoi l’écosystème Rust a produit des solutions très puissantes à ce
problème, qui encapsulent l’essentiel de la complexité dans des bibliothèque
générique pouvant être partagée par un grand nombre de programmes. La plus
connue d’entre elles est clap
.
Utilisation de clap
On commence par ajouter clap
en dépendance à son projet avec cargo add
, en
activant l’excellent support optionnel de derive()
:
cargo add clap --features derive
Puis on définit une structure de données décrivant les arguments que nous attendons en entrée, avec des types standard du langages (on peut aussi ajouter le support de ses propres types moyennant un peu plus de travail) :
#![allow(unused)] fn main() { struct Arguments { speed: f32, iterations: u32, } }
On documente les différents membres ainsi que la structure. La documentation de
la structure servira d’introduction au test --help
:
#![allow(unused)] fn main() { /// Gray-Scott reaction computation /// /// This program simulates the Gray-Scott reaction, a sort of Game of Life for /// chemists. It was developed for the "Gray-Scott Battle" training. struct Arguments { /// Reaction speed /// /// If you tune this higher, the reaction goes faster. speed: f32, /// Number of iterations /// /// The higher you tune this, the longer the reaction will be simulated. iterations: u32, } }
Et enfin, on demande au compilateur de dériver le trait clap::Parser
, qui a
comme prérequis le trait Debug
, en ajoutant quelques annotations pour
clarifier ce qu’on veut que clap génère automatiquement (forme courte des
arguments, etc) :
use clap::Parser;
/// Gray-Scott reaction computation
///
/// This program simulates the Gray-Scott reaction, a sort of Game of Life for
/// chemists. It was developed for the "Gray-Scott Battle" training.
#[derive(Debug, Parser)]
#[command(author, version, about, long_about)]
struct Args {
/// Reaction speed
///
/// If you tune this higher, the reaction goes faster.
#[arg(short, long, default_value_t = 1.0)]
speed: f32,
/// Number of iterations
///
/// The higher you tune this, the longer the reaction will be simulated.
iterations: u32,
}
Ensuite, plus qu’à déclencher le parsing des arguments dans notre fonction
main()
…
use clap::Parser;
/// Gray-Scott reaction computation
///
/// This program simulates the Gray-Scott reaction, a sort of Game of Life for
/// chemists. It was developed for the "Gray-Scott Battle" training.
#[derive(Debug, Parser)]
#[command(author, version, about, long_about)]
struct Args {
/// Reaction speed
///
/// If you tune this higher, the reaction goes faster.
#[arg(short, long, default_value_t = 1.0)]
speed: f32,
/// Number of iterations
///
/// The higher you tune this, the longer the reaction will be simulated.
iterations: u32,
}
fn main() {
let args = Args::parse();
println!("{args:?}");
}
…et en ayant fait juste ça, on a une interface en ligne de commande complète avec :
- Des textes
--help
et-h
automatiquement maintenus en cohérence avec la définition des arguments au niveau de la struct :cargo run -- -h
Gray-Scott reaction computation Usage: grayscott [OPTIONS] <ITERATIONS> Arguments: <ITERATIONS> Number of iterations Options: -s, --speed <SPEED> Reaction speed [default: 1] -h, --help Print help (see more with '--help') -V, --version Print version
- Un parsing automatique des arguments vers le type désiré, avec des valeurs
par défaut :
cargo run -- 5
Args { speed: 1.0, iterations: 5 }
- Une vérification des erreurs de saisie de l’utilisateur, avec des messages
d’erreurs automatiques quand la saisie est mauvaise, par exemple si l’argument
positionnel obligatoire
iterations
est oublié ici :cargo run
error: the following required arguments were not provided: <ITERATIONS> Usage: grayscott <ITERATIONS> For more information, try '--help'.
- Un accès facile à des fonctionnalités plus avancées telles que…
- La possibilité de passer aussi les arguments par variable d’environnement.
- Le chargement automatique des valeurs d’arguments depuis un fichier.
- La génération de pages de man.
- La génération de scripts shells d’autocomplétion.
Conclusion
On comprend pourquoi autant d’utilitaires en ligne de commande sont développés en Rust aujourd’hui. Le niveau de maturité du langage pour ce type d’utilisation est excellent, grâce au typage explicite des arguments il parvient même à dépasser le niveau de confort fourni par Python qui a longtemps été la référence du domaine.
Si d’aventure vous voulez écrire des interfaces textuelles plus sophistiquées
qui reproduisent le comportement des GUIs, à la manière de perf report
et
zenith
, je vous encourage à étudier aussi les bibliothèques
ratatui
et cursive
qui sont conçues pour ce type d’utilisation. La première est plus spécialisée
dans la visualisation temps réel (pensez “moniteur système”), la seconde dans
les interfaces hautement interactives (pensez “perf report
”).
Sérialisation
Dans le chapitre précédent, nous avons vu comment la bibliothèque clap
est
capable de générer automatiquement le code d’une interface ligne de commande
très complète à partir de la définition des paramètres qu’on veut
contrôler via cette ligne de commande.
Plus généralement, comme la génération de code est quelque chose de très accessible en Rust, on peut s’en servir pour automatiser toutes sortes de tâches de manipulation de données rébarbatives. Ici, nous allons l’appliquer à une autre opération qui donne beaucoup de fil à retordre en C++, la sérialisation des données. Ou comment transformer ses structs et enum en JSON, YAML, CSV, etc.
Contexte
Dans la plupart des langages, quand on sérialise des données, on se “marie” à un format de données en utilisant une bibliothèque très spécifique à ce format. C’est un problème pour deux raisons.
D’abord, au moment où on fait un premier choix de format de données, l’application n’est pas forcément assez mature pour qu’on fasse un choix éclairé, et donc c’est important de pouvoir essayer autre chose sans réécrire beaucoup de code. Particulièrement au niveau des formats de sortie ou d’entrée, qui opèrent sous des contraintes de performances et se comportent plus ou moins bien selon la manière dont le code les utilise.
Ensuite, les formats de données sont sujets à des effets de mode, particulièrement dans le domaine de la configuration. A l’heure où ces lignes sont écrites, il y a une mode du YAML et du TOML, avec encore pas mal de JSON ici et là. Il y a une dizaine d’années la mode était aux langages à balise comme XML, alors qu’aujourd’hui ce format est considéré comme has been et les utilisateurs détestent configurer quelque chose avec. Et je suis prêt à parier que si vous relisez ce cours dans dix ans, quelque chose d’autre aura remplacé le YAML et son utilisation sera universellement honnie.
Tout ça pour dire que si votre application est destinée à vivre longtemps, vous devez être prêt à changer de format de fichier de configuration au fil de son cycle de maintenance, et dépendre d’un format précis est une mauvaise idée.
En Rust, la bibliothèque serde
essaie donc de répondre à
deux problèmes :
- Rendre la sérialisation plus simple en générant un maximum de code automatiquement, de façon bien intégrée au langage.
- Rendre la sérialisation plus flexible en permettant d’émettre et recevoir des données dans de très nombreux formats de données, du Avro au YAML.
Utilisation
Comme précédemment, commencez par ajouter serde
comme dépendance, en activant
le support de la dérivation de traits :
cargo add serde --features derive
Ensuite, définissez un type de données avec ce que vous voulez dedans :
#![allow(unused)] fn main() { struct MonType { abc: u32, def: String, ghi: Vec<f32>, } }
Puis ramenez les traits de serde
dans le scope pour plus de confort, et
activez le support de la sérialisation pour ce type en quelques dérivations :
use serde::{Serialize, Deserialize};
#[derive(Debug, Deserialize, Serialize)]
struct MonType {
abc: u32,
def: String,
ghi: Vec<f32>,
}
Et voilà, les données de ce type peuvent maintenant être sérialisées et
désérialisées depuis et vers tous les formats supportés par
serde
.
Pour être plus concret, on va donner un exemple en JSON. Ajoutons la
bibliothèque serde_json
pour ce format en dépendance…
cargo add serde_json
…et en une paire d’appels de fonction, on est maintenant capables d’émettre du JSON et de reconstruire des données du type choisi depuis le JSON :
fn main() {
// Valeur test
let entree = MonType {
abc: 42,
def: "hello".to_string(),
ghi: vec![1.2, 3.4, 5.6, 7.8],
};
println!("Valeur d'entrée : {entree:#?}");
// Transformation en JSON
let json = serde_json::to_string(&entree)
.expect("Failed to encode JSON");
println!("JSON : {}", json);
// Décodage du JSON
let sortie: MonType = serde_json::from_str(&json)
.expect("Failed to decode JSON");
println!("Valeur reconstruite : {sortie:#?}");
}
On le voit, serde
rend le cas simple extrêmement simple d’utilisation, ce qui
est très important. Pour les cas plus complexe, on peut utiliser des directives
spécifiques à serde, comme dans l’exemple clap
du chapitre précédent.
Cela permet notamment de contrôler finement la façon dont les types énumérés sont sérialisés : comme ces types ne sont pas supportés nativement par la plupart des formats de données, il y a plusieurs manières de les retranscrire, et c’est important de choisir la bonne quand on doit décoder des données générées par un autre programme.
Parallélisation
Dans le chapitre sur les itérateurs, je vous ai promis que si vous preniez le
temps de vous familiariser avec les pipelines d’itération, vous seriez
récompensés avec une parallélisation par threads d’une incroyable facilité
via la bibliothèque rayon
. Il est temps de tenir cette promesse.
Démonstration
Ajoutez rayon
comme dépendance à votre projet…
cargo add rayon
…puis importez les traits dans le scope de votre programme…
use rayon::prelude::*;
Et maintenant, vous pouvez transformer vos pipelines d’itérateurs séquentiels en pipelines parallèles à peu de frais :
use rayon::prelude::*;
fn main() {
// Donnée d'exemple sans aucune prétention de réalisme
let v = vec![1.0f32; 1024 * 1024];
// Calcul parallèle de la somme des carrés des éléments
let resultat = v.par_iter()
.map(|x| x.powi(2))
.sum::<f32>();
println!("{resultat}");
}
Bien sûr, pour que ça marche, il faut que votre calcul soit bien thread-safe, sans accès en écriture à une variable déclarée en-dehors de l’itérateur par exemple.
Si vous essayez, la compilation échouera en vous indiquant quel accès mémoire n’est pas sûr en parallèle (c’est détecté car un tel accès viole forcément les règles d’emprunt partagé XOR mutable de Rust), et cela vous aidera à corriger votre code.
Explication
Sous le capot, rayon
a une implémentation analogue à celle de Intel TBB et du
langage Cilk. Le parallélisme est basé sur une approche fork-join avec
ordonnancement par vol de travail :
- On divise récursivement le travail en moitiés à peu près égales jusqu’à une certaine granularité.
- On traite le bloc actif, les autres blocs étant mis de côté dans une file de travail spécifique à chaque thread, mais accessible aux autres threads.
- Si un thread n’a pas de travail, il peut en voler dans la file de travail des autres threads, ce qui permet l’équilibrage dynamique de charge entre les threads.
Cette approche s’applique naturellement au parallélisme de données, c’est à dire au traitement parallèle des données d’une collection : on divise récursivement la collection en deux parties jusqu’à la bonne granularité, et après on distribue le travail selon l’algorithme précédent.
Ce découpage récursif est défini par un trait, implémenté par Rayon pour
toutes les collections de la bibliothèque standard. Quand on importe
rayon::prelude::*
, ce trait arrive dans le scope, donc on peut accéder à ses
méthodes par_iter()
etc, et aux implémentations fournies par rayon
pour les
collections de la bibliothèque standard.
D’autres bibliothèques fournissent du support rayon
intégré. Quand ce n’est
pas le cas, c’est facile de l’implémenter soi-même via l’itérateur parallèle
rayon::iter::split()
qui permet de diviser récursivement une collection de son choix en donnant
simplement la recette pour la couper en deux et la granularité souhaitée.
Asynchronisme
Un programme est dit asynchrone quand il est capable d’avoir
simultanément plusieurs opérations d’entrées/sorties en vol sans devoir bloquer
un thread d’OS par entrée/sortie en cours. Cela requiert un support de l’OS,
via des APIs telles que
epoll()
et
io_uring
sous Linux,
kqueue()
sous macOS et BSD, et les
I/O Completion Ports
sous Windows.
Mais le support d’OS n’est qu’un fondement nécessaire de l’asynchronisme. Le plus difficile est en réalité de fournir un modèle de programmation ergonomique par dessus ces APIs système.
Historiquement, la communauté Rust a tenté de résoudre ce problème avec une solution purement basée sur les bibliothèques externes. Mais il est rapidement apparu qu’un support direct du langage pourrait grandement améliorer l’ergonomie de la programmation asynchrone. Par ailleurs, il était tout aussi clair que l’écosystème de bibliothèques externes n’était pas assez mature pour qu’une intégration complète de l’asynchronisme au langage et à la bibliothèque standard ait du sens.
L’approche choisie fut donc d’intégrer au langage juste l’ensemble de fonctionnalités nécessaire pour obtenir les améliorations ergonomiques voulues, tout en laissant le gros du travail aux bibliothèques externes pré-existante.
Il en résulte une situation actuelle un peu complexe où pour comprendre l’asynchronisme en Rust, il faut comprendre à la fois des concepts au niveau du langage, de la bibliothèque standard et des bibliothèques externes. C’est pourquoi ce chapitre sera sans doute le plus difficile des chapitres applicatifs de ce cours… Mais je vais faire de mon mieux pour clarifier ça, un petit pas à la fois.
Types auto-référentiels
Pour commencer ce chapitre sur l’asynchronisme, nous devons évoquer un sujet qui n’est pas directement lié à l’asynchronisme, mais qui a émergé dans le cadre du développement de l’infrastructure associée, et dont l’asynchronisme reste aujourd’hui la principale application : les types auto-référentiels, dont les données contiennent des références vers elles-mêmes.
Problème
Considérons la déclaration de type suivante, utilisant une syntaxe imaginaire ressemblant à du Rust :
struct AutoReferentiel {
/// Données auxquelles on fait référence
donnee: i32,
/// Référence vers l'autre membre self.a de la structure
/// (la syntaxe 'self n'existe pas vraiment en Rust)
reference: &'self i32,
}
On pourrait penser qu’il est possible de la construire avec la syntaxe Rust standard, mais en réalité ce n’est pas possible avec du code safe, ni de façon simple…
struct AutoReferentiel<'donnees> {
donnees: i32,
reference: &'donnees i32,
}
let simple = AutoReferentiel {
donnees: 42,
reference: /* ??? */,
};
…ni de façon compliquée…
struct AutoReferentiel<'donnees> {
donnees: i32,
reference: &'donnees i32,
}
fn sournois() -> AutoReferentiel</* ??? */> {
let temporaire = 123;
let mut resultat = AutoReferentiel {
donnees: 42,
reference: &temporaire,
};
resultat.temporaire = &resultat.donnees;
resultat
}
Pour construire un tel type, on est forcé d’utiliser des pointeurs bruts, ce qui veut dire que pour l’utiliser, on aura besoin de code unsafe :
#![allow(unused)] fn main() { struct AutoReferentiel { donnees: i32, pointeur: *const i32, } // Construction let mut autoref = AutoReferentiel { donnees: 42, pointeur: std::ptr::null(), }; autoref.pointeur = &autoref.donnees; // Utilisation (ATTENTION : Lire ce qui suit avant de reprendre ce code !) println!("{}", unsafe { *autoref.pointeur }); }
Pourquoi a-t’on besoin d’unsafe ici ? Pour le comprendre, il nous faut nous
demander ce qui se passerait si la variable autoref
était déplacée.
Pour rappel, la façon dont Rust a transformé le déplacement de C++11, qui était
une fonctionnalité obscure, mal comprise et peu utilisée, en élément quotidien
du langage qui ne pose problème à personne, a été de spécifier que cette
opération se comporte comme un memcpy()
.
Pour la quasi totalité des types qu’on utilise au quotidien, dont les références et pointeurs désignent d’autres données en mémoire c’est une façon correcte de faire. Mais pas pour les données de types auto-référentiels. Lorsqu’on déplace une donnée auto-référentielle en mémoire, on change l’adresse de la donnée, donc on invalide les pointeurs internes qui continuent de pointer vers l’ancienne adresse de la donnée. Si l’on tente d’utiliser ces pointeurs à nouveau, il en résultera du comportement indéfini de type dangling pointer : le programme est invalide et fera n’importe quoi.
L’utilisation d’un type auto-référentiel défini de façon simple doit donc nécessiter du code unsafe en Rust, puisqu’elle est soumise à une précondition de sécurité qui est que les données de ce type ne doivent pas être déplacées entre l’initialisation d’un objet auto-référentiel et la fin de son utilisation.
Historique
Le problème des types auto-référentiels a été soulevé peu de temps après la stabilisation du langage Rust, dès que les programmeurs ont voulu retourner d’une fonction un type qui possède une donnée, mais se comporte comme une référence à cette donnée.
Supposons par exemple qu’on veuille retourner un type qui possède un tableau
[T; N]
, mais se comporte comme un &[T]
vers une partie du tableau. La
première idée qui vient est de créer un type qui est litéralement composé d’un
[T; N]
et d’un &[T]
vers le tableau, avec un accesseur qui permet d’obtenir
une copie du &[T]
. Mais malheureusement, un tel type est auto-référentiel.
Cependant, dans la plupart de ces situations, il existe une solution moins
élégante que de créer un type auto-référentiel. Par exemple, dans l’exemple
ci-dessus, on peut retourner un type composé d’un [T; N]
et un Range<usize>
d’indices, puis créer un &[T]
associé à la demande.
L’absence de types auto-référentiels n’est donc pas bloquante. Mais on a le sentiment de dépenser de l’énergie à contourner les limites du langage et c’est insatisfaisant. Ce qui a amené plus d’un membre de la communauté Rust à chercher une solution plus générale au problème, sous forme d’extension du langage ou de bibliothèque permettant l’utilisation de types auto-référentiels.
Grâce à ces recherches, on a aujourd’hui un certain recul critique vis à vis de ce problème, et de différentes solutions qui ont été historiquement proposées :
- Garder le
memcpy()
comme comportement par défaut du déplacement, mais permettre d’injecter un comportement plus compliqué si besoin à la manière des move constructors et move assignments de C++11.- C’est impossible sans briser la compatibilité avec une partie du code
unsafe existant qui présume qu’un
memcpy()
qui supprime l’accès à la source est toujours autorisé.
- C’est impossible sans briser la compatibilité avec une partie du code
unsafe existant qui présume qu’un
- Interdire le déplacement des données auto-référentielles.
- Si c’était fait de façon générale, dès la construction des données, cela rendrait ces données très difficiles à utiliser et nécessiterait d’autres ajouts complexes au langage, du même genre que le placement new et la return value optimization de C++.
- Néanmoins, on peut utiliser une variante moins contraignante de cette idée, comme on va le voir dans un instant.
- Utiliser des pointeurs relatifs (offsets) plutôt que des pointeurs absolus
dans les structures de données auto-référentielles.
- C’est possible et élégant, mais ça veut dire que les pointeurs relatifs vivent dans leur monde à eux et que l’interopérabilité avec les autres types pointeurs et références du langage Rust va nécessairement être difficile.
- Allouer systématiquement les données auto-référentielles sur le tas via
Box<T>
ou autre, et exposer une API limitée qui ne permet pas de déplacer les données situées à l’intérieur.- L’allocation systématique sur le tas n’est pas quelque chose qu’on souhaite imposer sans alternative comme primitive de base au niveau du langage, d’autant plus que certaines utilisations des données allouées sur la pile sont en réalité légales.
- Quand on limite l’API pour éviter les déplacements (qui sont possibles dès
lors qu’on dispose d’un
&mut
sur les données intérieures, via des APIs commestd::mem::replace()
), on tend à se restreindre à un certain domaine d’applications. - De très nombreuses bibliothèques ont tenté de produire une abstraction safe générale et se sont révélées plus tard avoir du comportement indéfini quand elles sont utilisées de la mauvaise façon. Si une solution existe, elle nécessite clairement du code très subtil. Et si vous comptez utiliser une de ces bibliothèques, prenez bien le temps de vous assurer qu’elle a été passée en revue par des experts indépendants sensibilisés au risques de comportement indéfini lié aux types auto-référentiels…
Toutes ces réflexions historiques ont fait que plus tard, quand les fonctions asynchrones se sont révélées nécessiter l’utilisation de types auto-référentiels, la communauté Rust avait déjà un bon recul critique sur ce problème. Ce qui a permis l’émergence d’une nouvelle façon d’aborder le problème, qui est devenue standard dans le domaine de l’asynchronisme en Rust : le pinning.
Pinning
Le type générique Pin<P>
,
où P est un genre de pointeur (&
, &mut
, *const
, *mut
, NonNull
, Box
,
Arc
, etc.), se base sur les observations suivantes :
- Si on n’arrive pas à discipliner les données auto-référentielles elles-mêmes, on peut essayer de discipliner les pointeurs vers ces données à la place.
- Si le coût ergonomique d’une interdiction permanente des déplacements est inacceptable, on peut interdire les déplacements seulement pendant la phase du programme où la partie auto-référentielle du type est en train d’être utilisée.
- Si on n’arrive pas à prouver automatiquement l’absence de mouvement à la compilation, on peut se rabattre sur une preuve manuelle, avec une interface unsafe.
- Si on accepte l’ensemble des observations ci-dessus, alors il n’y a pas besoin de beaucoup de support au niveau du compilateur, on peut presque tout faire dans une bibliothèque.
Partant de là, Pin<P>
agit comme un wrapper de P
qui limite l’utilisation
de P
de la façon suivante :
- Toutes les méthodes de
Pin<P>
qui pourraient exposer un&mut T
sur la valeur cible (donc permettre son déplacement) ou déplacer directement la valeur sont unsafe par défaut. Leur utilisateur doit garantir qu’aucun déplacement incorrect ne sera effectué. - Le compilateur implémente automatiquement un trait marqueur
Unpin
pour tous les types qui ne sont pas auto-référentiels (donc presque tous les types, avec quelques exceptions importantes que nous allons aborder plus loin). - Lorsqu’un type implémente
Unpin
, il n’y a pas de risques liés au déplacement, donc des variantes safe des méthodes permettant le déplacement et exposant&mut T
sont exposées.
Ces bases simples cachent un monde de complexité au niveau du détail, qui font
que la documentation du module std::pin
est de celles qu’on utilise pour
faire peur aux enfants. Mais en
pratique, l’abstraction suffit pour les besoins de la programmation asynchrone.
Et on a rarement besoin de la manipuler directement dans ce cadre, ce qui fait
que ce n’est pas trop gênant en pratique qu’elle soit difficile à bien utiliser.
Certains comme moi entretiennent toujours l’espoir qu’un jour, un théoricien
plus brillant que les autres trouvera une meilleure solution pour rendre les
types auto-référentiels plus faciles à utiliser en Rust. Mais pour l’heure,
Pin
est la solution qui existe, et qui permet une programmation asynchrone à
la fois performante et relativement ergonomique en Rust. Il est donc nécessaire
de comprendre un minimum sa logique pour faire de l’asynchronisme en Rust.
La crate futures
L’idée centrale de la programmation asynchrone, c’est qu’une routine d’entrée-sortie de l’OS puisse répondre au thread qui l’appelle “je n’ai pas la réponse tout de suite, va faire autre chose et reviens me voir plus tard” au lieu de bloquer le thread en attendant que l’opération soit terminée.
Il existe plusieurs manières de fournir une interface de plus haut niveau par dessus ce type d’API. Dans ce chapitre, nous allons explorer la notion de future, qui est l’approche plesbicitée en Rust.
Même si vous avez déjà utilisé des futures dans d’autres langages, y compris le
type std::future
de
C++, ne passez pas ce chapitre. Ce que Rust appelle une future est
différent de ce que la plupart des autres langages appellent une future, et il
est important que ayez une bonne compréhension du fonctionnement des futures de
Rust si vous comptez les utiliser.
Futures
De même que les opérations d’entrées/sorties asynchrones de l’OS rendent immédiatement la main à l’appelant, une façon classique d’exposer une interface asynchrone dans une bibliothèque haut niveau est d’avoir une API qui retourne immédiatement un objet “future”.
Cet objet représente la promesse d’un résultat futur. L’appelant peut s’en servir pour différentes choses, notamment déterminer si le résultat est arrivé, programmer d’autres opérations lorsque le résultat arrivera, ou attendre l’arrivée du résultat lorsqu’il n’y a plus rien d’autre à faire.
L’intérêt d’une abstraction future bien conçue, c’est qu’elle donne à l’appelant le contrôle de la stratégie d’attente du résultat de l’entrée/sortie, au lieu d’imposer une certaine stratégie pas toujours adaptée (par exemple devoir attendre que le résultat arrive sans pouvoir rien faire d’autre dans le thread actif, comme les APIs synchrones).
Rust utilise des futures, mais avec une spécificité qui est que l’API qui retourne une future ne fait pas d’entrées/sorties. C’est l’objet future lui-même qui initie le processus, de façon paresseuse, au moment où on commence à attendre que le résultat arrive. Cela a plusieurs intérêts :
- L’implémentation des futures peut être plus efficace. Avec les futures classiques, on a besoin d’une allocation tas et d’une couche de polymorphisme dynamique par étape d’entrée/sortie d’une tâche asynchrone. Alors qu’avec les futures paresseuses de Rust, on n’a besoin que d’une allocation tas et une couche de polymorphisme dynamique par tâche asynchrone, et dans certains cas on peut même s’en tirer sans allocation tas ni vtable.
- On peut plus facilement gérer l’annulation des tâches asynchrones en cours ainsi que la backpressure (quand un serveur est surchargé, il accepte moins de tâches du client).
- Si l’implémentation d’une future a besoin de données auto-référentielles (nous allons voir plus tard pourquoi), elle n’en a pas besoin dès que la future est créée, mais seulement à partir du moment où la tâche asynchrone est lancée. Avant ça, la future peut être déplacée librement.
Le coeur de l’interface des futures est aujourd’hui disponible dans la
bibliothèque standard, via le trait
Future
. Mais comme
nous allons le voir dans ce qui suit, cette interface de base est très simple
et bas niveau. Toutes les fonctionnalités plus avancées des futures
sont implémentées dans la crate
futures
, au sein de laquelle les
futures Rust ont été initialement mises au point.
Par exemple, en important futures::prelude::*
, on gagne un accès aux
extensions
FutureExt
qui permettent de programmer davantage de travail à la suite de la tâche
asynchrone associée à une Future
, ainsi qu’au trait
TryFuture
et ses extensions
TryFutureExt
qui facilitent la manipulation de futures représentant des opérations
asynchrones faillibles.
Il est probable qu’un jour, davantage de fonctionnalités de futures
soient
intégrées à la bibliothèque standard, mais à l’heure actuelle on ne peut pas
dire quand ça se produira.
Tâches asynchrones
La base de l’interface des futures en Rust est une méthode
poll()
ayant la signature suivante :
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>
On constate d’abord cette méthode prend son paramètre par référence Pin<&mut Self>
. Cela garantit à l’implémentation de la future qu’un appelant ne va pas
déplacer la future entre deux appels à poll()
, et permet donc à
l’implémentation de stocker des données auto-référentielles à l’intérieur de
l’objet future à partir du premier appel à poll()
.
Lorsque la méthode poll()
est appelée, elle retourne un objet de type
énuméré
Poll<Self::Output>
, où
Self::Output
est le type du résultat final de la tâche asynchrone. Le type
énuméré Poll
est défini de la façon suivante :
#![allow(unused)] fn main() { pub enum Poll<T> { Ready(T), Pending, } }
Ces deux variantes représentent deux scénarios possibles pour l’appelant de
poll()
:
- Si
poll()
retournePoll::Ready
, la tâche asynchrone est terminée et son résultat est retourné à l’appelant. A partir de ce moment, la méthodepoll()
ne doit plus être utilisée. - Si
poll()
retournePoll::Pending
, la tâche asynchrone a atteint un point bloquant (entrée/sortie). On doit attendre que ce point bloquant soit passé, puis rappelerpoll()
.
Le mécanisme qui permet d’attendre que le point bloquant soit passé est
l’argument Context
de poll()
. Ce Context
donne accès à un
Waker
qui permet à
l’implémentation de la tâche asynchrone de signaler quand le résultat de
l’entrée/sortie asynchrone est disponible, afin que la méthode poll()
de la
future associée soit rappelée pour continuer la tâche asynchrone.
On le voit, tout ça est plutôt bas niveau, ce n’est pas le style d’interface qu’on a envie d’utiliser directement au quotidien. La façon normale d’utiliser des futures en Rust est de passer par une bibliothèque fournissant un ensemble d’opérations d’entrées/sorties retournant des futures ainsi qu’un moyen d’ordonnancer l’exécution de tâches asynchrones. On parle de runtime asynchrone.
Il existe plusieurs runtimes asynchrones. Certains sont généralistes, d’autres
sont spécialisés pour des domaines précis d’applications (par exemple, les
systèmes résilients aux pannes, ou
les systèmes embarqués sans OS). Dans
les chapitres suivants, nous ferons appel à
tokio
, le runtime asynchrone le plus
utilisé aujourd’hui.
Mais avant de nous focaliser davantage sur tokio
, nous allons d’abord terminer
notre tour des primitives de bases de l’asynchronisme en Rust, en parlant un peu
des autres fonctionnalités communes fournies par la crate futures
.
Flux de données typés
Souvent, quand on fait des opérations d’entrées/sorties, on ne lit pas qu’une seule donnée. On lit plutôt un flux continu de données du même type : paquets UDP, blocs d’octets, requêtes HTTP, etc. Et de même, on n’écrit pas qu’une seule données, on écrit un flux continu de données similaires.
On pourrait représenter chaque opération de lecture/écriture comme une opération asynchrone complètement indépendante, à laquelle on associe une future dédiée. Mais il est à la fois plus ergonomique et plus efficace au niveau de l’implémentation d’avoir une abstraction dédiée pour cette situation, analogue aux itérateurs de la bibliothèque standard.
Cette abstraction, c’est
Stream
pour
les données d’entrée, et
Sink
pour les
données de sortie.
Stream
est une généralisation asynchrone de Iterator
. Un Stream
de T
se
comporte comme une future réutilisable de Option<T>
: tant que l’opération
poll_next()
produit des Poll::Ready(Some(x))
, on peut continuer de l’appeler. C’est
seulement quand on reçoit un Poll::Ready(None)
que le flux d’entrée est tari
et qu’on doit arrêter d’appeler poll_next()
.
A l’inverse, un Sink
de T
accepte des valeurs de type T
en entrée via le
protocole suivant :
- D’abord, on doit utiliser l’opération asynchrone
poll_ready()
pour attendre que leSink
soit prêt à accepter une nouvelle valeur en entrée. - Quand
poll_ready()
indique que leSink
est prêt, on peut lui soumettre une nouvelle valeur à envoyer avecstart_send()
. - Le cycle
poll_ready()
/start_send()
peut être répété autant de fois que nécessaire pour envoyer toutes les valeurs souhaitées - Un
Sink
est autorisé à accumuler des données en interne avant envoi. Lorsqu’on veut s’assurer que toutes les données ont bien été envoyées, on utilisepoll_flush()
si on veut envoyer d’autres données par la suite, oupoll_close()
si on a terminé.
De la même façon que la crate futures
complète le trait bas niveau Future
par des extensions
FutureExt
et
TryFutureExt
qui l’enrichissent avec des opérations plus haut niveau, les traits bas niveau
Stream
et Sink
sont complétés par des extensions
StreamExt
,
TryStreamExt
et
SinkExt
qui
les enrichissent avec des opérations plus haut niveau.
Par exemple
SinkExt::send_all()
permet de récupérer l’ensemble des valeurs d’un Stream
, les envoyer à un
Sink
, et récupérer une Future
qui sera résolue lorsque ce travail sera
terminé.
Entrées/sorties asynchrones
Parmi les flux de données, le cas particulier des flux d’octets faillibles est important, car c’est en ces termes que sont exprimées les APIs d’entrées/sorties asynchrones de tous les systèmes d’exploitation couramment utilisés.
Ce ne serait pas efficace de représenter ces flux d’octets par un
TryStream
ou un Sink
de u8
, car on devrait initier une transaction d’entrée/sortie
asynchrone au niveau du système pour chaque octet envoyé ou reçu, ce qui ferait
bien trop de transactions.
A la place, la crate futures
fournit donc les traits
AsyncRead
,
AsyncBufRead
,
AsyncWrite
et
AsyncSeek
,
qui reprennent l’interface des traits Read
, BufRead
, Write
et
Seek
de la bibliothèque standard mais sous une forme qui se prête aux
entrées/sorties asynchrone.
Selon une logique désormais habituelle, ces traits sont complétés par des
extensions
AsyncReadExt
,
AsyncBufReadExt
,
AsyncWriteExt
et
AsyncSeekExt
,
qui donnent des fonctionnalités plus haut niveau aux implémentations des
traits AsyncXyz
.
Composition d’opérations asynchrones
Il est courant de vouloir combiner plusieurs opérations asynchrones. Par exemple, on peut vouloir construire une opération asynchrone qui attend que les opérations asynchrones représentées par trois futures a, b et c soient toutes terminées (opération join). Ou bien on peut attendre qu’une de ces opérations soit terminée (opération select).
futures
fournit plusieurs implémentations de join et select qui répondent
à différents besoins : nombre de futures connu à l’avance ou pas, besoin de
prioriser certaines opérations par rapport à d’autres ou pas, etc. Une partie de
ces opérations est implémentée sous forme de
macros, le reste dans
des modules dédiés (ex : module
future
pour la
composition de futures).
Pour donner un exemple d’application, une attente d’entrée/sortie asynchrone avec timeout est typiquement implémentée par un select entre une futures d’entrée/sortie asynchrone et une future de timeout. Si le timeout se termine en premier, la future d’entrée/sortie asynchrone est jetée, ce qui déclenche l’annulation de l’opération sous-jacente.
Il est important de bien comprendre que ces combinateurs n’introduisent pas de
parallélisme dans l’exécution de code. Si plusieurs futures dans un join ou
un select sont prêtes, leurs méthodes poll()
seront appelées
séquentiellement, les unes après les autres, sur un seul coeur CPU. Nous allons
voir un peu plus loin comment faire quand ce n’est pas ce qu’on veut.
Ce que join et select permettent, en revanche, c’est d’introduire de la concurrence, donc d’attendre plusieurs événements simultanément. Par exemple en utilisant join, on peut lancer plusieurs entrées/sorties asynchrones au niveau du système d’exploitation et attendre que l’ensemble des résultats de ces entrées/sorties soient disponibles avant de continuer.
Exécuteurs et synchronisation
Introduction
On l’a vu précédemment, pour exécuter une tâche asynchrone, on appele à
plusieurs reprises la méthode poll()
de la future associée, jusqu’à ce
qu’elle retourne Poll::Ready
, en attendant entre chaque Poll::Pending
le
signal d’un Waker
.
Les autres abstractions de la crate futures
dont nous avons discuté
(Stream
, Sink
, AsyncRead
, etc.) utilisent un principe similaire, donc
dans la suite nous allons nous concentrer sur le cas des futures, mais la
plupart de ce qui va être dit sera applicable au reste.
Le composant d’un runtime asynchrone qui se charge d’exécuter des tâches
asynchrones est appelé un exécuteur, et la crate futures
fournit des
exécuteurs simples qui peuvent être utilisé quand on n’a pas besoin
d’un runtime asynchrone plus complet. Nous allons maintenant étudier les
stratégies d’exécution proposées par la crate futures
, par ordre de
complexité croissante.
Exécution bloquante
Avec futures::executor::block_on()
,
on exécute une future de façon bloquante. A chaque fois que la méthode
poll()
de la future retourne Poll::Pending
, le thread actif s’arrête en
attendant que la situation se débloque. Et lorsque poll()
finit par émettre le
résultat final via Poll::Ready
, l’exécution de block_on()
se termine en
retournant ce résultat à l’appelant.
A quoi cela peut-il bien servir de s’embêter à écrire du code asynchrone pour
finalement le rendre synchrone ? Typiquement lorsqu’on a besoin d’un résultat
tout de suite pour “nourrir” une API synchrone, ou bien à la fin de la fonction
main()
pour s’assurer que l’ensemble des tâches asynchrones qu’on a préparé
précédemment soit bien exécuté.
Ainsi, un programme asynchrone typique va souvent commencer par créer une grosse
future représentant l’ensemble des tâches qu’il a à traiter, puis effectuer un
appel block_on()
sur cette future pour exécuter toutes ces tâches avant de
s’arrêter.
Exécution séquentielle
L’utilisation efficace de block_on()
nous impose une gymnastique désagréable
avec les combinateurs join et select, pour nous assurer que l’ensemble des
futures que nous voulons exécuter soit bien couvert par l’attente que nous nous
préparons à effectuer.
Une approche plus élégante est d’utiliser
LocalPool
,
qui nous permet de programmer l’exécution d’un certain nombre de futures au sein
du thread actif via un objet de type
LocalSpawner
, obtenu via la méthode
pool.spawner()
et qui implémente les traits
Spawn
et
LocalSpawn
,
complétés par les extensions plus haut niveau
SpawnExt
et
LocalSpawnExt
.
On peut utiliser des méthodes de ces traits, comme
LocalSpawnExt::spawn_local()
,
pour programmer l’exécution de tâches asynchrones au sein de la LocalPool
.
Une fois qu’on a soumis des tâches via le mécanisme du LocalSpawner
,
LocalPool
permet de les exécuter de différentes façons :
- Avec
run()
, on peut exécuter complètement l’ensemble des futures soumises précédemment, comme si on avait appeléblock_on()
sur le join de ces futures. - Avec
run_until()
, on peut exécuter l’ensemble des futures soumises précédmment jusqu’à ce qu’une future nouvellement soumise se soit complètement exécutée, ce qui nécessiterait une combinaison complexe de join, select etblock_on()
si on tentait de le faire sansLocalPool
. - Avec
try_run_one()
etrun_until_stall()
, on peut exécuter l’ensemble des futures soumises précédemment jusqu’à ce qu’une tâche se termine (pourtry_run_one()
) ou que l’ensemble des tâches atteigne un point où il n’y a plus rien d’autre à faire qu’attendre.
Comme les combinateurs, LocalPool
n’introduit pas de parallélisme, juste de la
concurrence : les méthodes poll()
des futures qui sont prêtes à s’exécuter
sont appelées les unes après les autres, sur un seul coeur CPU. Tout ce que
fait LocalPool
, on pourrait le faire avec des combinateurs et block_on()
,
c’est juste (beaucoup) plus simple avec l’aide de LocalPool
.
Exécution parallèle
Nous l’avons dit plusieurs fois, quand on utilise block_on()
avec des
combinateurs ou LocalPool
on n’a que de la concurrence (attendre plusieurs
choses en même temps) et pas du parallélisme (exécuter plusieurs morceaux du
code de notre programme en même temps).
Pour exécuter nos tâches asynchrones de façon parallèle, on peut utiliser un
autre exécuteur fourni par la crate futures
, appelé
ThreadPool
.
Comme son nom l’indique, cet exécuteur gère un groupe de threads, par défaut
un par hyperthread CPU. On peut lui soumettre des tâches via la méthode
pool.spawn_ok()
,
et ces tâches s’exécuteront en parallèle sur les threads de l’exécuteur.
Si on compare ThreadPool
à LocalPool
…
ThreadPool
n’accepte que des futuresSend
pouvant être transmises à un autre thread pour exécution. C’est tout à fait logique, mais ça signifie qu’on ne peut plus utiliser des abstractions non thread-safe commeRc
, ce qui peut nécessiter quelques modifications du code.ThreadPool
ne fournit aucun moyen de se synchroniser avec la tâche en cours d’exécution, pas même de savoir quand est-ce qu’elle se termine. C’est à nous d’implémenter la synchronisation nécessaire.
Dans l’ensemble, la ThreadPool
de futures
est bâtie pour la simplicité
(son implémentation tient en quelques centaines de ligne de code), pas pour
l’ergonomie ni pour la performance. Cet exécuteur suffit pour des besoins
simples, mais toute application un tant soit peu complexe gagnera fortement à
utiliser l’exécuteur parallèle plus sophistiqué fourni par un runtime complet
comme tokio
, qui sera plus facile à utiliser, aura moins d’overhead, et
passera mieux à l’échelle sur des systèmes avec beaucoup de coeurs CPU.
Synchronisation
Dès lors qu’on a de l’exécution parallèle de tâches sur plusieurs threads, on
a besoin de synchronisation entre threads. On l’a vu, dans le cas de la
ThreadPool
simple de futures
ce besoin émerge ne serait-ce que pour attendre
que nos tâches qui s’exécutent en parallèle se terminent.
Dans un contexte de programmation asynchrone, c’est plus complexe que d’habitude de se synchroniser, car on doit éviter d’utiliser des primitives de synchronisation bloquantes basées sur le fait d’attendre au sein du thread actif qu’un autre thread fasse quelque chose. En effet, toute l’idée de la programmation asynchrone parallèle, c’est de travailler avec un nombre minimum de threads (un par coeur ou hyperthread CPU), donc si on commence à bloquer lesdits threads…
- Au mieux, on va arrêter d’utiliser certains de nos coeurs CPU, donc perdre en efficacité.
- Au pire, on va se retrouver dans une situation où tous nos threads attendent qu’une autre tâche asynchrone s’exécute pour continuer, mais plus aucune tâche ne peut s’exécuter car tous les threads sont bloqués, et donc l’exécution asynchrone sera définitivement bloquée.
On privilégiera donc l’utilisation de primitives de synchronisation adaptées à
la programmation asynchrone, où les opérations qui seraient normalement
bloquantes deviennent des opérations asynchrones. La crate futures
en
fournit quelques unes :
- Dans le module
channel
, on retrouve une variante asynchrone des files d’attentempsc
de la bibliothèque standard, ainsi qu’une variante spécialiséeoneshot
qui sert à envoyer une seule valeur, une seule fois, d’une tâches asynchrone à une autre.- La primitive
oneshot
est exactement ce dont on a besoin pour récupérer les résultats de tâches créées par des APIs de typeThreadPool::spawn_ok()
.
- La primitive
- Dans le module
lock
, on retrouve une variante asynchrone du typeMutex
de la bibliothèque standard, ainsi qu’un type plus spécialiséBiLock
optimisé pour le cas où il n’y a que deux tâches qui partagent une donnée.
Runtimes et interopérabilité
Nous avons maintenant terminé notre tour de la crate futures
, qui pose les
bases communes de l’asynchronisme en Rust sur lesquelles tout le monde s’accorde.
Mais pour faire quoi que ce soit d’utile avec de l’asynchronisme, il va nous falloir aussi des implémentations concrètes d’opérations asynchrones, par exemple…
- Des primitives pour faire des entrées/sorties asynchrones sur le disque, en réseau…
- Un système de minuteur permettant d’attendre des deadlines, de fixer des timeouts…
- Des interfaces asynchrones vers d’autres fonctionnalités systèmes importantes : création et attente de processus enfants, gestion des signaux Unix, logging dans la console…
De plus, nous avons aussi mentionné que le support de l’exécution parallèle
directement fourni par futures
est minimal, et qu’on gagnera souvent à le
remplacer par d’autres exécuteurs plus ergonomiques, plus économes en CPU, et
passant mieux à l’échelle.
Un runtime asynchrone est une bibliothèque qui répond à tous ces besoins. On
l’a évoqué précédemment, il en existe plusieurs, plus ou moins spécialisés dans
un domaine d’application. Dans la suite de ce cours, nous allons faire appel
au runtime généraliste le plus utilisé,
tokio
.
Dans un monde idéal, le runtime que vous utilisez n’aurait pas d’importance, et vous pourriez librement envoyer les futures d’un runtime à l’exécuteur d’un autre. C’est l’objectif de long terme, mais malheureusement à l’heure où ces lignes sont écrites nous n’en sommes pas là. Il faut donc éviter de mélanger plusieurs runtimes dans votre code sous peine de rencontrer des problèmes bizarres (ex : tâches d’entrée/sortie qui se bloquent indéfiniment car la routine d’attente groupée de leur runtime n’est pas appelée) ou des problèmes d’oversubscription (ex : les exécuteurs parallèles de deux runtimes se battent pour le temps CPU d’un même coeur CPU).
Avant de vous lancer dans un projet utilisant l’asynchronisme, je vous
recommande donc de commencer par étudier les bibliothèques asynchrones utiles à
votre projet, les runtimes que chacune supporte, en choisir un qui met tout
le monde d’accord, et essayer de vous y tenir. En cas de doute, privilégiez les
runtimes les plus populaires comme tokio
: c’est ceux que vos bibliothèques
métier auront le plus de chances de supporter.
Premier exemple
Mise en situation
Supposons qu’on veuille afficher des messages sur la console, mais en attendant un certain temps. Avec un message unique, c’est facile à faire de façon synchrone…
#![allow(unused)] fn main() { use std::time::Duration; // Définition du message const DELAI: Duration = Duration::from_millis(100); const MESSAGE: &str = "Bonjour à tous"; // Attente std::thread::sleep(DELAI); // Affichage println!("Message : {MESSAGE}"); }
…mais à partir du moment où on veut le faire avec plusieurs messages, il faut réfléchir un peu plus. Dans ce cas particulier, on peut s’en tirer en triant les messages par durée d’attente croissante, et en raisonnant en termes de deadline plutôt que de durée…
#![allow(unused)] fn main() { use std::time::{Duration, Instant}; // Définition des messages let messages = [(Duration::from_millis(100), "...à tous"), (Duration::from_millis(50), "Bonjour...")]; // Transformation des délais en deadlines let debut = Instant::now(); let mut messages = messages.map(|(duree, texte)| (debut + duree, texte)); // Tri par deadline croissante messages.sort_by_key(|(deadline, _)| *deadline); // Attentes et affichages for (deadline, texte) in messages { std::thread::sleep(deadline.saturating_duration_since(Instant::now())); println!("Temps écoulé : {:?}", debut.elapsed()); println!("Message : {texte}"); } }
…mais ce n’est pas une approche générisable à toute tâche asynchrone : elle ne fonctionne que quand on connaît le délai d’attente à l’avance. Dans le cas général, si nous voulons gérer plusieurs tâches concurrentes, il nous faut un moyen de garder de côté l’état des différentes tâches en vol, et de basculer entre les différentes tâches au fur et à mesure que les différentes attentes se terminent.
Version avec des threads
On peut résoudre le problème de façon générale et relativement simple avec des threads…
#![allow(unused)] fn main() { use std::io::Write; use std::time::{Duration, Instant}; // Définition des messages let messages = [(Duration::from_millis(100), "...à tous"), (Duration::from_millis(50), "Bonjour...")]; // Transformation des délais en deadlines let debut = Instant::now(); let messages = messages.map(|(duree, texte)| (debut + duree, texte)); // Affichage temporisé d'un message // ATTENTION : println!() ne synchroniserait pas à la bonne granularité let afficher_message = move |(deadline, texte): (Instant, &str)| { std::thread::sleep(deadline.saturating_duration_since(Instant::now())); let mut stdout = std::io::stdout().lock(); writeln!(&mut stdout, "Temps écoulé : {:?}", debut.elapsed())?; writeln!(&mut stdout, "Message : {texte}") }; // Lancement des threads std::thread::scope(|s| { for message in messages { s.spawn(move || afficher_message(message).expect("Echec de l'affichage")); } }); }
…et pour un nombre faible de tâches concurrentes, c’est une bonne solution : c’est simple, ça fait le travail demandé, et le système d’exploitation se charge de tout le sale boulot de gérer les différentes tâches en vol, les mettre en attente quand il faut, et les réveiller quand l’attente est terminée.
Malheureusement, cette approche ne passe pas bien à l’échelle quand le nombre de threads augmente, pour plusieurs raisons :
- D’abord, à chaque thread est associé à un état interne relativement imposant, incluant notamment l’intégralité des données stockées sur sa pile. Tout ça prend de la place en RAM, souvent bien plus de place que la tâche en cours n’en a vraiment besoin.
- Ensuite, au niveau du système d’exploitation, ça peut être coûteux au niveau CPU de créer, gérer et détruire un grand nombre de threads. Certains systèmes d’exploitation comme Linux font leur possible pour rendre ces opérations aussi efficaces que possible, d’autres comme Windows… y dépensent moins d’énergie. Mais dans tous les cas, ça va finir par représenter un coût conséquent quand on a un grand nombre de tâches concurrentes à traiter, chacune ne faisant qu’un travail relativement simple.
- Enfin, l’OS tente d’exécuter les threads en parallèle même quand ça n’a pas
d’intérêt pour les performances, ce qui nous force à payer le coût en
complexité et temps d’exécution d’une synchronisation entre threads
(ici verouiller
stdout
à la bonne granularité) alors qu’on n’y gagnera rien au niveau des performances d’exécution.
Nous allons donc maintenant voir comment on peut gagner en efficacité en gérant la concurrence nous-même plutôt que de déléguer ce travail au mécanisme de threads de l’OS.
Première version asynchrone
Ajoutons maintenant la crate utilitaire futures
et le runtime tokio
comme dépendances…
cargo add futures
cargo add tokio --features full
…et modifions un peu notre programme pour utiliser des tâches asynchrones
tokio
à la place des threads du système d’exploitation :
// NOUVEAU : On active les extensions de Future fournies par la crate futures
use futures::prelude::*;
use std::time::Duration;
use tokio::{runtime::Runtime, time::Instant};
// NOUVEAU : On initialise et active le runtime tokio
let runtime = Runtime::new().expect("Echec d'initialisation du runtime");
let _garde = runtime.enter();
// Définition des messages
let messages = [
(Duration::from_millis(100), "...à tous"),
(Duration::from_millis(50), "Bonjour..."),
];
// Transformation des délais en deadlines
let debut = Instant::now();
let messages = messages.map(|(duree, texte)| (debut + duree, texte));
// Création des futures d'attentes et d'affichage
let mut futures = Vec::new();
for (deadline, texte) in messages {
// NOUVEAU : On crée des futures et on les garde de côté
futures.push(
// NOUVEAU : On utilise le sleep_until() de tokio pour obtenir une
// future qui attend jusqu'à une deadline.
tokio::time::sleep_until(deadline)
// NOUVEAU : On utilise la méthode map() fournie par la crate
// futures pour programmer du travail après l'attente.
.map(move |()| {
// ATTENTION : Il y a un problème ici, on va revenir dessus
println!("Temps écoulé : {:?}", debut.elapsed());
println!("Message : {texte}");
}),
);
}
// NOUVEAU : On combine toutes les futures d'attente en une seule future
let attente = future::join_all(futures);
// NOUVEAU : On exécute cette future combinée de façon synchrone
runtime.block_on(attente);
Quoi de neuf dans cet exemple ?
- Avec l’import de
futures::prelude::*
, on active l’ensemble des utilitaires communs fournis par la cratefutures
pour manipuler des futures. - On initialise le runtime
tokio
pendant la phase d’initialisation de notre programme. Commetokio
utilise le thread-local storage pour les accès au runtime, on doit aussi installer le runtime au sein du thread actif pour qu’il soit disponible par la suite lorsqu’on appellera l’APItokio
. - On utilise la fonction
sleep_until()
de tokio pour obtenir une future qui représente une tâche asynchrone dont l’exécution se terminera lorsqu’une certaine deadline sera dépassée. - On utilise la méthode
future.map()
fournie par la cratefutures
pour programmer l’affichage de texte après que la deadline soit atteinte, et obtenir une nouvelle future dont l’exécution sera considérée comme terminée après attente ET affichage du message. - On utilise la fonction
join_all()
fournie par la cratefutures
pour créer une future combinée qui représente l’exécution concurrente de toutes nos tâches asynchrones. - On utilise le runtime tokio pour exécuter la future combinée retournée par
join_all()
de façon synchrone, ce qui déclenche l’exécution concurrente de toutes nos tâches.
Avec cette approche, quel que soit le nombre de messages que l’on veuille
afficher, le runtime tokio
travaillera à nombre de threads constant.
Toute la concurrence entre les tâches sera purement gérée via les APIs du
système d’exploitation, qui permettent d’attendre plusieurs deadlines ou
plusieurs opérations d’entrées/sorties avec un seul appel système. Le risque
d’explosion de la consommation mémoire et de l’overhead CPU quand le
nombre de tâches en vol augmente est donc beaucoup plus faible que quand on
utilise un thread par tâche.
Si vous avez compris cet exemple, félicitations, vous savez maintenant comment
on faisait de la programmation asynchrone en 2016, lorsque les futures Rust
venaient de sortir. Dans les chapitres suivants, nous allons voir comment le
Rust moderne simplifie grandement l’écriture de code asynchrone, grâce à la
syntaxe async/await
introduite en 2018.
Mais avant ça, nous devons résoudre un petit problème du code ci-dessus.
Opérations bloquantes
Dans l’exemple ci-dessus, notre utilisation de future.map()
n’est pas
correcte, car nous détournons cette fonction pour faire quelque chose que l’on
doit éviter à tout prix en programmation asynchrone : appeler une opération
bloquante, en l’occurence println!()
.
tokio::time::sleep_until(deadline)
.map(move |()| {
// PROBLEME : On ne devrait pas utiliser println!() ici
println!("Temps écoulé : {:?}", debut.elapsed());
println!("Message : {texte}");
})
L’idée centrale de l’asynchronisme, c’est d’utiliser le nombre minimal de threads possibles pour tirer pleinement parti du matériel. Mais cette stratégie ne fonctionne que si les threads n’exécutent que des opérations non bloquantes. Si ils commencent à exécuter des opérations bloquantes, alors…
- Au mieux, la performance va se dégrader car on n’utilise plus tous les coeurs CPUs.
- Au pire, le programme va se bloquer car tous les threads du runtime sont bloqués en attendant que d’autres tâches asynchrones progressent, tandis que ces tâches ne peuvent pas progresser car il n’y a plus de threads disponibles pour les exécuter.
On dont donc s’assurer que le code voué à s’exécuter de façon asynchrone évite
au maximum l’utilisation d’opérations bloquantes. Ni entrées-sorties bloquantes,
ni opérations bloquantes (ex : attendre N secondes via std::thread::sleep()
),
ni primitives bloquantes de synchronisation entre threads, autant que faire se
peut. C’est mauvais à petite dose, et c’est mortel à haute dose.
Par quoi remplacer ces opérations bloquantes ? Tout dépend de l’opération en question :
- Parfois, ça a du sens de les exécuter en parallèle via une réserve
de threads dédiés aux tâches bloquantes, avec des outils comme le
spawn_blocking()
detokio
. - Parfois, les opérations sont bloquantes parce qu’elles sont sérialisées entre les threads. Dans ce cas, ça n’a pas de sens de les exécuter en parallèle, il est préférable d’avoir un thread unique dédié à ces opérations et de lui soumettre du travail via une file d’attente.
- Et parfois, il existe une alternative non-bloquante aux opérations bloquantes qu’on essaie d’utiliser, et on peut tout simplement utiliser cette alternative. C’est souvent la voie royale au niveau des performances, mais hélas ce n’est pas toujours la voie la plus simple.
Asynchronisme complet
Dans le cas d’un accès à stdout
comme println!()
, nous sommes dans le
cas où on peut tout faire de façon asynchrone, moyennant quelques précautions :
tokio
nous fournit un moyen non-bloquant d’écrire dansstdout
. Mais nous sommes avertis que cette alternative àprintln!()
n’est pas synchronisée, donc si plusieurs tâches asynchrones tentent d’écrire surstdout
en même temps, leurs sorties seront mélangées.tokio
nous fournit également des alternatives asynchrones aux primitives de synchronisation entre threads de la bibliothèque standard, que nous pouvons utiliser pour rétablir la synchronisation des accès àstdout
si besoin.
De façon surprenante, pour l’exemple qui nous occupe ici, nous n’avons pas
besoin de cette seconde précaution. En effet, si l’on y regarde de plus prêt, à
la fin du programme nous ne confions au runtime tokio
qu’une seule tâche à
exécuter :
// Construction d'une future composite
let attente = future::join_all(futures);
// Attente de la future composite
runtime.block_on(attente);
Certes, cette tâche attend plusieurs opérations asynchrones de façon
concurrente. Mais tel que le programme est écrit actuellement, le code associé à
ces opérations n’est pas exécuté de façon concurrente. Seule l’attente est
concurrente, les println!()
sont séquentiels (ce qui n’est pas un problème ici
car ça n’a pas beaucoup d’intérêt d’exécuter println!()
en parallèle).
Et donc il suffit de modifier le code comme ceci pour respecter les règles de l’asynchronisme :
use tokio::io::AsyncWriteExt;
// ... le début est comme avant ...
// Création des futures d'attentes et d'affichage
let mut futures = Vec::new();
for (deadline, texte) in messages {
futures.push(
tokio::time::sleep_until(deadline)
// NOUVEAU : then permet de programmer l'exécution d'une opération
// asynchrone à la suite d'une autre opération asynchrone,
// et async/await permet l'emprunt de "sortie".
.then(move |()| async move {
let sortie = format!(
"Temps écoulé : {:?}\n\
Message : {texte}\n",
debut.elapsed()
);
let mut stdout = tokio::io::stdout();
stdout.write_all(sortie.as_bytes()).await
}),
);
}
// NOUVEAU : try_join_all part de plusieurs futures de `Result<T, E>` et
// produit un `Result<Vec<T>, E>`.
let attente = future::try_join_all(futures);
// Comme avant, mais avec une gestion des erreurs
runtime.block_on(attente).expect("Erreur d'entrée/sortie");
Quoi de neuf dans cette version modifiée ?
- On utilise
future.then()
au lieu defuture.map()
, car l’écriture surstdout
est désormais une opération asynchrone qui retourne une future. - A l’intérieur de
future.then()
, on utiliseasync
/await
pour permettre l’emprunt du tamponsortie
durant l’écriture surstdout
. Nous reviendrons prochainement sur cette possibilité. - Ni
tokio
nifutures
ne fournissent d’alternative àprintln!()
, donc nous devons pré-calculer le texte avecformat!()
avant de l’envoyer surstdout
. - Les entrées/sorties sur
stdout
sont traitées comme faillibles partokio
, donc nous devons introduire une gestion des erreurs d’entrées/sorties.
On le voit, il est possible d’éviter des opérations bloquantes, mais ce n’est pas forcément simple vu l’omniprésence de ces opérations est l’immaturité relative des alternatives. Je ne vous cacherai pas qu’en pratique, c’est souvent l’aspect le plus rébarbatif de la programmation asynchrone.
async
/await
Aux premiers temps de la programmation asynchrone en Rust, on faisait tout avec des futures, des combinateurs et des runtimes asynchrones comme dans l’exemple précédent (dernière section exceptée). Mais cette approche n’était pas pleinement satisfaisante car…
- Le code qui utilise des futures et des combinateurs était trop différent de sa version synchrone. En particulier, le flux de contrôle asynchrone (if/else via select, gestion des erreurs d’entrées/sorties asynchrones…) était trop difficile à gérer.
- On ne pouvait pas utiliser l’emprunt et les références dans le code asynchrone
(ex : allouer un tampon d’octets alloué sur la pile, effectuer un
AsyncRead
qui écrit des octets dans ce tampon via un&mut [u8]
, puis utiliser les données fraîchement écrites dans le tampon). Il fallait tout faire avec des données allouées sur le tas, ce qui était inefficace et peu ergonomique.
Pour résoudre ces problèmes d’ergonomie et d’efficacité, Rust a introduit vers
2018 le mécanisme async
/await
. Ce dernier est très rapidement devenu la
façon dominante d’écrire du code asynchrone, au point que le code utilisant
directement des futures comme celui du chapitre précédent est aujourd’hui
considéré comme un peu has been, exotique et dérangeant. Dans ce chapitre,
nous allons donc voir ce que fait async
/await
et comment l’utiliser pour
rendre le code du chapitre précédent presque aussi clair que celui d’une
application utilisant des threads.
Enjeux de conception
Toute intégration de la programmation asynchrone à un langage de programmation doit se positionner quelque part entre deux extrêmes :
- A un extrême, on peut faire comme Go et avoir du code asynchrone qui ressemble exactement au code synchrone dans d’autres langages. Mais le prix à payer est que toutes les opérations d’entrée/sortie de la bibliothèque standard doivent être asynchrones, l’interopérabilité avec les autres langages de programmation est réduite, et le coût d’une tâche asynchrone en vol est relativement élevé (quoiqu’un peu plus faible que celui d’un thread d’OS).
- A l’autre extrême, on peut faire comme les programmeurs C et utiliser directement les APIs asynchrones de l’OS sans support du langage. Avec cette approche, on a un contrôle total sur l’utilisation des APIs et on peut faire en sorte d’en faire un usage aussi optimal que possible. Mais le code qu’on écrit n’a pas grand chose à voir avec du code synchrone, il tient plus de la machine à états codée à la main. C’est laborieux, et l’erreur est fréquente.
Comme d’autres langages, Rust opte pour le compromis du async/await
:
- Le code asynchrone doit être annoté avec le mot-clé
async
. Il est soumis à quelques restrictions (ex : pas de récursion directe), retourne une future, et ne peut être appelé directement par une partie synchrone du programme. Les programmeurs Go sont fans. - Les opérations potentiellement bloquantes doivent être annotées avec le
mot-clé
await
, ce qui clarifie les points d’attente pour le lecteur et le compilateur. Ayant connaissance de ces points d’attente, le compilateur peut transformer du codeasync
ressemblant furieusement à du code synchrone en une machine à état aussi efficace que celle que l’on aurait codé à la main en C (ou en utilisant des combinateurs de futures en Rust).
Cependant, l’implémentation d’async
/await
de Rust tire parti des
spécificités de l’écosystème asynchrone de ce langage, et notamment du fait que
les futures de Rust sont mieux conçues que celles de la plupart des autres
langages (cf chapitre précédent), pour être particulièrement efficace.
Ainsi, contrairement à ce qui se passe avec les coroutines C++20, un code Rust
utilisant async
/await
n’est pas forcé de faire une allocation tas par bloc
asynchrone utilisé.
Exemple détaillé
Si l’on reprend le bloc de code async
introduit dans le chapitre précédent…
async move {
let sortie = format!(
"Temps écoulé : {:?}\n\
Message : {texte}\n",
debut.elapsed()
);
let mut stdout = tokio::io::stdout();
stdout.write_all(sortie.as_bytes()).await
}
…voici ce qu’on peut en conclure à la lumière des explications précédentes.
D’abord, c’est un bloc async
, donc il retourne une future du résultat final
correspondant à la dernière expression du bloc. Ici, cette expression est
stdout.write_all(/* ... */).await
, et elle retourne un io::Result<()>
qui
indique si l’écriture sur stdout
s’est bien passée ou pas.
A partir de ce bloc async
, le compilateur va générer une implémentation du
trait Future
sous forme de machine à états avec…
- Un état initial où le code asynchrone n’a pas encore commencé à s’exécuter
- Ici, ça correspond à la capture initiale des variables externes
texte
etdebut
.
- Ici, ça correspond à la capture initiale des variables externes
- Un état par point d’arrêt
await
dans le code, dans lequel on peut se retrouver si le code a commencé à effectuer l’opération asynchrone associée à ce pointawait
mais son résultat n’est pas encore disponible et on doit retournerPoll::Pending
- Ici, ça correspond au cas où l’opération d’écriture asynchrone
stdout.write_all()
a été lancée, mais elle n’a pas pu être terminée en une seule transaction non-bloquante. L’OS nous a invité à revenir plus tard, et un gestionnaire d’événements a été installé pour savoir quand on pourra poursuivre l’écriture surstdout
. A ce stade, notre future de blocasync
délègue le travail asynchrone à la future retournée parstdout.write_all()
.
- Ici, ça correspond au cas où l’opération d’écriture asynchrone
- Un état final qui est atteint après que le résultat final ait été émis par
Poll::Ready
, où la méthodepoll()
ne devrait plus être appelée.- Ici, ça correspond à la fin du bloc, où le résultat de
write_all()
a été récupéré et propagé vers l’appelant de la tâche asynchrone.
- Ici, ça correspond à la fin du bloc, où le résultat de
Quand on appelle la méthode poll()
de la future résultante, elle passera d’un
état à l’autre de la machine à états sous-jacente en exécutant le code situé
entre le point d’arrêt de départ et le point d’arrêt d’arrivée, puis elle
émet le résultat Poll::Pending
si l’exécution n’est pas terminée ou
Poll::Ready
si l’exécution est terminée.
Etat auto-référentiel
Tout ceci est bien amusant, mais à ce stade vous vous demandez peut-être
pourquoi je vous ai embêté avec Pin
et les types auto-référentiels au début de
ce chapitre.
Cela tient au fait que tout bloc async
a tendance à générer des structures de
données auto-référentielles en présence de code idiomatique ayant recours à
l’emprunt de références.
Dans le cas présent, notre bloc async
d’exemple…
- Commence par construire une chaîne de caractères appelée
sortie
. - Emprunte un
&[u8]
issu de cette chaîne de caractères avec l’expressionsortie.as_bytes()
. - Transmet ce
&[u8]
à la méthodestdout.write_all()
. - …et peut se retrouver à se bloquer là et sauvegarder son état interne,
comprenant à la fois la chaîne de caractères
sortie
et un&[u8]
capturé parstdout.write_all()
qui pointe vers ladite chaîne de caractères.
…donc si notre chaîne de caractères était stockée sur la pile (ce qui est le
cas de certaines implémentations de chaînes de caractères utilisant la “small
string optimization”), la structure de données associée à l’état sauvegardé
pendant l’opération asynchrone stdout.write_all()
serait auto-référentielle
et ne pourrait pas être déplacée en toute sécurité.
En l’occurence, la structure n’est pas réellement auto-référentielle car la chaîne de caractères est allouée sur le tas et on a donc juste affaire à deux pointeurs ciblant une même allocation tas. Mais le système de typage de Rust traite ces deux situations de façon identique pour que les propriétés du code ne dépendent pas des détails d’implémentation du type chaîne de caractères utilisé.
Fonctions async
Si vous avez compris les blocs async
, les fonctions async
ne devraient pas
vous poser beaucoup de problèmes. Une fonction comme ceci…
#![allow(unused)] fn main() { type T = usize; async fn identique(x: T) -> T { x } }
…retourne immédiatement une future qui capture le paramètre d’entrée x
. Le
premier appel à la méthode poll()
de cette future retournera immédiatement
Poll::Ready(x)
.
Et un cas plus complexe comme ceci…
#![allow(unused)] fn main() { type T = usize; async fn enfant1() -> T { /* ... */ 42 } async fn enfant2() -> T { /* ... */ 24 } async fn parent() -> T { let x = enfant1().await; let y = enfant2().await; x + y } }
…s’interprète de la façon suivante :
- Lorsqu’on appelle
enfant1()
ouenfant2()
, cela retourne immédiatement une implémentation deFuture<Output=T>
qui produira, à terme, la valeur retournée par le code asynchrone de la fonction. - Lorsqu’on appelle
parent()
, cela retourne immédiatement une implémentation deFuture<Output=T>
dont la méthodepoll()
se comporte comme suit :- Au premier appel à
poll()
, on appelleenfant1()
pour construire une future et on tente d’appeler la méthodepoll()
de cette future. Si le résultat estPoll::Pending
, on sauvegarde cette future “enfant”, et tous les appels suivants aupoll()
de la future “parente” délèguent à la future retournée parenfant1()
jusqu’à ce que sa méthodepoll()
retourne un résultat finalPoll::Ready(x)
. - A partir de ce moment là, on met de côté la valeur x, jette la future
enfant retournée par
enfant1()
, puis on appelleenfant2()
. Cela construit une nouvelle future, et on répète le cycle précédent jusqu’à ce que la méthodepoll()
de la 2e future enfant retourne un résultat finalPoll::Ready(y)
. - Lorsque ça arrive, on récupère cette valeur y, on la somme avec la valeur
x stockée précédemment, et on retourne le résultat final
Poll::Ready(x + y)
. La future parente rentre dans son état final, l’exécuteur ne doit plus appeler sa méthodepoll()
.
- Au premier appel à
Notez que les tâches asynchrones retournées par enfant1()
et enfant2()
ne
sont pas exécutées de façon concurrente, ce qui ne semble pas optimal puisque ce
sont deux tâches indépendantes. async
/await
nous permet de créer des
futures dont le comportement reproduit fidèlement celui d’un code séquentiel
synchrone, mais ce comportement n’est pas forcément optimal pour une tâche
donnée. Donc async
/await
ne rend pas les combinateurs de futures
obsolètes.
Ici, une version de parent()
qui s’exécute de façon aussi concurrente que
possible serait…
async fn parent() -> T {
let (x, y) = futures::join!(enfant1(), enfant2());
x + y
}
Retour à notre exemple
Depuis la sortie de async
/await
, les runtimes comme tokio
ont mis au
point toutes sortes d’utilitaires qui en tirent parti pour améliorer
l’ergonomie. Voici notre exemple précédent d’affichage temporisé de messages,
réécrit de façon idiomatique avec les outils tokio
modernes :
use futures::prelude::*;
use std::time::Duration;
use tokio::{io::AsyncWriteExt, time::Instant};
// NOUVEAU : On délègue l'initialisation du runtime à la macro tokio::main
#[tokio::main]
async fn main() {
// Définition des messages
let messages = [
(Duration::from_millis(100), "...à tous"),
(Duration::from_millis(50), "Bonjour..."),
];
// Transformation des délais en deadlines
let debut = Instant::now();
let messages = messages.map(|(duree, texte)| (debut + duree, texte));
// Création des futures d'attentes et d'affichage
let mut futures = Vec::new();
for (deadline, texte) in messages {
// NOUVEAU : On réécrit la tâche asynchrone en un seul bloc async
futures.push(async move {
tokio::time::sleep_until(deadline).await;
let sortie = format!(
"Temps écoulé : {:?}\n\
Message : {texte}\n",
debut.elapsed()
);
let mut stdout = tokio::io::stdout();
stdout.write_all(sortie.as_bytes()).await
});
}
// On combine nos futures de résultats en une future de résultat combiné
let attente = future::try_join_all(futures);
// NOUVEAU : On attend la fin avec await
attente.await.expect("Erreur d'entrée/sortie");
}
On voit que même si tout ceci n’atteint pas encore la simplicité de la version utilisant des threads…
use std::io::Write; use std::time::{Duration, Instant}; fn main() { // Définition des messages let messages = [(Duration::from_millis(100), "...à tous"), (Duration::from_millis(50), "Bonjour...")]; // Transformation des délais en deadlines let debut = Instant::now(); let messages = messages.map(|(duree, texte)| (debut + duree, texte)); // Affichage temporisé d'un message let afficher_message = move |(deadline, texte): (Instant, &str)| { std::thread::sleep(deadline.saturating_duration_since(Instant::now())); let mut stdout = std::io::stdout().lock(); writeln!(&mut stdout, "Temps écoulé : {:?}", debut.elapsed())?; writeln!(&mut stdout, "Message : {texte}") }; // Lancement des threads std::thread::scope(|s| { for message in messages { s.spawn(move || afficher_message(message).expect("Echec de l'affichage")); } }); }
…l’écart ergonomique s’est quand même beaucoup resserré. Il devient moins
ridicule qu’avant d’imaginer que dans quelques années, lorsque l’infrastructure
asynchrone aura gagné en maturité avec notamment des équivalents asynchrones de
std::thread::scope()
et println!()
, ces deux programmes pourraient devenir
presque identiques à quelques async
et await
bien placés près.
Le reste du hibou
L’introduction de async
/await
a bien amélioré l’ergonomie de la
programmation asynchrone en Rust, mais ça ne signifie pas que l’équipe de
conception du langage peut se reposer sur ses lauriers.
L’infrastructure asynchrone qui a été intégrée au niveau du langage reste très préliminaire, et on y regrettera notamment l’absence…
- De lambdas asynchrones
async move || { /* ... */ }
(oui, on peut les approximer avecmove || async move { /* ... */ }
, mais ça devient vite lourdingue). - De méthodes asynchrones dans les traits.
- De nombreux traits fondamentaux de
futures
dans la bibliothèque standard. - D’un meilleur support des entrées/sorties asynchrones dans la bibliothèque standard.
- D’une solution au problème de l’interopérabilité des runtimes, qui passera peut-être par l’intégration de la notion de runtime au langage et à la bibliothèque standard.
- D’une généralisation de la notion de coroutine, introduite par la porte de
derrière dans l’implémentation des blocs et fonctions
async
, vers un modèle plus général où on peut déclarer des itérateurs et streams qui retournent plusieurs valeurs. - D’une alternative au paradigme du parallélisme structuré pour le code asynchrone, qui supporte le compromis parfois intéressant de la concurrence structurée (de l’attente parallèle, mais pas d’exécution de code parallèle).
Un objectif pour les prochaines années est donc de transformer l’essai pour qu’à terme, le code asynchrone soit aussi facile à écrire que le code synchrone. Voire idéalement encore plus facile à écrire, pour que son utilisation soit privilégiée chaque fois que c’est possible, et que ça devienne le cas exceptionnel de bloquer bêtement des threads quand on a juste besoin d’attendre que le système d’exploitation finisse quelques tâches…
Aller plus loin
L’écosystème asynchrone de Rust est vaste et en expansion rapide. Voici quelques suggestions de ressources à explorer pour en savoir plus :
- Ce chapitre doit beaucoup au petit e-book Asynchronous Programming in Rust, que je vous encourage à lire en entier plutôt que de vous contenter de mon résumé.
tokio
est loin d’être le seul runtime asynchrone disponible, même si c’est le plus populaire.- Du côté des runtimes généralistes, vous pourriez aussi essayer
async-std
etsmol
. - Pour plus de tolérance aux pannes,
bastion
pourrait vous intéresser. - Si vous faites de l’embarqué, les tâches asynchrones sont une alternative
assez populaire à l’utilisation d’un OS embarqué complet. Voir par exmeple
embassy
. - Et si vous avez affaire à une de ces bibliothèques qui vous embêtent à
fournir des fonctions asynchrones alors que vous voulez écrire du code
synchrone, l’implémentation
block_on()
la plus minimale que vous pouvez trouver est probablementpollster
.
- Du côté des runtimes généralistes, vous pourriez aussi essayer
- Les programmes qui exécutent des tâches indépendantes de façon concurrente
tendent à produire des logs difficiles à lire, où les événements associés à
différentes tâches sont entrelacés. La crate
tracing
tente de rendre les logs plus faciles à interpréter grâce à une approche de logging plus structurée. - Les runtimes comme
tokio
ne vous fournissent que des entrées/sorties bas niveau, comme leTcpStream
de la bibliothèque standard. A plus haut niveau, vous pourriez essayer…hyper
pour communiquer en HTTP, en combinaison avec une implémentation TLS commerustls
pour le HTTPS.- Des surcouches spécialisées de
hyper
commereqwest
si vous voulez juste faire des requêtes ouaxum
si vous voulez écrire un serveur web. tokio-uring
si vous voulez essayer la nouvelle interfaceio-uring
du noyau linux, qui permet de faire des entrées/sorties disque asynchrones aussi efficaces que les entrées/sorties réseau asynchrones.
Tableaux multidimensionnels
Pour l’instant, Rust est dans une situation similaire à C++ en ce qui concerne les tableaux multidimensionnels : ils ne sont pas directement intégrés au langage comme en Fortran, mais disponibles sous forme de bibliothèques tierces.
Ces bibliothèques représentent un effort de recherche de l’API idéale qui est encore en cours, et ne se terminera probablement pas avant que certains prérequis soient remplis côté langage. Dans le cas de Rust, deux gros prérequis encore manquants sont une couche d’abstraction standard pour le SIMD et un support plus complet des types et opérations génériques paramétrés par des valeurs.
Il n’y a donc pas actuellement une solution parfaite prête à l’emploi, mais plusieurs solutions imparfaites qui répondent plus ou moins bien à différents besoins selon les choix de conception faits par chacune d’entre elles :
- Si vous rencontrez principalement des problèmes d’algèbre linéaire à base de
vecteurs et matrices, et désirez une génération de code spécialisée pour les
problèmes de faible dimensionnalité qui en ont cruellement besoin, je vous
recommande à l’heure actuelle de commencer par essayer
nalgebra
, même si des challengers intéressants commefaer
commencent à arriver à l’horizon. - Si vous rencontrez des problèmes multidimensionnels plus généraux, comme les
calculs en stencil, je vous recommande plutôt de commencer par essayer
ndarray
, qui adopte un style d’interface à la NumPy plus adapté dans ce genre de cas.
Ces deux bibliothèques fournissent des tutoriels introductifs et prennent
beaucoup de soin à rédiger les parties “générales” de leur documentation
(documentation à l’échelle de la crate
entière, du module, etc). Je vous
encourage très fortement à ne pas faire l’impasse sur ces sections de leur
documentation, car en raison de l’usage relativement avancé qui est fait du
système de typage de Rust, la partie de la documentation de référence qui est
générée automatiquement (signatures de méthodes, etc) peut être plus difficile
à suivre pour l’utilisateur non aguerri.
GPU
A l’heure actuelle, l’écosystème pour le calcul GPU de Rust est dans l’ensemble moins développé que celui de C++. Il manque notamment encore une solution complète pour écrire la partie GPU (shader) du programme en Rust, même si le prototype rust-gpu est prometteur.
Ce qui est plus mature, en revanche, c’est les bindings vers les bibliothèques graphiques côté hôte.
La plupart des programmes C++ qui font du GPU utilisent directement l’API C bas niveau, qui est verbeuse et piégeuse, à cause de la difficulté d’utiliser des bibliothèques tierces en C++. Mais en Rust, la gestion des dépendances étant un problème bien résolu, on préférera utiliser des couches d’abstractions légères écrites par d’autres personnes.
Ces bindings ne cachent pas la fonctionnalité de l’API, contrairement à l’option plus radicale des moteurs de jeu vidéo (Unreal Engine, Unity…). Ce qu’ils font, c’est d’éliminer toute la partie rébarbative des APIs, liée au fait qu’elles sont spécifiées en C pour des raisons de portabilité :
- La libération des ressources est automatisée.
- La gestion des erreurs est basée sur
Result
et les paniques. - Les types manipulés sont plus riches, par exemple on utilise des types énumérés plutôt que des unions ou des structures dont certains membres ont un comportement particulier quand on les met à la valeur spéciale 0.
- Le système des méthodes centralise la liste des méthodes associées à un type en un seul point de la documentation de la bibliothèque.
- Et les aspects les plus lourds de l’utilisation (découpage des allocations en sous-allocations, gestion du temps de vie des ressources actuellement utilisées par le GPU, barrières de pipeline…) sont disponibles sous une forme encapsulée plus facile à utiliser, avec possibilité de descendre jusqu’à l’infrastructure sous-jacente via du code unsafe dans les cas rares où on fait des choses trop exigeantes pour l’encapsulation proposée.
A l’heure actuelle, en Rust, je vous recommande deux bibliothèques en particulier :
- Si vous recherchez une solution mature et bien documentée avec un accès
complet aux fonctionnalités du matériel, le support
Vulkan de Rust est excellent, avec
vulkano
comme binding de la vie de tous les jours etash
pour l’accès direct à l’API C sous-jacente. La portabilité entre OS est bonne, mais pas excellente faute de support officiel de Windows et macOS (il faut généralement installer un logiciel tiers pour que ça marche). - Si vous privilégiez la portabilité via l’utilisation des APIs graphiques
natives de chaque OS, et appréciez une approche plus haut niveau où davantage
d’erreurs peuvent être détectées dans le code GPU, en échange de quoi vous
êtes prêts à accepter une API moins mature qui couvre moins complètement les
fonctionnalités du matériels, alors WebGPU
est une autre option à explorer. L’une des implémentations de référence est
écrite en Rust, et fournit l’interface haut niveau
wgpu
qui est l’API graphique la plus utilisée en Rust.
Pour démarrer avec vulkano
, qui est la solution que je vous recommanderais
actuellement pour le calcul sur GPU hautes performances, je vous conseille le
Vulkano Guide qui est le
tutoriel officiel du projet. Côté wgpu
, l’équivalent est le tutoriel
Learn Wgpu, non officiel et moins
complet mais qui reste assez bien pour démarrer.
Les deux bibliothèques fournissent également un grand nombre d’exemples d’utilisation plus sophistiquées dans leurs dépôts git respectifs :