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.