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.