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
      

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 retourner int, plusieurs types sont acceptés grâce à une conversion vers int.
  • 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 et isize
  • Les types entiers non signés u8, u16, u32, u64, u128 et usize.
  • Les types flottants f32 et f64.

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 et Into représentent les conversions qui réussissent toujours sans pertes d’information. Ces opérations permettent donc de convertir i8 en i16, mais pas f32 en usize.
  • TryFrom et TryInto 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 mais u8::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…

  1. 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.
  2. 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() et overflowing_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 du std::pow de C++, en Rust f.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 type RangeFull représentant l’ensemble des valeurs d’un type ordonné.
  • i.. est un RangeFrom représentant l’ensemble des x tels que i <= x.
  • i..j est un Range représentant l’ensemble des x tels que i <= x < j.
  • i..=j est un RangeInclusive représentant l’ensemble des x tels que i <= x <= j.
  • ..j est un RangeTo représentant l’ensemble des x tels que x < j.
  • ..=j est un RangeToInclusive représentant l’ensemble des x tels que x <= j.
  • Les autres cas sont couverts par des tuples (Bound, Bound)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 :
    #![allow(unused)]
    fn main() {
    let normalisation1 = "é";
    let normalisation2 = "e\u{0301}";
    let comparaison = normalisation1 == normalisation2;
    println!("{normalisation1} == {normalisation2} -> {comparaison}");
    }
    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.
  • 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 :
    #![allow(unused)]
    fn main() {
    let mot = "rate\u{0301}";
    let (debut, fin) = mot.split_at(4);
    println!("{mot} -> {debut} {fin}");
    }
    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.

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…

  1. 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.
  2. 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 de u8 utilisé par l’ASCII.
  • Des litérales ASCII telles que b'\n' et b"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 écrivant value: 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…

  1. 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 =.
  2. On peut ignorer un membre en utilisant _ à la place d’un nom de variable :
    #![allow(unused)]
    fn main() {
    let (x, _) = (12, 34);
    println!("{x}");
    }
  3. 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}");
    }
  4. 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 syntaxe receveur.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 :

  1. 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.
  2. 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 fonction T -> 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 et std::forward, des fonctions d’insertion spéciales sur les conteneurs comme emplace()… 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 &TT 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 :

  1. 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
    }
    }
  2. 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 soit mut.
  • 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 {}.
  • 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éthode iter() conventionnelle.
  • Les collections associatives HashMap et BTreeMap 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() et chunks_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() et chunks_exact_mut(), variantes de chunks() et chunks_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 :
    #![allow(unused)]
    fn main() {
    for window in [1, 2, 3, 4, 5, 6, 7, 8, 9, 10].windows(4) {
      println!("{window:?}");
    }
    }
    Saurez-vous deviner pourquoi il n’existe pas de méthode 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 retourne true :
    #![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 un Vec : 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. Comme VecDeque, c’est une structure de données très utile dans les problèmes d’ordonnancement : en théorie de l’ordonnancement VecDeque 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 pas Copy, car un memcpy() 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 explicite b.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 le memcpy() 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 de Box 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> et Box<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”) :

  1. 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.
  2. On retourne à l’utilisateur un pointeur intelligent Rc<T> ou Arc<T> qui peut être copié via l’opérateur clone(). 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é.
  3. 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é.
  4. 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 de Arc<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> ou Arc<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> et Arc<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 de const T* et T* 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 par Mutex & assimilé.
  • Et quelques primitives non bloquantes écrites par l’auteur de ce cours : triple-buffer pour le partage de données et rt-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 et mpsc 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 à un goto 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 PATHs), 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 et stderr, 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 type Instant.
  • Connaissant deux Instants, on peut calculer le temps écoulé entre les deux en les soustrayant. Ce temps écoulé est représenté par un objet de type Duration.
  • De façon symétrique, on peut ajouter ou supprimer une Duration à un Instant pour obtenir un autre Instant 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() et rename(), 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 ou Write 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é par println!(). Il faut être prudent avec cette variante, car sur la plupart des systèmes d’exploitation, les sorties stdout ne sont affichées sur la console qu’après chaque saut de ligne.
  • eprintln!() et eprint!() sont des variantes de println!() et print!() qui écrivent sur stderr plutôt que sur stdout. Traditionnellement, un programme Unix écrit sa sortie normale sur stdout et ses erreurs et messages de statut sur stderr.
  • format!() fonctionne un peu comme print!(), mais construit une chaîne de caractères au lieu d’écrire sur stdout.
  • Et enfin writeln!() et write!() fonctionnent un peu comme println!() et print!(), 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 comme C:\ 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 et OsString. 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 et PathBuf. C’est très similaire au type std::filesystem::path enfin introduit par C++17, sauf que…
    • On a la distinction Path/PathBuf en Rust, qui est analogue à la distinction string_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.

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ète OpenOptions, qui utilise une conception de type builder pattern et offre la même flexibilité que les chaînes de caractères de fopen(), 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 le ftruncate() de POSIX.
  • Changer les permissions d’accès via la méthode file.set_permissions(), à la manière du fchmod() 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() et file.sync_data() qui fonctionnent comme les fsync() et fdatasync() 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 ::) et BROADCAST (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 ou SocketAddrV6, 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…

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 extension hyper-tls pour le TLS.
  • reqwest pour un client HTTP de plus haut niveau.
  • De très (trop ?) nombreux frameworks serveur, tels que axum et actix.

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 optionel envp 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 comme setenv() en parallèle, et la conception de getenv() 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 avec var(), modifier une variable avec set_var(), et supprimer une variable avec remove_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, comme vars_os().
  • Les fonctions split_paths() et join_paths() permettent de manipuler une variable d’environnement PATH 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 et argv 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 niveau args_os(), la nuance étant identique à celle entre vars() et vars_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 et OS identifient la plate-forme cible en suivant la même syntaxe que les options de configuration cfg!() (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 et EXE_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 et DLL_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 surtout pub(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…

  1. 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 en T: 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).
  2. 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 ou bool 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 que T::Result doit être T. 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 traits Fn(), FnMut() et FnOnce() 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 traits Debug et Display du module std::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() pour Vec…), vous pouvez l’indiquer avec le trait Default.
  • 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 type Self 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.
  • 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 de Box.
  • enum facilite le support de nouvelles opérations, alors que dyn 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 que enum, 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…

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 et clippy 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 de cargo 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.

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 sur criterion (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 et remove.
    • 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 de core::arch::x86_64. La différence entre les deux sera expliquée dans le chapitre sur no_std, pour l’heure retenez juste que les modules std::arch::xyz sont des alias vers d’autres modules core::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 :

  1. 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.
  2. 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 : relancer cargo 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 clauses use, 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 de rustdoc pour en savoir plus.
  • Si votre fonction a des conditions d’erreur (retour Result<T, Erreur>, paniques, domaine de validité pour les abstractions unsafe…), 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 ou docker 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 dans git.
  • 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é.
  • 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 comme std::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() retourne Poll::Ready, la tâche asynchrone est terminée et son résultat est retourné à l’appelant. A partir de ce moment, la méthode poll() ne doit plus être utilisée.
  • Si poll() retourne Poll::Pending, la tâche asynchrone a atteint un point bloquant (entrée/sortie). On doit attendre que ce point bloquant soit passé, puis rappeler poll().

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 le Sink soit prêt à accepter une nouvelle valeur en entrée.
  • Quand poll_ready() indique que le Sink est prêt, on peut lui soumettre une nouvelle valeur à envoyer avec start_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 utilise poll_flush() si on veut envoyer d’autres données par la suite, ou poll_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 et block_on() si on tentait de le faire sans LocalPool.
  • Avec try_run_one() et run_until_stall(), on peut exécuter l’ensemble des futures soumises précédemment jusqu’à ce qu’une tâche se termine (pour try_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 futures Send 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 comme Rc, 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’attente mpsc de la bibliothèque standard, ainsi qu’une variante spécialisée oneshot 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 type ThreadPool::spawn_ok().
  • Dans le module lock, on retrouve une variante asynchrone du type Mutex 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 crate futures pour manipuler des futures.
  • On initialise le runtime tokio pendant la phase d’initialisation de notre programme. Comme tokio 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’API tokio.
  • 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 crate futures 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 crate futures 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() de tokio.
  • 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 dans stdout. Mais nous sommes avertis que cette alternative à println!() n’est pas synchronisée, donc si plusieurs tâches asynchrones tentent d’écrire sur stdout 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 de future.map(), car l’écriture sur stdout est désormais une opération asynchrone qui retourne une future.
  • A l’intérieur de future.then(), on utilise async/await pour permettre l’emprunt du tampon sortie durant l’écriture sur stdout. Nous reviendrons prochainement sur cette possibilité.
  • Ni tokio ni futures ne fournissent d’alternative à println!(), donc nous devons pré-calculer le texte avec format!() avant de l’envoyer sur stdout.
  • Les entrées/sorties sur stdout sont traitées comme faillibles par tokio, 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 code async 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 et debut.
  • 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 point await mais son résultat n’est pas encore disponible et on doit retourner Poll::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 sur stdout. A ce stade, notre future de bloc async délègue le travail asynchrone à la future retournée par stdout.write_all().
  • Un état final qui est atteint après que le résultat final ait été émis par Poll::Ready, où la méthode poll() 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.

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’expression sortie.as_bytes().
  • Transmet ce &[u8] à la méthode stdout.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é par stdout.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() ou enfant2(), cela retourne immédiatement une implémentation de Future<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 de Future<Output=T> dont la méthode poll() se comporte comme suit :
    • Au premier appel à poll(), on appelle enfant1() pour construire une future et on tente d’appeler la méthode poll() de cette future. Si le résultat est Poll::Pending, on sauvegarde cette future “enfant”, et tous les appels suivants au poll() de la future “parente” délèguent à la future retournée par enfant1() jusqu’à ce que sa méthode poll() retourne un résultat final Poll::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 appelle enfant2(). Cela construit une nouvelle future, et on répète le cycle précédent jusqu’à ce que la méthode poll() de la 2e future enfant retourne un résultat final Poll::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éthode poll().

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 avec move || 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 et smol.
    • 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 probablement pollster.
  • 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 le TcpStream de la bibliothèque standard. A plus haut niveau, vous pourriez essayer…
    • hyper pour communiquer en HTTP, en combinaison avec une implémentation TLS comme rustls pour le HTTPS.
    • Des surcouches spécialisées de hyper comme reqwest si vous voulez juste faire des requêtes ou axum si vous voulez écrire un serveur web.
    • tokio-uring si vous voulez essayer la nouvelle interface io-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 comme faer 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 et ash 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 :