Entrées/Sorties
Introduction
Pour comprendre les enjeux des entrées/sorties en C++ et en Rust, il faut
d’abord se pencher un peu sur les entrées/sorties standard du C que ces deux
langages utilisent sous le capot. En effet, c’est l’interface standard des
accès aux fichiers et à stdin
/stdout
/stderr
qui met d’accord tous les
systèmes d’exploitation couramment utilisés grâce à la popularité du C.
La bibliothèque d’entrée/sortie standard du C est plutôt bien conçue au niveau
de ses opérations bas niveau comme
fread()
,
fwrite()
et
fseek()
. Mais les couches
supérieures de la pile d’abstraction ont plusieurs problèmes que les langages
plus récents essaient de résoudre :
- Il n’y a pas de support standard des échanges réseau, ce qui est devenu une omission gênante dans le monde d’aujourd’hui où presque tous les ordinateurs sont connectés à Internet.
- Il est difficile d’écrire du code portable manipulant des noms de fichiers via
fopen()
,remove()
etrename()
, au-delà des lectures/écritures basiques dans le répertoire courant à des chemins codés en dur, car la syntaxe des chemins de fichiers varie d’un système d’exploitation à l’autre et la bibliothèque standard C ne fournit aucun outil pour manipuler des chemins de fichiers de façon portable. - Il est laborieux de tester du code faisant des entrées/sorties dans un environnement cloisonné comme un serveur d’intégration continue, car on ne peut pas facilement modifier un programme qui fait des entrées/sorties pour qu’il échange simplement des octets avec des tampons stockés en RAM, sans utiliser des fonctionnalités spécifiques à un OS cible.
- La gestion des erreurs des entrées/sorties standard C est très laborieuse, il est facile de manquer ou mal interpréter un code d’erreur et d’avoir un programme qui poursuit son exécution de façon incorrecte quand une erreur se produit.
- De nombreuses fonctions de la bibliothèque standard C ne devraient plus être
utilisées car elles ont des problèmes insolubles de sécurité (ex : fonctions
produisant des sorties non bornées comme
scanf()
), de performances (ex : fonctions travaillant octet par octet) ou ne règlent pas vraiment les problèmes qu’elles étaient censées régler et n’ont donc pas beaucoup d’intérêt (ex : fonctions basées sur les “wide characters”). Il est donc préférable de ne pas exposer toute l’API C à l’identique, et on peut en profiter pour revoir ladite API en passant.
La bibliothèque standard Rust résout ces problèmes à tous les niveaux
d’abstraction. Dans ce chapitre, nous allons nous concentrer sur les couches
basses, qui concernent les échanges d’octets et de texte avec des sources et
destination. Dans des chapitres ultérieurs, nous aborderons ensuite le cas
particulier des flux standards stdin
/stdout
/stderr
, fichiers et échanges
réseau.
Entrées/sorties binaires avec std::io
Read
, Write
et Seek
Au coeur du module std::io
, on trouve les traits Read
, Write
et Seek
,
qui correspondent comme leur nom l’indique aux fonctions d’entrée/sortie de
base de la bibliothèque standard C : lire des octets, écrire des octets, et
savoir où on se trouve / changer de position dans le fichier.
Rust reprend la logique introduite par C et Unix de manipuler tout ce qui
ressemble à un flux d’octets de la même façon, mais grâce aux traits cette
uniformisation est poussée plus loin. Parmi les types qui implémentent
Read
, on
retrouve ainsi…
- Les fichiers, bien sûr
- Les connexions réseau entrantes
- Les pipes entrants (
stdin
du processus actif,stdout
/stderr
des processus enfants, etc.) - Quelques itérateurs spécialisés du module
std::io
- Différentes collections ordonnées d’octets, parfois avec l’aide d’un wrapper
Cursor
qui mémorise l’information “quel octet suis-je en train de lire/écrire dans la collection”
La situation est similaire du côté des types qui implémentent
Write
, là où
Seek
est implémenté de façon plus
sélective car
peu de flux d’octets ont une notion de position et de déplacement.
Grâce à cette abstraction, les programmes Rust qui manipulent des fichiers sont plus faciles à tester et à faire évoluer. On peut facilement écrire du code générique qui fonctionne aussi bien avec un fichier qu’une collection d’octets en mémoire, et s’en servir pour vérifier dans les tests unitaires que les entrées/sorties sont correctes. Et on peut plus facilement passer d’un programme qui fait des entrées/sorties fichiers à un programme qui fait des communications réseau ou inter-processus.
Par-dessus la fonction de base d’échange d’octets fourniées par chaque
implémentation, les traits Read
, Write
et Seek
fournissent des méthodes
qui simplifient des tâches courantes telles que lire l’intégralité des octets
restants dans un tampon, écrire l’intégralité des octets d’une slice, ou
concaténer plusieurs sources de données entrantes.
Voici une démonstration simple des fonctionnalités de Read
, Write
et
Seek
. Comme nous n’avons pas encore traité les “vrais” sources et drains de
données, nous les appliquerons à des itérateurs et des données en mémoire.
// Import de tous les traits d'E/S standards + type Cursor use std::io::{prelude::*, Cursor}; fn main() { // Itérateur qui répète des octets d'une certaine valeur let mut quarante_deux = std::io::repeat(42); // Lecture de quelques octets let mut tampon = [0; 8]; { let octets_lus = quarante_deux.read(&mut tampon[..]) .expect("Echec de la lecture"); println!( "Lu {octets_lus} octets depuis l'itérateur : {:?}", &tampon[..octets_lus] ); } // Vecteur d'octets utilisé comme destination let mut dest = Vec::new(); // Ecriture de quelques octets dest.write_all(b"Je vous salue bien") .expect("Echec de l'écriture"); println!("Octets ecrits : {dest:?}"); // Lecture aléatoire avec un curseur let mut src = Cursor::new(&mut dest); { let octets_lus = src.read(&mut tampon[..]) .expect("Echec de la lecture"); println!( "Lu {octets_lus} octets depuis l'itérateur : {:?}", &tampon[..octets_lus] ); } // Position actuelle println!("Actuellement à la position {} du flux d'entrée", src.stream_position().expect("Echec de la requête de position")); }
BufRead
, BufReader
et BufWriter
La plupart des implémentations de Read
et Write
ne font aucun buffering en
interne, chaque appel à Read::read()
et Write::write()
correspond
directement à un appel système de l’OS sous jacent qui échange des données avec
un tampon fourni par l’utilisateur. Cela a deux conséquences :
- Il n’est pas possible de fournir au niveau de
Read
des fonctionnalités comme la lecture de fichier texte ligne par ligne, car elles requièrent (si on utilise un tampon de taille >1, ce qu’on devrait toujours faire pour des raisons de performances) de garder des données de côté pour les ressortir lors d’un appel ultérieur. - Il est très facile de se tirer dans le pied au niveau des performances en
utilisant
Read
ouWrite
avec un tampon de taille trop petite, qui correspond aux besoins du moment du programme et pas à la granularité d’échange efficace avec le périphérique sous-jacent.
Pour éviter ces problèmes, on utilise les wrappers BufReader
et BufWriter
,
qui insèrent une couche tampon de taille configurable entre le programme et
l’implémentation Read
/Write
sous-jacente.
Ces types ré-implémentent Read
et Write
en passant par le tampon interne,
mais du côté des lectures, BufReader
implémente aussi le trait
BufRead
, qui permet de
découper le flux de données entrant en sections délimitées par un certain octet
(tel que b'\n'
pour les sauts de ligne sous Unix).
BufRead
est aussi directement implémenté par les implémentations de Read
qui
possèdent déjà un tampon d’octets sous le capot, comme les slices d’octets :
#![allow(unused)] fn main() { use std::io::{prelude::*, Cursor}; // Données d'entrée et Cursor pour la lecture let source = [1, 2, 3, 42, 4, 5, 42, 42, 6, 7, 8]; let curseur = Cursor::new(&source[..]); // Lecture tronçonnée avec BufRead for segment in curseur.split(42) { println!("Segment : {:?}", segment.expect("Echec de la lecture")); } }
Erreurs d’entrées/sorties
Toutes les fonctions de std::fs
et std::io
signalent leurs erreurs avec le
type std::io::Error
. Ce
type est traduit depuis la gestion d’erreur de la libc
(codes d’erreur,
errno
, etc.) et des autres APIs système utilisées. Mais comme il est utilisé
via Result
, on ne peut pas oublier de gérer les erreurs comme en C et en C++.
L’abstraction par rapport aux différents types d’erreurs système est associée
via le mécanisme
ErrorKind
, qui fournit
une classification analogue à celle des valeurs standard de errno
en C. Les
programmes ayant besoin d’analyser plus précisément ce qui se passe peuvent
accéder aux erreurs système brutes, dont l’interprétation est non portable, via
des méthodes comme
raw_os_error()
.
Comme il existe un très grand nombre de fonctions d’entrée/sortie qui retournent
un type Result<T, std::io::Error>
, il existe un raccourci
std::io::Result<T>
pour écrire ce type de façon un peu plus concise.
Entrées/sorties textuelles avec std::fmt
Chaînes UTF-8 pré-existantes
Les traits Read
et BufRead
fournissent des méthodes standard pour traiter
les données entrantes comme de l’UTF-8. Elles se comportent comme des méthodes
de lecture d’octets sauf qu’elles valident que le flux d’octets entrant est
bien encodé en UTF-8, traitent les erreurs de validation comme un cas
particulier d’erreur d’entrée/sortie, et stockent leur résultats dans des
String
:
#![allow(unused)] fn main() { use std::io::prelude::*; // Chaîne de caractères ASCII traitée comme un flux d'octets via Read et BufRead. let mut ascii: &[u8] = b"Bonjour, je suis un texte en ASCII.\nVous pouvez me lire ligne par ligne."; // Itération sur les lignes du flux d'octets via BufRead for ligne in ascii.lines() { println!( "Lu une ligne : {}", ligne.expect("Erreur de lecture") ); } }
Si l’on a une chaîne de caractères pré-encodée en UTF-8, on peut aussi l’écrire
via un flux d’octets sortant Write
. Il suffit d’utiliser la méthode
as_bytes()
du type chaîne utilisé pour accéder à la séquence d’octets UTF-8
sous-jacente :
#![allow(unused)] fn main() { use std::io::prelude::*; // Tampon traité comme un flux d'octets sortant let mut dest = Vec::new(); // Ecriture d'UTF-8 dans le tampon dest.write_all("Je suis encodé en UTF-8".as_bytes()) .expect("Erreur d'écriture"); // Affichage des octets sortants println!("Octets sortants : {dest:02x?}"); }
Mais que se passe-t’il si on n’a pas déjà une chaîne de caractères
pré-existante ? Doit-on commencer par en créer une avant de faire des
entrées-sorties ? On pourrait, mais ce n’est pas ce qu’il y a de plus efficace,
car on doit faire plusieurs passes sur les données. Il y a donc une façon de
faire plus efficace, analogue au ifstream
du C++ et au fprintf()
du C.
Les cousins de println!()
Depuis le début de ce cours, nous utilisons la macro
println!()
pour effectuer
des sorties console. Cette macro peut être vue comme une forme améliorée du
printf()
du C, donc par analogie avec le C, on peut se demander si il n’y a
pas des équivalents aux fonctions fprintf()
et sprintf()
de la libc. Et
en effet, il existe de nombreuses variantes de println!()
en Rust :
print!()
n’inclut pas le saut de ligne final qui est automatiquement inséré parprintln!()
. Il faut être prudent avec cette variante, car sur la plupart des systèmes d’exploitation, les sortiesstdout
ne sont affichées sur la console qu’après chaque saut de ligne.eprintln!()
eteprint!()
sont des variantes deprintln!()
etprint!()
qui écrivent surstderr
plutôt que surstdout
. Traditionnellement, un programme Unix écrit sa sortie normale surstdout
et ses erreurs et messages de statut surstderr
.format!()
fonctionne un peu commeprint!()
, mais construit une chaîne de caractères au lieu d’écrire surstdout
.- Et enfin
writeln!()
etwrite!()
fonctionnent un peu commeprintln!()
etprint!()
, mais peuvent être utilisées pour ajouter du texte à des chaînes de caractères pré-existantes et pour écrire de l’UTF-8 dans des fichiers, la destination étant donné en premier argument.
Ces deux dernières macros sont un peu plus complexes que les autres, nous allons donc donner quelques exemples pour clarifier comment elles fonctionnent.
D’abord, on peut utiliser write!()
et writeln!()
pour écrire du texte
formaté en encodage UTF-8 dans un flux d’octets sortant. Dans cette utilisation,
ces macros utilisent le trait std::io::Write
que nous avons déjà vu, et
retournent un résultat de type std::io::Result<()>
:
#![allow(unused)] fn main() { use std::io::prelude::*; // Tampon accueillant les octets sortants let mut sortie = Vec::new(); // Ecriture de texte formaté write!( &mut sortie, "La réponse est {}", 42 ).expect("Erreur d'entrée/sortie"); // Lecture des octets sortants println!("Octets émis : {:02x?}", sortie); }
On peut également utiliser write!()
et writeln!()
pour écrire du texte
formaté dans une chaîne de caractère (String
, OsString
) ou dans le type
Formatter
qui abstrait différentes sorties texte pour les besoins de
l’implémentation de Display
et Debug
.
Dans cette utilisation, ces macros utilisent un autre trait appelé
std::fmt::Write
et retournent un résultat de type
std::fmt::Result<()>
,
basé sur le type erreur opaque
std::fmt::Error
.
#![allow(unused)] fn main() { use std::fmt::Write; // Chaîne de caractères initiale let mut s = String::from("Du texte"); println!("Chaîne initiale : {s}"); // Ajout de texte write!( &mut s, ", et encore {}", "plus de texte" ).expect("Erreur d'écriture formatée"); // Chaîne de caractères final println!("Chaîne finale : {s}"); }
Le fait que ces opérations retournent toujours un fmt::Result<()>
peut
surprendre, dans la mesure où quand on écrit dans une chaîne de caractères,
aucune erreur ne peut survenir, et donc le cas expect()
ci-dessus ne sera
jamais rencontré. C’est un des cas où la bibliothèque standard Rust utilise un
type erreur un peu trop pessimiste pour éviter la prolifération des types
erreur.
En effet, dans le cas de Formatter
(qui, pour rappel, est la couche
d’abstraction utilisée par les implémentations de Display
et Debug
) la
cible des écritures peut être n’importe quoi, y compris un fichier. L’erreur
est donc possible, et ça a du sens d’avoir un type erreur dans ce cas. C’est
juste le fait d’avoir utilisé le même trait et le même type erreur pour les
écritures infaillibles dans les chaînes de caractères qui est regrettable, et
malheureusement cela ne peut plus être changé maintenant pour des raisons de
compatibilité avec le code Rust existant.
Mais bon. Si on avait l’esprit moins chagrin que moi, on pourrait aussi comparer
le résultat à la situation de C++, où l’API historique <iostream>
reprend tous
les vices de conception de la bibliothèque standard C et en ajoute un paquet
d’autres de son cru, tandis que la nouvelle API <format>
de C++20 est une
mauvaise copie du std::fmt
de Rust qui a été rendue si compliquée par la magie
du design by
committee que personne ne
comprend comment s’en servir. Vu sous cet angle, Rust peut quand même être
plutôt fier de son mécanisme de sortie texte formaté, qui offre un excellent
compromis ergonomie/performance/flexibilité en comparaison.