Types, structures de données en Rust
Introduction
Comme expliqué au cours des deux précédents chapitres, le langage Rust est strictement compilé, sans interprétation aucune. La phase de compilation est donc là pour tout vérifier en anticipant les problèmes liés à la mémoire (pour Rust en tout cas). Les différents types utilisés et disponibles en Rust sont donc pensés par les concepteurs du langage pour permettre la meilleure sécurisation possible de leurs utilisations, ceci dès la phase de compilation.
D’où le typage statique. On entend par typage statique le fait que le type d’une variable est connu dès la compilation car défini explicitement. Ainsi, le compilateur peut analyser l’usage des types utilisés et prévenir les éventuels incidents lors de l’exécution du programme.
Commençons par présenter les types primitifs en langage Rust, triés par grandes catégories : d’abord, les types primitifs dits simples, puis les types primitifs semblables à des structures de données.
Les types primitifs simples
1. Types numériques entiers
a. Entiers signés
Les types numériques entiers incluent explicitement dans leurs noms mêmes le nombre de bits sur lequel la valeur est codée.
En termes de numériques entiers signés, les éléments suivants sont à notre disposition :
-
i8
-
i16
-
i32
-
i64
Ainsi, i32 est une valeur entière signée codée sur 32 bits. De cette manière, on a un bit consacré au signe et 31 bits à la valeur entière elle-même. Ce type permet donc de coder des entiers compris entre -2147483648 et +2147483648 (correspondant à 231).
b. Entiers non signés
On trouve les types équivalents non signés :
-
u8
-
u16
-
u32
-
u64
Dans ce cas, l’entièreté des bits est consacrée à l’encodage de la valeur numérique entière. Ainsi, u32 correspond à un entier non signé (forcément positif) codé sur 32 bits. Il permet donc de coder un entier entre 0 et +4294967296 (correspondant à 232).
c. Entiers codés d’après la taille du processeur
Il existe les deux types suivants :
-
isize
-
usize
Le premier, isize, permet de coder un entier signé ; le second, usize, permet quant à lui de coder un entier non signé. La taille de l’encodage dépend de la taille des adresses de la machine...
Les types primitifs, structures de données
D’autres types sont présents en Rust, assimilables à des structures de données, que nous allons brièvement lister ici.
1. Le tuple
Un tuple est un type primitif Rust qui correspond à une structure de données pouvant accueillir tous les types primitifs précédemment évoqués. Par défaut, un tuple est stocké dans la pile. Il est possible de le stocker dans le tas, mais pour cela il faudra utiliser un dispositif appelé « boîte » que nous étudierons un peu plus loin, dans la section consacrée aux pointeurs.
Ainsi, on peut écrire la ligne suivante qui définit un tuple :
let tuple_exemple = (1u64, 2u32, 3u8, 1.2f32, 3.4f64, -4i64, 'R', false);
Dans ce tuple, on réunit des entiers non signés (respectivement sur 32, 64 et 8 bits), deux flottants (respectivement sur 32 et 64 bits), un entier signé sur 64 bits, un caractère (char) et un booléen.
L’écriture utilisée permet de définir à la fois la valeur ainsi que le type, de manière explicite :
-
1u64 signifie ainsi que l’on veut un entier non signé sur 64 bits de valeur 1.
-
1.2f32 signifie ainsi que l’on veut un flottant sur 32 bits de valeur 1,2.
On peut afficher certaines valeurs du tuple comme ci-dessous. Notons au passage qu’un tuple n’est pas muni d’un itérateur permettant de le parcourir. On affiche le deuxième élément puis le sixième :
println!("{}", tuple_exemple.1);
println!("{}", tuple_exemple.5);
On obtient ceci en sortie :
> 2
> -4
Au passage, quand on tente d’accéder à un élément qui n’existe pas dans notre tuple, la compilation va échouer :
println!("{}", tuple_exemple.10);
On obtient ce message d’erreur lors de la compilation :
error[E0609]: no field `10` on type `(u64, u32, u8, f32, f64, i64,
char, bool)` ...
Les types pointeurs en Rust
Les types pointeurs en Rust peuvent être classés en trois grandes catégories :
-
Les références.
-
Les boîtes (box).
-
Les pointeurs non sûrs.
1. Références en Rust
Parmi les types pointeurs en Rust, le type référence est le plus usuel.
Une référence peut aussi bien pointer une valeur dans la pile que dans le tas. La référence contient une adresse, à laquelle se trouve la valeur pointée, ceci de façon un peu similaire à ce qui se fait en langage C++.
La syntaxe est la suivante :
-
&i64 correspond à une référence vers un type i64 (prononcé « ref i64 »).
-
&T est une référence non mutable.
-
&mut T permet d’avoir une référence mutable.
Une référence ne peut pas être nulle en Rust. De façon encore plus générale, chaque référence est scrutée à la compilation pour éviter tout problème de libération multiple de mémoire, de durée de vie et de référence qui ne pointerait sur rien.
Créons un nouveau projet dédié aux pointeurs et créons une référence non mutable vers un f64 et une référence mutable vers un f64 :
fn main() {
let abc = std::f64::consts::PI;
let refNonMutable = &abc;
println!("{}", refNonMutable);
let mut fgh = 3.14 * 2.0;
let refMutable = &mut fgh;
println!("{}", refMutable);
}
Comme on peut le constater, on a défini un f64 non mutable (abc) qui est stocké dans la pile, sur laquelle pointe une référence vers une valeur non mutable (refNonMutable).
De façon similaire, on définit un f64 mutable (fgh) qui est stocké dans la pile, sur laquelle pointe une référence vers une valeur mutable (refMutable).
À l’exécution du programme, on obtient ceci :
> 3.141592653589793
> 6.28
On peut chercher maintenant à afficher les différentes valeurs mises en jeu et les adresses mémoire. On commence...
Les types tableaux, vecteurs et tranches
1. Introduction
Les tableaux (array), vecteurs (vector) et tranches (slice) sont rassemblés dans cette section car ces trois types relatifs à des structures de données possèdent plusieurs aspects en commun. D’une part, ils peuvent être alloués dans le tas ou être définis sur la pile. Par ailleurs, les trois types sont eux-mêmes relatifs à un type donné. On peut ainsi créer un tableau de i32, un vecteur de f64 ou une tranche de booléens.
2. Les tableaux en Rust
Syntaxiquement, on parle d’un type [T; N] et donc d’un tableau de type T contenant N éléments défini ainsi à la compilation. Il n’est donc pas autorisé de modifier la taille du tableau ensuite à l’exécution.
Il existe deux manières de déclarer un tableau. Premièrement, en déclarant explicitement le type inclus et la taille du tableau :
let mut tableauEntiers : [u32; 8] = [2, 1, 3, 5, 6, 4, 7, 8];
Deuxièmement, en déclarant un contenu cohérent sans précision explicite :
let tableauFlottants = [3.4, 2.0, 5.1];
Voici la fonction dédiée aux exemples de tableaux :
fn exempleTableau(){
println!("----------");
let mut tableauEntiers : [u32; 8] = [2, 1, 3, 5, 6, 4, 7, 8];
println!("4e case du tableau d'entiers : {}", tableauEntiers[3]);
tableauEntiers.sort();
for ii in 0..tableauEntiers.len() {
println!("Chaque case après tri : {}", tableauEntiers[ii]);
}
println!("----------");
let tableauFlottants = [3.4, 2.0, 5.1];
println!("Taille du tableau de flottants : {}", tableauFlottants.len());
println!("3e case du tableau de flottants : {}", tableauFlottants[2]);
}
Lors de l’exécution, on obtient ceci :
----------
4e case du tableau d'entiers : 5
Chaque case après tri : 1
Chaque case après tri : 2
Chaque case après...
Le type chaîne de caractères (string)
1. Introduction
Si vous avez déjà programmé en C++, on se trouve ici exactement dans la même configuration. En effet, en C++, deux grands types de chaînes de caractères peuvent être cités :
-
Le type const char*.
-
Le type string issu de la librairie standard.
Ce dernier est très semblable en Rust et se nomme d’ailleurs de façon identique. On peut appliquer une référence dite str (ref str) sur une telle chaîne, qui fonctionne plus ou moins comme une tranche (slice) de chaîne de caractères.
En termes d’encodage, une chaîne de caractères string en Rust est une suite de caractères Unicode en UTF-8.
Passons à la pratique.
2. Cas pratique autour de string
Commençons par créer plusieurs chaînes de caractères, toutes allouées dans le tas. Une tranche (ref str) pointe sur la dernière chaîne de caractères (chaine3) :
fn exempleString(){
let mut chaine1 = String::new();
chaine1.push('a');
chaine1.push('b');
chaine1.push('c');
println!("{}", chaine1);
let chaine2 = String::from("def"); ...
Conclusion
L’essentiel des types Rust a été abordé dans ce chapitre. Certains (les structures notamment) seront approfondis par la suite. On note deux absents de marque pour le moment : les énumérations et surtout la notion de trait.
Pour cela, il nous manque encore certains concepts, à commencer par ceux qui font l’objet du chapitre Possession et emprunt en Rust. Nous verrons notamment la notion de propriété. En Rust, une valeur appartient toujours à une et une seule entité. La raison, en revanche, est évoquée dès le premier chapitre : il s’agit de sécuriser l’usage de la mémoire. Ainsi, comme chaque valeur a une entité propriétaire, la mémoire associée n’est pas « perdue » et on ne risque plus de pointer sur une zone mémoire non allouée par exemple.