Stocker des listes de valeurs avec des vecteurs

Le premier type de collection que nous allons voir est Vec<T>, aussi appelé vecteur. Les vecteurs vous permettent de stocker plus d'une valeur dans une seule structure de données qui stocke les valeurs les unes à côté des autres dans la mémoire. Les vecteurs peuvent stocker uniquement des valeurs du même type. Ils sont utiles lorsque vous avez une liste d'éléments, tels que les lignes de texte provenant d'un fichier ou les prix des articles d'un panier d'achat.

Créer un nouveau vecteur

Pour créer un nouveau vecteur vide, nous appelons la fonction Vec::new, comme dans l'encart 8-1.

fn main() {
    let v: Vec<i32> = Vec::new();
}

Encart 8-1 : création d'un nouveau vecteur vide pour y stocker des valeurs de type i32

Remarquez que nous avons ajouté ici une annotation de type. Comme nous n'ajoutons pas de valeurs dans ce vecteur, Rust ne sait pas quel type d'éléments nous souhaitons stocker. C'est une information importante. Les vecteurs sont implémentés avec la généricité ; nous verrons comment utiliser la généricité sur vos propres types au chapitre 10. Pour l'instant, sachez que le type Vec<T> qui est fourni par la bibliothèque standard peut stocker n'importe quel type. Lorsque nous créons un vecteur pour stocker un type précis, nous pouvons renseigner ce type entre des chevrons. Dans l'encart 8-1, nous précisons à Rust que le Vec<T> dans v va stocker des éléments de type i32.

Le plus souvent, vous allez créer un Vec<T> avec des valeurs initiales et Rust va deviner le type de la valeur que vous souhaitez stocker, donc vous n'aurez pas souvent besoin de faire cette annotation de type. Rust propose la macro très pratique vec!, qui va créer un nouveau vecteur qui stockera les valeurs que vous lui donnerez. L'encart 8-2 crée un nouveau Vec<i32> qui stocke les valeurs 1, 2 et 3. Le type d'entier est i32 car c'est le type d'entier par défaut, comme nous l'avons évoqué dans la section “Les types de données” du chapitre 3.

fn main() {
    let v = vec![1, 2, 3];
}

Encart 8-2 : création d'un nouveau vecteur qui contient des valeurs

Comme nous avons donné des valeurs initiales i32, Rust peut en déduire que le type de v est Vec<i32>, et l'annotation de type n'est plus nécessaire. Maintenant, nous allons voir comment modifier un vecteur.

Modifier un vecteur

Pour créer un vecteur et ensuite lui ajouter des éléments, nous pouvons utiliser la méthode push, comme dans l'encart 8-3.

fn main() {
    let mut v = Vec::new();

    v.push(5);
    v.push(6);
    v.push(7);
    v.push(8);
}

Encart 8-3 : utilisation de la méthode push pour ajouter des valeurs à un vecteur

Comme pour toute variable, si nous voulons pouvoir modifier sa valeur, nous devons la rendre mutable en utilisant le mot-clé mut, comme nous l'avons vu au chapitre 3. Les nombres que nous ajoutons dedans sont tous du type i32, et Rust le devine à partir des données, donc nous n'avons pas besoin de l'annotation Vec<i32>.

Libérer un vecteur libère aussi ses éléments

Comme toutes les autres structures, un vecteur est libéré quand il sort de la portée, comme précisé dans l'encart 8-4.

fn main() {
    {
        let v = vec![1, 2, 3, 4];
    
        // on fait des choses avec v
    
    } // <- v sort de la portée et est libéré ici
}

Encart 8-4 : mise en évidence de là où le vecteur et ses éléments sont libérés

Lorsque le vecteur est libéré, tout son contenu est aussi libéré, ce qui veut dire que les nombres entiers qu'il stocke vont être effacés de la mémoire. Cela semble très simple mais cela peut devenir plus compliqué quand vous commencez à utiliser des références vers les éléments du vecteur. Voyons ceci dès à présent !

Lire les éléments des vecteurs

Il existe deux façons de désigner une valeur enregistrée dans un vecteur : via les indices ou en utilisant la méthode get. Dans les exemples suivants, nous avons précisé les types des valeurs qui sont retournées par ces fonctions pour plus de clarté.

L'encart 8-5 nous montre les deux façons d'accéder à une valeur d'un vecteur, via la syntaxe d'indexation et avec la méthode get.

fn main() {
    let v = vec![1, 2, 3, 4, 5];

    let troisieme: &i32 = &v[2];
    println!("Le troisième élément est {}", troisieme);

    match v.get(2) {
        Some(troisieme) => println!("Le troisième élément est {}", troisieme),
        None => println!("Il n'y a pas de troisième élément."),
    }
}

Encart 8-5 : utilisation de la syntaxe d'indexation ainsi que la méthode get pour accéder à un élément d'un vecteur

Il y a deux détails à remarquer ici. Premièrement, nous avons utilisé l'indice 2 pour obtenir le troisième élément car les vecteurs sont indexés par des nombres, qui commencent à partir de zéro. Deuxièmement, nous obtenons le troisième élément soit en utilisant & et [], ce qui nous donne une référence, soit en utilisant la méthode get avec l'indice en argument, ce qui nous fournit une Option<&T>.

La raison pour laquelle Rust offre ces deux manières d'obtenir une référence vers un élement est de vous permettre de choisir le comportement du programme lorsque vous essayez d'utiliser une valeur dont l'indice est à l'extérieur de la plage des éléments existants. Par exemple, voyons dans l'encart 8-6 ce qui se passe lorsque nous avons un vecteur de cinq éléments et qu'ensuite nous essayons d'accéder à un élément à l'indice 100 avec chaque technique.

fn main() {
    let v = vec![1, 2, 3, 4, 5];

    let existe_pas = &v[100];
    let existe_pas = v.get(100);
}

Encart 8-6 : tentative d'accès à l'élément à l'indice 100 dans un vecteur qui contient cinq éléments

Lorsque nous exécutons ce code, la première méthode [] va faire paniquer le programme car il demande un élément non existant. Cette méthode doit être favorisée lorsque vous souhaitez que votre programme plante s'il y a une tentative d'accéder à un élément après la fin du vecteur.

Lorsque nous passons un indice en dehors de l'intervalle du vecteur à la méthode get, elle retourne None sans paniquer. Vous devriez utiliser cette méthode s'il peut arriver occasionnellement de vouloir accéder à un élément en dehors de l'intervalle du vecteur en temps normal. Votre code va ensuite devoir gérer les deux valeurs Some(&element) ou None, comme nous l'avons vu au chapitre 6. Par exemple, l'indice peut provenir d'une saisie utilisateur. Si par accident il saisit un nombre qui est trop grand et que le programme obtient une valeur None, vous pouvez alors dire à l'utilisateur combien il y a d'éléments dans le vecteur courant et lui donner une nouvelle chance de saisir une valeur valide. Cela sera plus convivial que de faire planter le programme à cause d'une faute de frappe !

Lorsque le programme obtient une référence valide, le vérificateur d'emprunt va faire appliquer les règles de possession et d'emprunt (que nous avons vues au chapitre 4) pour s'assurer que cette référence ainsi que toutes les autres références au contenu de ce vecteur restent valides. Souvenez-vous de la règle qui dit que vous ne pouvez pas avoir des références mutables et immuables dans la même portée. Cette règle s'applique à l'encart 8-7, où nous obtenons une référence immuable vers le premier élément d'un vecteur et nous essayons d'ajouter un élément à la fin. Ce programme ne fonctionnera pas si nous essayons aussi d'utiliser cet élément plus tard dans la fonction :

fn main() {
    let mut v = vec![1, 2, 3, 4, 5];

    let premier = &v[0];

    v.push(6);

    println!("Le premier élément est : {}", premier);
}

Encart 8-7 : tentative d'ajout d'un élément à un vecteur alors que nous utilisons une référence à un élément

Compiler ce code va nous mener à cette erreur :

$ cargo run
   Compiling collections v0.1.0 (file:///projects/collections)
error[E0502]: cannot borrow `v` as mutable because it is also borrowed as immutable
 --> src/main.rs:6:5
  |
4 |     let premier = &v[0];
  |                    - immutable borrow occurs here
5 | 
6 |     v.push(6);
  |     ^^^^^^^^^ mutable borrow occurs here
7 | 
8 |     println!("Le premier élément est : {}", premier);
  |                                             ------- immutable borrow later used here

For more information about this error, try `rustc --explain E0502`.
error: could not compile `collections` due to previous error

Le code dans l'encart 8-7 semble pourtant marcher : pourquoi une référence au premier élément devrait se soucier de ce qui se passe à la fin du vecteur ? Cette erreur s'explique par la façon dont les vecteurs fonctionnent : comme les vecteurs ajoutent les valeurs les unes à côté des autres dans la mémoire, l'ajout d'un nouvel élément à la fin du vecteur peut nécessiter d'allouer un nouvel espace mémoire et copier tous les anciens éléments dans ce nouvel espace, s'il n'y a pas assez de place pour placer tous les éléments les uns à côté des autres dans la mémoire là où est actuellement stocké le vecteur. Dans ce cas, la référence au premier élément pointerait vers de la mémoire désallouée. Les règles d'emprunt évitent aux programmes de se retrouver dans cette situation.

Remarque : pour plus de détails sur l'implémentation du type Vec<T>, consultez le “Rustonomicon”.

Itérer sur les valeurs d'un vecteur

Pour accéder à chaque élément d'un vecteur chacun son tour, nous devrions itérer sur tous les éléments plutôt que d'utiliser individuellement les indices. L'encart 8-8 nous montre comment utiliser une boucle for pour obtenir des références immuables pour chacun des éléments dans un vecteur de i32, et les afficher.

fn main() {
    let v = vec![100, 32, 57];
    for i in &v {
        println!("{}", i);
    }
}

Encart 8-8 : affichage de chaque élément d'un vecteur en itérant sur les éléments en utilisant une boucle for

Nous pouvons aussi itérer avec des références mutables pour chacun des éléments d'un vecteur mutable afin de modifier tous les éléments. La boucle for de l'encart 8-9 va ajouter 50 à chacun des éléments.

fn main() {
    let mut v = vec![100, 32, 57];
    for i in &mut v {
        *i += 50;
    }
}

Encart 8-9 : itérations sur des références mutables vers des éléments d'un vecteur

Afin de changer la valeur vers laquelle pointe la référence mutable, nous devons utiliser l'opérateur de déréférencement * pour obtenir la valeur dans i avant que nous puissions utiliser l'opérateur +=. Nous verrons plus en détail l'opérateur de déréférencement dans une section du chapitre 15.

Utiliser une énumération pour stocker différents types

Les vecteurs ne peuvent stocker que des valeurs du même type. Cela peut être un problème ; il y a forcément des cas où on a besoin de stocker une liste d'éléments de types différents. Heureusement, les variantes d'une énumération sont définies sous le même type d'énumération, donc lorsque nous avons besoin d'un type pour représenter les éléments de types différents, nous pouvons définir et utiliser une énumération !

Par exemple, imaginons que nous voulions obtenir les valeurs d'une ligne d'une feuille de calcul dans laquelle quelques colonnes sont des entiers, d'autres des nombres à virgule flottante, et quelques chaînes de caractères. Nous pouvons définir une énumération dont les variantes vont avoir les différents types, et toutes les variantes de l'énumération seront du même type : celui de l'énumération. Ensuite, nous pouvons créer un vecteur pour stocker cette énumération et ainsi, au final, qui stocke différents types. La démonstration de cette technique est dans l'encart 8-10.

fn main() {
    enum Cellule {
        Int(i32),
        Float(f64),
        Text(String),
    }
    
    let ligne = vec![
        Cellule::Int(3),
        Cellule::Text(String::from("bleu")),
        Cellule::Float(10.12),
    ];
}

Encart 8-10 : définition d'une enum pour stocker des valeurs de différents types dans un seul vecteur

Rust a besoin de savoir quel type de donnée sera stocké dans le vecteur au moment de la compilation afin de connaître la quantité de mémoire nécessaire pour stocker chaque élément sur le tas. Nous devons être précis sur les types autorisés dans ce vecteur. Si Rust avait permis qu'un vecteur stocke n'importe quel type, il y aurait pu avoir un risque qu'un ou plusieurs des types provoquent une erreur avec les manipulations effectuées sur les éléments du vecteur. L'utilisation d'une énumération ainsi qu'une expression match permet à Rust de garantir au moment de la compilation que tous les cas possibles sont traités, comme nous l'avons appris au chapitre 6.

Si vous n'avez pas une liste exhaustive des types que votre programme va stocker dans un vecteur, la technique de l'énumération ne va pas fonctionner. À la place, vous pouvez utiliser un objet trait, que nous verrons au chapitre 17.

Maintenant que nous avons vu les manières les plus courantes d'utiliser les vecteurs, prenez le temps de consulter la documentation de l'API pour découvrir toutes les méthodes très utiles définies dans la bibliothèque standard pour Vec<T>. Par exemple, en plus de push, nous avons une méthode pop qui retire et retourne le dernier élément. Intéressons-nous maintenant au prochain type de collection : la String !