Exploration des types de données en Rust

Beginner

This tutorial is from open-source community. Access the source code

Introduction

Bienvenue dans Data Types. Ce laboratoire est une partie du Rust Book. Vous pouvez pratiquer vos compétences Rust dans LabEx.

Dans ce laboratoire, nous allons explorer le concept de types de données en Rust, où chaque valeur est assignée à un type spécifique pour déterminer la manière dont elle est traitée, et dans les cas où plusieurs types sont possibles, des annotations de type doivent être ajoutées pour fournir les informations nécessaires au compilateur.

Ceci est un Guided Lab, qui fournit des instructions étape par étape pour vous aider à apprendre et à pratiquer. Suivez attentivement les instructions pour compléter chaque étape et acquérir une expérience pratique. Les données historiques montrent que c'est un laboratoire de niveau débutant avec un taux de réussite de 83%. Il a reçu un taux d'avis positifs de 100% de la part des apprenants.

Types de données

Toute valeur en Rust est d'un certain type de données, qui indique à Rust quel type de données est spécifié afin qu'il sache comment travailler avec ces données. Nous examinerons deux sous-ensembles de types de données : scalaires et composites.

Gardez à l'esprit que Rust est un langage statiquement typé, ce qui signifie qu'il doit connaître les types de toutes les variables à la compilation. Le compilateur peut généralement déduire quel type nous souhaitons utiliser en fonction de la valeur et de la manière dont nous l'utilisons. Dans les cas où plusieurs types sont possibles, par exemple lorsque nous avons converti une String en un type numérique en utilisant parse dans "Comparer la supposition au nombre secret", nous devons ajouter une annotation de type, comme ceci :

let guess: u32 = "42".parse().expect("Not a number!");

Si nous n'ajoutons pas l'annotation de type : u32 montrée dans le code précédent, Rust affichera l'erreur suivante, ce qui signifie que le compilateur a besoin de plus d'informations de notre part pour savoir 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 guess = "42".parse().expect("Not a number!");
  |         ^^^^^ consider giving `guess` a type

Vous verrez différentes annotations de type pour d'autres types de données.

Types scalaires

Un type scalaire représente une valeur unique. Rust a quatre types scalaires principaux : les entiers, les nombres à virgule flottante, les booléens et les caractères. Vous les reconnaissez peut-être d'autres langages de programmation. Passons à voir comment ils fonctionnent en Rust.

Types Entiers

Un entier (integer) est un nombre sans composante fractionnaire. Nous avons utilisé un type entier au Chapitre 2, le type u32. Cette déclaration de type indique que la valeur qui lui est associée doit être un entier non signé (les types entiers signés commencent par i au lieu de u) qui occupe 32 bits d'espace. Le Tableau 3-1 montre les types entiers intégrés en Rust. Nous pouvons utiliser n'importe laquelle de ces variantes pour déclarer le type d'une valeur entière.

Tableau 3-1 : Types Entiers en Rust

Longueur Signé Non signé


8 bits i8 u8
16 bits i16 u16
32 bits i32 u32
64 bits i64 u64
128 bits i128 u128
arch isize usize

Chaque variante peut être signée ou non signée et a une taille explicite. Signé (signed) et non signé (unsigned) se réfèrent à la possibilité pour le nombre d'être négatif - en d'autres termes, si le nombre doit avoir un signe avec lui (signé) ou s'il ne sera jamais que positif et peut donc être représenté sans signe (non signé). C'est comme écrire des nombres sur papier : lorsque le signe est important, un nombre est affiché avec un signe plus ou un signe moins ; cependant, lorsqu'il est sûr de supposer que le nombre est positif, il est affiché sans signe. Les nombres signés sont stockés en utilisant la représentation en complément à deux (two's complement).

Chaque variante signée peut stocker des nombres de -(2^(n-1)) à 2^(n-1) - 1 inclus, où n est le nombre de bits que cette variante utilise. Ainsi, un i8 peut stocker des nombres de -(2^7) à 2^7 - 1, ce qui équivaut à -128 à 127. Les variantes non signées peuvent stocker des nombres de 0 à 2^n - 1, donc un u8 peut stocker des nombres de 0 à 2^8 - 1, ce qui équivaut à 0 à 255.

De plus, les types isize et usize dépendent de l'architecture de l'ordinateur sur lequel votre programme s'exécute, ce qui est indiqué dans le tableau par "arch" : 64 bits si vous êtes sur une architecture 64 bits et 32 bits si vous êtes sur une architecture 32 bits.

Vous pouvez écrire des littéraux entiers sous l'une des formes présentées dans le Tableau 3-2. Notez que les littéraux numériques qui peuvent être de plusieurs types numériques autorisent un suffixe de type, tel que 57u8, pour désigner le type. Les littéraux numériques peuvent également utiliser _ comme séparateur visuel pour faciliter la lecture du nombre, comme 1_000, qui aura la même valeur que si vous aviez spécifié 1000.

Tableau 3-2 : Littéraux Entiers en Rust

Littéraux numériques Exemple


Décimal 98_222
Hexadécimal 0xff
Octal 0o77
Binaire 0b1111_0000
Octet (Byte) (uniquement u8) b'A'

Alors, comment savoir quel type d'entier utiliser ? Si vous n'êtes pas sûr, les valeurs par défaut de Rust sont généralement de bons points de départ : les types entiers sont par défaut i32. La principale situation dans laquelle vous utiliseriez isize ou usize est lors de l'indexation d'une sorte de collection.

Dépassement de capacité (Integer Overflow)

Disons que vous avez une variable de type u8 qui peut contenir des valeurs entre 0 et 255. Si vous essayez de changer la variable en une valeur en dehors de cette plage, comme 256, un dépassement de capacité (integer overflow) se produira, ce qui peut entraîner l'un des deux comportements. Lorsque vous compilez en mode débogage, Rust inclut des vérifications de dépassement de capacité qui font que votre programme panique (panic) au moment de l'exécution si ce comportement se produit. Rust utilise le terme paniquer (panicking) lorsqu'un programme se termine avec une erreur ; nous discuterons des paniques plus en détail dans "Erreurs irrécupérables avec panic!".

Lorsque vous compilez en mode release avec l'indicateur --release, Rust n'inclut pas de vérifications de dépassement de capacité qui provoquent des paniques. Au lieu de cela, si un dépassement de capacité se produit, Rust effectue un wrapping en complément à deux (two's complement wrapping). En bref, les valeurs supérieures à la valeur maximale que le type peut contenir "s'enroulent" (wrap around) vers le minimum des valeurs que le type peut contenir. Dans le cas d'un u8, la valeur 256 devient 0, la valeur 257 devient 1, et ainsi de suite. Le programme ne paniquera pas, mais la variable aura une valeur qui n'est probablement pas celle que vous attendiez. Se fier au comportement d'enroulement du dépassement de capacité est considéré comme une erreur.

Pour gérer explicitement la possibilité d'un dépassement de capacité, vous pouvez utiliser ces familles de méthodes fournies par la bibliothèque standard pour les types numériques primitifs :

  • Enrouler dans tous les modes avec les méthodes wrapping_*, telles que wrapping_add.
  • Retourner la valeur None s'il y a dépassement de capacité avec les méthodes checked_*.
  • Retourner la valeur et un booléen indiquant s'il y a eu dépassement de capacité avec les méthodes overflowing_*.
  • Saturer aux valeurs minimales ou maximales de la valeur avec les méthodes saturating_*.

Types à virgule flottante

Rust dispose également de deux types primitifs pour les nombres à virgule flottante, qui sont des nombres avec des points décimaux. Les types à virgule flottante de Rust sont f32 et f64, qui ont respectivement une taille de 32 bits et 64 bits. Le type par défaut est f64 car sur les processeurs modernes, sa vitesse est approximativement la même que celle de f32 mais elle est capable d'une plus grande précision. Tous les types à virgule flottante sont signés.

Créez un nouveau projet appelé data-types :

cargo new data-types
cd data-types

Voici un exemple qui montre les nombres à virgule flottante en action :

Nom de fichier : src/main.rs

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

    let y: f32 = 3.0; // f32
}

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

Opérations numériques

Rust prend en charge les opérations mathématiques de base que vous attendriez pour tous les types de nombres : addition, soustraction, multiplication, division et reste. La division entière tronque vers zéro jusqu'au plus proche entier. Le code suivant montre comment utiliser chaque opération numérique dans une instruction let :

Nom de fichier : src/main.rs

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

    // soustraction
    let difference = 95.5 - 4.3;

    // multiplication
    let product = 4 * 30;

    // division
    let quotient = 56.7 / 32.2;
    let truncated = -5 / 3; // Résulte en -1

    // reste
    let remainder = 43 % 5;
}

Chaque expression dans ces instructions utilise un opérateur mathématique et évalue à une seule valeur, qui est ensuite liée à une variable. L'annexe B contient une liste de tous les opérateurs que Rust fournit.

Le type booléen

Comme dans la plupart des autres langues de programmation, un type booléen en Rust a deux valeurs possibles : true et false. Les booléens ont une taille d'un octet. Le type booléen en Rust est spécifié en utilisant bool. Par exemple :

Nom de fichier : src/main.rs

fn main() {
    let t = true;

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

La principale manière d'utiliser des valeurs booléennes est à travers des conditionnels, tels qu'une expression if. Nous aborderons comment fonctionnent les expressions if en Rust dans "Flux de contrôle".

Le type caractère

Le type char de Rust est le type alphabétique le plus primitif du langage. Voici quelques exemples de déclaration de valeurs char :

Nom de fichier : src/main.rs

fn main() {
    let c = 'z';
    let z: char = 'ℤ'; // avec annotation de type explicite
    let heart_eyed_cat = '😻';
}

Notez que nous spécifions les littéraux char avec des apostrophes simples, contrairement aux littéraux de chaîne, qui utilisent des guillemets doubles. Le type char de Rust a une taille de quatre octets et représente une Valeur scalaire Unicode, ce qui signifie qu'il peut représenter bien plus que simplement le code ASCII. Les lettres accentuées ; les caractères chinois, japonais et coréens ; les émojis ; et les espaces de largeur nulle sont tous des valeurs char valides en Rust. Les Valeurs scalaires Unicode vont de U+0000 à U+D7FF et de U+E000 à U+10FFFF inclusivement. Cependant, un "caractère" n'est pas vraiment un concept dans Unicode, donc votre intuition humaine de ce qu'est un "caractère" peut ne pas correspondre à ce qu'est un char en Rust. Nous aborderons ce sujet en détail dans "Stockage de texte encodé en UTF-8 avec les chaînes".

Types composés

Les types composés peuvent regrouper plusieurs valeurs en un seul type. Rust a deux types composés primitifs : les tuples et les tableaux.

Le type tuple

Un tuple est une manière générale de regrouper un certain nombre de valeurs de différents types en un seul type composé. Les tuples ont une longueur fixe : une fois déclarés, ils ne peuvent pas grandir ou rétrécir en taille.

Nous créons un tuple en écrivant une liste séparée par des virgules de valeurs entre parenthèses. Chaque position dans le tuple a un type, et les types des différentes valeurs dans le tuple n'ont pas besoin d'être les mêmes. Nous avons ajouté des annotations de type optionnelles dans cet exemple :

Nom de fichier : src/main.rs

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

La variable tup est liée à l'ensemble du tuple car un tuple est considéré comme un seul élément composé. Pour extraire les valeurs individuelles d'un tuple, nous pouvons utiliser la correspondance de motifs pour décomposer une valeur de tuple, comme ceci :

Nom de fichier : src/main.rs

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

    let (x, y, z) = tup;

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

Ce programme crée d'abord un tuple et le lie à la variable tup. Il utilise ensuite un motif avec let pour prendre tup et le transformer en trois variables distinctes, x, y et z. Cela s'appelle décomposition car il brise le tuple unique en trois parties. Enfin, le programme imprime la valeur de y, qui est 6,4.

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

Nom de fichier : src/main.rs

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

    let five_hundred = x.0;

    let six_point_four = x.1;

    let one = x.2;
}

Ce programme crée le tuple x puis accède à chaque élément du tuple en utilisant leurs indices respectifs. Comme dans la plupart des langages de programmation, le premier index dans un tuple est 0.

Le tuple sans aucune valeur a un nom spécial, unité. Cette valeur et son type correspondant sont tous deux écrits () et représentent une valeur vide ou un type de retour vide. Les expressions renvoient implicitement la valeur d'unité si elles ne renvoient pas d'autre valeur.

Le type tableau

Une autre manière d'avoir une collection de plusieurs valeurs est avec un tableau. Contrairement à un tuple, chaque élément d'un tableau doit avoir le même type. Contrairement aux tableaux dans certains autres langages, les tableaux en Rust ont une longueur fixe.

Nous écrivons les valeurs dans un tableau comme une liste séparée par des virgules à l'intérieur de crochets :

Nom de fichier : src/main.rs

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

Les tableaux sont utiles lorsque vous voulez que vos données soient allouées sur la pile plutôt que sur la pile (nous en parlerons plus en détail dans le chapitre 4) ou lorsque vous voulez vous assurer d'avoir toujours un nombre fixe d'éléments. Cependant, un tableau n'est pas aussi flexible que le type vecteur. Un vecteur est un type de collection similaire fourni par la bibliothèque standard qui peut grandir ou rétrécir en taille. Si vous n'êtes pas sûr de devoir utiliser un tableau ou un vecteur, il est probable que vous devriez utiliser un vecteur. Le chapitre 8 traite des vecteurs en détail.

Cependant, les tableaux sont plus utiles lorsque vous savez que le nombre d'éléments ne devra pas changer. Par exemple, si vous utilisez les noms des mois dans un programme, vous utiliseriez probablement un tableau plutôt qu'un vecteur car vous savez qu'il contiendra toujours 12 éléments :

let months = ["January", "February", "March", "April", "May", "June", "July",
              "August", "September", "October", "November", "December"];

Vous écrivez le type d'un tableau en utilisant des crochets avec le type de chaque élément, un point-virgule, puis le nombre d'éléments dans le tableau, comme ceci :

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 également initialiser un tableau pour qu'il contienne la même valeur pour chaque élément en spécifiant la valeur initiale, suivie d'un point-virgule, puis la longueur du tableau entre crochets, comme montré ici :

let a = [3; 5];

Le tableau nommé a contiendra 5 éléments qui seront tous initialisés à la valeur 3. Cela équivaut à écrire let a = [3, 3, 3, 3, 3]; mais de manière plus concise.

Accès aux éléments d'un tableau

Un tableau est un bloc unique de mémoire d'une 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 :

Nom de fichier : src/main.rs

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

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

Dans cet exemple, la variable nommée first obtiendra la valeur 1 car c'est la valeur à l'index [0] dans le tableau. La variable nommée second obtiendra la valeur 2 à partir de l'index [1] dans le tableau.

Accès invalide à un élément de tableau

Voyons ce qui se passe si vous essayez d'accéder à un élément d'un tableau qui est au-delà de la fin du tableau. Disons que vous exécutez ce code, similaire au jeu de devinette du chapitre 2, pour obtenir un index de tableau à partir de l'utilisateur :

Nom de fichier : src/main.rs

use std::io;

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

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

    let mut index = String::new();

    io::stdin()
     .read_line(&mut index)
     .expect("Échec de lecture de la ligne");

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

    let element = a[index];

    println!(
        "La valeur de l'élément à l'index {index} est : {element}"
    );
}

Ce code se compile avec succès. Si vous exécutez ce code en utilisant cargo run et que vous entrez 0, 1, 2, 3 ou 4, le programme affichera la valeur correspondante à cet index dans le tableau. Si vous entrez au lieu de cela un nombre au-delà de la fin du tableau, tel que 10, vous verrez une sortie comme celle-ci :

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 entraîné une erreur au moment de l'exécution au niveau de l'utilisation d'une valeur invalide dans l'opération d'indexation. Le programme est sorti avec un message d'erreur et n'a pas exécuté l'instruction println! finale. Lorsque vous essayez d'accéder à un élément en utilisant l'indexation, Rust vérifiera que l'index que vous avez spécifié est inférieur à la longueur du tableau. Si l'index est supérieur ou égal à la longueur, Rust générera une panique. Ce contrôle doit se produire au moment de l'exécution, surtout dans ce cas, car le compilateur ne peut pas savoir quelle valeur un utilisateur entrera lorsqu'il exécutera le code plus tard.

C'est un exemple des principes de sécurité mémoire de Rust en action. Dans de nombreux langages de bas niveau, ce genre de contrôle n'est pas effectué, et lorsque vous fournissez un index incorrect, une mémoire invalide peut être accédée. Rust vous protège contre ce genre d'erreur en sortant immédiatement au lieu de permettre l'accès mémoire et de continuer. Le chapitre 9 traite davantage de la gestion d'erreurs de Rust et de la manière dont vous pouvez écrire un code lisible et sécurisé qui ne génère ni panique ni accès mémoire invalide.

Sommaire

Félicitations ! Vous avez terminé le laboratoire sur les types de données. Vous pouvez pratiquer d'autres laboratoires sur LabEx pour améliorer vos compétences.