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.
Taille | Signé | Non signé |
---|---|---|
8 bits | i8 | u8 |
16 bits | i16 | u16 |
32 bits | i32 | u32 |
64 bits | i64 | u64 |
128 bits | i128 | u128 |
archi | isize | usize |
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
.
Littéral numérique | Exemple |
---|---|
Décimal | 98_222 |
Hexadécimal | 0xff |
Octal | 0o77 |
Binaire | 0b1111_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'unu8
, 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 exemplewrapping_add
- Retourner la valeur
None
s'il y a un dépassement avec des méthodeschecked_*
- 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.