Les types de données

Chaque valeur en Rust est d'un type bien déterminé, qui indique à Rust quel genre de données il manipule pour qu'il sache comment traiter ces données. Nous allons nous intéresser à deux catégories de types de données : les scalaires et les composés.

Gardez à l'esprit que Rust est un langage statiquement typé, ce qui signifie qu'il doit connaître les types de toutes les variables au moment de la compilation. Le compilateur peut souvent déduire quel type utiliser en se basant sur la valeur et sur la façon dont elle est utilisée. Dans les cas où plusieurs types sont envisageables, comme lorsque nous avons converti une chaîne de caractères en un type numérique en utilisant parse dans la section “Comparer le nombre saisi au nombre secret” du chapitre 2, nous devons ajouter une annotation de type, comme ceci :


#![allow(unused)]
fn main() {
let supposition: u32 = "42".parse().expect("Ce n'est pas un nombre !");
}

Si nous n'ajoutons pas l'annotation de type ici, Rust affichera l'erreur suivante, signifiant que le compilateur a besoin de plus d'informations pour déterminer quel type nous souhaitons utiliser :

$ cargo build
   Compiling no_type_annotations v0.1.0 (file:///projects/no_type_annotations)
error[E0282]: type annotations needed
 --> src/main.rs:2:9
  |
2 |     let supposition = "42".parse().expect("Ce n'est pas un nombre !");
  |         ^^^^^^^^^^^ consider giving `supposition` a type

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

Vous découvrirez différentes annotations de type au fur et à mesure que nous aborderons les autres types de données.

Types scalaires

Un type scalaire représente une seule valeur. Rust possède quatre types principaux de scalaires : les entiers, les nombres à virgule flottante, les booléens et les caractères. Vous les connaissez sûrement d'autres langages de programmation. Regardons comment ils fonctionnent avec Rust.

Types de nombres entiers

Un entier est un nombre sans partie décimale. Nous avons utilisé un entier précédemment dans le chapitre 2, le type u32. Cette déclaration de type indique que la valeur à laquelle elle est associée doit être un entier non signé encodé sur 32 bits dans la mémoire (les entiers pouvant prendre des valeurs négatives commencent par un i (comme integer : “entier”), plutôt que par un u comme unsigned : “non signé”). Le tableau 3-1 montre les types d'entiers intégrés au langage. Nous pouvons utiliser chacune de ces variantes pour déclarer le type d'une valeur entière.

Tableau 3-1 : les types d'entiers en Rust

TailleSignéNon signé
8 bitsi8u8
16 bitsi16u16
32 bitsi32u32
64 bitsi64u64
128 bitsi128u128
archiisizeusize

Chaque variante peut être signée ou non signée et possède une taille explicite. Signé et non signé veut dire respectivement que le nombre peut prendre ou non des valeurs négatives — en d'autres termes, si l'on peut lui attribuer un signe (signé) ou s'il sera toujours positif et que l'on peut donc le représenter sans signe (non signé). C'est comme écrire des nombres sur du papier : quand le signe est important, le nombre est écrit avec un signe plus ou un signe moins ; en revanche, quand le nombre est forcément positif, on peut l'écrire sans son signe. Les nombres signés sont stockés en utilisant le complément à deux.

Chaque variante signée peut stocker des nombres allant de −(2n − 1) à 2n − 1 − 1 inclus, où n est le nombre de bits que cette variante utilise. Un i8 peut donc stocker des nombres allant de −(27) à 27 − 1, c'est-à-dire de −128 à 127. Les variantes non signées peuvent stocker des nombres de 0 à 2n − 1, donc un u8 peut stocker des nombres allant de 0 à 28 − 1, c'est-à-dire de 0 à 255.

De plus, les types isize et usize dépendent de l'architecture de l'ordinateur sur lequel votre programme va s'exécuter, d'où la ligne “archi” : 64 bits si vous utilisez une architecture 64 bits, ou 32 bits si vous utilisez une architecture 32 bits.

Vous pouvez écrire des littéraux d'entiers dans chacune des formes décrites dans le tableau 3-2. Notez que les littéraux numériques qui peuvent être de plusieurs types numériques autorisent l'utilisation d'un suffixe de type, tel que 57u8, afin de préciser leur type. Les nombres littéraux peuvent aussi utiliser _ comme séparateur visuel afin de les rendre plus lisible, comme par exemple 1_000, qui a la même valeur que si vous aviez renseigné 1000.

Tableau 3-2 : les littéraux d'entiers en Rust

Littéral numériqueExemple
Décimal98_222
Hexadécimal0xff
Octal0o77
Binaire0b1111_0000
Octet (u8 seulement)b'A'

Comment pouvez-vous déterminer le type d'entier à utiliser ? Si vous n'êtes pas sûr, les choix par défaut de Rust sont généralement de bons choix : le type d'entier par défaut est le i32. La principale utilisation d'un isize ou d'un usize est lorsque l'on indexe une quelconque collection.

Dépassement d'entier

Imaginons que vous avez une variable de type u8 qui peut stocker des valeurs entre 0 et 255. Si vous essayez de changer la variable pour une valeur en dehors de cet intervalle, comme 256, vous aurez un dépassement d'entier (integer overflow), qui peut se compter de deux manière. Lorsque vous compilez en mode débogage, Rust embarque des vérifications pour détecter les cas de dépassements d'entiers qui pourraient faire paniquer votre programme à l'exécution si ce phénomène se produit. Rust utilise le terme paniquer quand un programme se termine avec une erreur ; nous verrons plus en détail les paniques dans une section du chapitre 9.

Lorsque vous compilez en mode publication (release) avec le drapeau --release, Rust ne va pas vérifier les potentiels dépassements d'entiers qui peuvent faire paniquer le programme. En revanche, en cas de dépassement, Rust va effectuer un rebouclage du complément à deux. Pour faire simple, les valeurs supérieures à la valeur maximale du type seront “rebouclées” depuis la valeur minimale que le type peut stocker. Dans le cas d'un u8, la valeur 256 devient 0, la valeur 257 devient 1, et ainsi de suite. Le programme ne va paniquer, mais la variable va avoir une valeur qui n'est probablement pas ce que vous attendez à avoir. Se fier au comportement du rebouclage lors du dépassement d'entier est considéré comme une faute.

Pour gérer explicitement le dépassement, vous pouvez utiliser les familles de méthodes suivantes qu'offrent la bibliothèque standard sur les types de nombres primitifs :

  • Enveloppez les opérations avec les méthodes wrapping_*, comme par exemple wrapping_add
  • Retourner la valeur None s'il y a un dépassement avec des méthodes checked_*
  • Retourner la valeur et un booléen qui indique s'il y a eu un dépassement avec des méthodes overflowing_*
  • Saturer à la valeur minimale ou maximale avec des méthodes saturating_*

Types de nombres à virgule flottante

Rust possède également deux types primitifs pour les nombres à virgule flottante (ou flottants), qui sont des nombres avec des décimales. Les types de flottants en Rust sont les f32 et les f64, qui ont respectivement une taille en mémoire de 32 bits et 64 bits. Le type par défaut est le f64 car sur les processeurs récents ce type est quasiment aussi rapide qu'un f32 mais est plus précis. Tous les flottants ont un signe.

Voici un exemple montrant l'utilisation de nombres à virgule flottante :

Ficher : src/main.rs

fn main() {
    let x = 2.0; // f64

    let y: f32 = 3.0; // f32
}

Les nombres à virgule flottante sont représentés selon la norme IEEE-754. Le type f32 est un flottant à simple précision, et le f64 est à double précision.

Les opérations numériques

Rust offre les opérations mathématiques de base dont vous auriez besoin pour tous les types de nombres : addition, soustraction, multiplication, division et modulo. Les divisions d'entiers arrondissent le résultat à l'entier le plus près. Le code suivant montre comment utiliser chacune des opérations numériques avec une instruction let :

Fichier : src/main.rs

fn main() {
    // addition
    let somme = 5 + 10;

    // soustraction
    let difference = 95.5 - 4.3;

    // multiplication
    let produit = 4 * 30;

    // division
    let quotient = 56.7 / 32.2;
    let arrondi = 2 / 3; // retournera 0

    // modulo
    let reste = 43 % 5;
}

Chaque expression de ces instructions utilise un opérateur mathématique et calcule une valeur unique, qui est ensuite attribuée à une variable. L'annexe B présente une liste de tous les opérateurs que Rust fournit.

Le type booléen

Comme dans la plupart des langages de programmation, un type booléen a deux valeurs possibles en Rust : true (vrai) et false (faux). Les booléens prennent un octet en mémoire. Le type booléen est désigné en utilisant bool. Par exemple :

Fichier : src/main.rs

fn main() {
    let t = true;

    let f: bool = false; // avec une annotation de type explicite
}

Les valeurs booléennes sont principalement utilisées par les structures conditionnelles, comme l'expression if. Nous aborderons le fonctionnement de if en Rust dans la section “Les structures de contrôle”.

Le type caractère

Le type char (comme character) est le type de caractère le plus rudimentaire. Voici quelques exemples de déclaration de valeurs de type char :

Fichier : src/main.rs

fn main() {
    let c = 'z';
    let z = 'ℤ';
    let chat_aux_yeux_de_coeur = '😻';
}

Notez que nous renseignons un litéral char avec des guillemets simples, contrairement aux littéraux de chaîne de caractères, qui nécéssite des doubles guillemets. Le type char de Rust prend quatre octets en mémoire et représente une valeur scalaire Unicode, ce qui veut dire que cela représente plus de caractères que l'ASCII. Les lettres accentuées ; les caractères chinois, japonais et coréens ; les emoji ; les espaces de largeur nulle ont tous une valeur pour char avec Rust. Les valeurs scalaires Unicode vont de U+0000 à U+D7FF et de U+E000 à U+10FFFF inclus. Cependant, le concept de “caractère” n'est pas clairement défini par Unicode, donc votre notion de “caractère” peut ne pas correspondre à ce qu'est un char en Rust. Nous aborderons ce sujet plus en détail au chapitre 8.

Les types composés

Les types composés peuvent regrouper plusieurs valeurs dans un seul type. Rust a deux types composés de base : les tuples et les tableaux (arrays).

Le type tuple

Un tuple est une manière générale de regrouper plusieurs valeurs de types différents en un seul type composé. Les tuples ont une taille fixée : à partir du moment où ils ont été déclarés, on ne peut pas y ajouter ou enlever des valeurs.

Nous créons un tuple en écrivant une liste séparée par des virgules entre des parenthèses. Chaque emplacement dans le tuple a un type, et les types de chacune des valeurs dans le tuple n'ont pas forcément besoin d'être les mêmes. Nous avons ajouté des annotations de type dans cet exemple, mais c'est optionnel :

Fichier : src/main.rs

fn main() {
    let tup: (i32, f64, u8) = (500, 6.4, 1);
}

La variable tup est liée à tout le tuple, car un tuple est considéré comme étant un unique élément composé. Pour obtenir un élément précis de ce tuple, nous pouvons utiliser un filtrage par motif (pattern matching) pour déstructurer ce tuple, comme ceci :

Fichier : src/main.rs

fn main() {
    let tup = (500, 6.4, 1);

    let (x, y, z) = tup;

    println!("La valeur de y est : {}", y);
}

Le programme commence par créer un tuple et il l'assigne à la variable tup. Il utilise ensuite un motif avec let pour prendre tup et le scinder en trois variables distinctes : x, y, et z. On appelle cela déstructurer, car il divise le tuple en trois parties. Puis finalement, le programme affiche la valeur de y, qui est 6.4.

Nous pouvons aussi accéder directement à chaque élément du tuple en utilisant un point (.) suivi de l'indice de la valeur que nous souhaitons obtenir. Par exemple :

Fichier : src/main.rs

fn main() {
    let x: (i32, f64, u8) = (500, 6.4, 1);

    let cinq_cents = x.0;

    let six_virgule_quatre = x.1;

    let un = x.2;
}

Ce programme crée le tuple x puis crée une nouvelle variable pour chaque élément en utilisant leur indices respectifs. Comme dans de nombreux langages de programmation, le premier indice d'un tuple est 0.

Le tuple sans aucune valeur, (), est un type spécial qui a une seule et unique valeur, qui s'écrit aussi (). Ce type est aussi appelé le type unité et la valeur est appelée valeur unité. Les expressions retournent implicitement la valeur unité si elles ne retournent aucune autre valeur.

Le type tableau

Un autre moyen d'avoir une collection de plusieurs valeurs est d'utiliser un tableau. Contrairement aux tuples, chaque élément d'un tableau doit être du même type. Contrairement aux tableaux de certains autres langages, les tableaux de Rust ont une taille fixe.

Nous écrivons les valeurs dans un tableau via une liste entre des crochets, séparée par des virgules :

Fichier : src/main.rs

fn main() {
    let a = [1, 2, 3, 4, 5];
}

Les tableaux sont utiles quand vous voulez que vos données soient allouées sur la pile (stack) plutôt que sur le tas (heap) (nous expliquerons la pile et le tas au chapitre 4) ou lorsque vous voulez vous assurer que vous avez toujours un nombre fixe d'éléments. Cependant, un tableau n'est pas aussi flexible qu'un vecteur (vector). Un vecteur est un type de collection de données similaire qui est fourni par la bibliothèque standard qui, lui, peut grandir ou rétrécir en taille. Si vous ne savez pas si vous devez utiliser un tableau ou un vecteur, il y a de fortes chances que vous devriez utiliser un vecteur. Le chapitre 8 expliquera les vecteurs.

Toutefois, les tableaux s'avèrent plus utiles lorsque vous savez que le nombre d'éléments n'aura pas besoin de changer. Par exemple, si vous utilisez les noms des mois dans un programme, vous devriez probablement utiliser un tableau plutôt qu'un vecteur car vous savez qu'il contient toujours 12 éléments :


#![allow(unused)]
fn main() {
let mois = ["Janvier", "Février", "Mars", "Avril", "Mai", "Juin", "Juillet",
            "Août", "Septembre", "Octobre", "Novembre", "Décembre"];
}

Vous pouvez écrire le type d'un tableau en utilisant des crochets et entre ces crochets y ajouter le type de chaque élément, un point-virgule, et ensuite le nombre d'éléments dans le tableau, comme ceci :


#![allow(unused)]
fn main() {
let a: [i32; 5] = [1, 2, 3, 4, 5];
}

Ici, i32 est le type de chaque élément. Après le point-virgule, le nombre 5 indique que le tableau contient cinq éléments.

Vous pouvez initialiser un tableau pour qu'il contienne toujours la même valeur pour chaque élément, vous pouvez préciser la valeur initiale, suivie par un point-virgule, et ensuite la taille du tableau, le tout entre crochets, comme ci-dessous :


#![allow(unused)]
fn main() {
let a = [3; 5];
}

Le tableau a va contenir 5 éléments qui auront tous la valeur initiale 3. C'est la même chose que d'écrire let a = [3, 3, 3, 3, 3]; mais de manière plus concise.

Accéder aux éléments d'un tableau

Un tableau est un simple bloc de mémoire de taille connue et fixe, qui peut être alloué sur la pile. Vous pouvez accéder aux éléments d'un tableau en utilisant l'indexation, comme ceci :

Fichier : src/main.rs

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

    let premier = a[0];
    let second = a[1];
}

Dans cet exemple, la variable qui s'appelle premier aura la valeur 1, car c'est la valeur à l'indice [0] dans le tableau. La variable second récupèrera la valeur 2 depuis l'indice [1] du tableau.

Accès incorrect à un élément d'un tableau

Découvrons ce qui se passe quand vous essayez d'accéder à un élément d'un tableau qui se trouve après la fin du tableau ? Imaginons que vous exécutiez le code suivant, similaire au jeu du plus ou du moins du chapitre 2, pour demander un indice de tableau à l'utilisateur :

Fichier : src/main.rs

use std::io;

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

    println!("Veuillez entrer un indice de tableau.");

    let mut indice = String::new();

    io::stdin()
        .read_line(&mut indice)
        .expect("Échec de la lecture de l'entrée utilisateur");

    let indice: usize = indice
        .trim()
        .parse()
        .expect("L'indice entré n'est pas un nombre");

    let element = a[indice];

    println!(
        "La valeur de l'élément d'indice {} est : {}",
        indice, element
    );
}

Ce code compile avec succès. Si vous exécutez ce code avec cargo run et que vous entrez 0, 1, 2, 3 ou 4, le programme affichera la valeur correspondante à cet indice dans le tableau. Si au contraire, vous entrez un indice après la fin du tableau tel que 10, ceci s'affichera :

thread 'main' panicked at 'index out of bounds: the len is 5 but the index is 10', src/main.rs:19:19
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

Le programme a rencontré une erreur à l'exécution, au moment d'utiliser une valeur invalide comme indice. Le programme s'est arrêté avec un message d'erreur et n'a pas exécuté la dernière instruction println!. Quand vous essayez d'accéder à un élément en utilisant l'indexation, Rust va vérifier que l'indice que vous avez demandé est plus petit que la taille du tableau. Si l'indice est supérieur ou égal à la taille du tableau, Rust va paniquer. Cette vérification doit avoir lieu à l'exécution, surtout dans ce cas, parce que le compilateur ne peut pas deviner la valeur qu'entrera l'utilisateur quand il exécutera le code plus tard.

C'est un exemple de mise en pratique des principes de sécurité de la mémoire par Rust. Dans de nombreux langages de bas niveau, ce genre de vérification n'est pas effectuée, et quand vous utilisez un indice incorrect, de la mémoire invalide peut être récupérée. Rust vous protège de ce genre d'erreur en quittant immédiatement l'exécution au lieu de permettre l'accès en mémoire et continuer son déroulement. Le chapitre 9 expliquera la gestion d'erreurs de Rust.