I. Les bases de la programmation en Rust
10. Les structures
Comme certains d'entre vous vont s'en rendre compte, les structures sont à la fois très ressemblantes et très différentes de ce que vous pourriez croiser dans d'autres langages (impératifs notamment). Ce chapitre contient beaucoup de nouvelles informations donc n'hésitez surtout pas à prendre votre temps pour être sûr de bien tout comprendre. Commençons donc de ce pas !
À quoi ça ressemble ?
Sachez qu'il existe quatre types de structures en Rust :
- Les structures tuples : une structure dont les champs n'ont pas de nom. Tout comme un tuple, on peut accéder aux champs avec leur position dans la déclaration de la structure.
- Les structures unitaires (on dit aussi structure opaque). Dans le cas où il n'y a pas de données à mettre dans un type, ce sont ces structures qui sont utilisées. Le plus souvent, ce type de structure est utilisé pour assurer des règles de compilation en tant qu'argument de type générique. Nous reviendrons sur la généricité dans la deuxième partie de ce livre.
- Les structures "classiques" : ce sont les plus communes. Elles ont des champs nommés comme ce qu'on peut voir dans des langages comme le C ou le C++.
- Les structures "newtype" : c'est une structure tuple mais avec un seul champ. Cela permet d'ajouter des contrôles sur un type en particulier en le mettant dans un type qui (ré-)implémentera les opérations par-dessus.
Exemple de déclaration pour chacune d'entre elles :
Run// Une structure tuple
struct Tuple(isize, usize, bool);
// Une structure unitaire
struct Unitaire;
// Une structure "classique"
struct Classique {
name: String,
age: usize,
a_un_chat: bool,
}
// Une structure "newtype"
struct StructureTuple(usize);
Maintenant voyons comment on les instancie et on accède à leur(s) champ(s) :
Run// La structure tuple
let t = Tuple(0, 2, false);
println!("0 : {}, 1 : {}, 2 : {}", t.0, t.1, t.2);
// La structure unitaire
let u = Unitaire;
// Pas de champs donc rien à montrer ici.
// La structure "classique"
let c = Classique {
// On convertit une `&'static str` en `String`
name: "Moi".to_owned(),
age: 18,
a_un_chat: false,
};
println!("name : {}, age : {}, a_un_chat : {}", c.name, c.age, c.a_un_chat);
// La structure "newtype"
let nt = NewType(1);
println!("valeur : {}", nt.0);
Vous devez savoir que, par convention, les noms des structures doivent être écrits en camel case en Rust. Par exemple, appeler une structure "ma_structure" serait "invalide". Il faudrait l'appeler "MaStructure". J'insiste bien sur le fait que ce n'est pas obligatoire, ce n'est qu'une convention. Cependant, il est préférable de la suivre autant que possible car cela facilite la lecture pour les autres développeurs. D'ailleurs, il est important d'ajouter :
Les noms des fonctions, par convention en Rust, doivent être écrits en snake case. Donc "MaFonction" est invalide, "ma_fonction" est correct.
Prenons maintenant un exemple d'utilisation de la structure tuple :
Run// Une distance en mètres.
struct Distance(usize);
impl Distance {
fn to_kilometre(&self) -> usize {
self.0 / 1000
}
}
let distance = Distance(2000);
// On peut récuperer la valeur contenue dans le type de cette façon.
let Distance(longueur) = distance;
println!(
"La distance est {}m (ou {} km)",
longueur,
distance.to_kilometre(),
);
Maintenant regardons à quoi sert une structure unitaire. Comme indiqué, elles sont pratiques pour être utilisées pour la généricité (que nous aborderons dans la deuxième partie du livre). Particulièrement dans les ECS (entity component system, ou bien "système de composants d'entité" en français), très utilisés dans les jeux-vidéos. Par-exemple, les monstres et le joueur sont tous des "personnages" et utilisent donc le même type. Mais pour les différencier, on utilisera des structures unitaires en plus pour les différencier ("joueur" et "monstre").
Déstructuration
Il est possible de déstructurer une structure en utilisant le pattern matching ou le pattern binding :
Runstruct Point {
x: i32,
y: i32,
}
let origin = Point { x: 0, y: 0 };
// pattern matching
match origin {
Point { x, y } => println!("({}, {})", x, y),
}
// pattern binding
let Point { x, y } = origin;
println!("({}, {})", x, y);
Il est d'ailleurs possible de ne matcher que certains champs en utilisant ".." :
Runstruct Point {
x: i32,
y: i32,
}
let origin = Point { x: 0, y: 0 };
// pattern matching
match origin {
Point { y, .. } => println!("(.., {})", y),
}
// pattern binding
let Point { y, .. } = origin;
println!("(.., {})", y);
Ici, il ne sera pas possible d'afficher le contenu de "x", car nous l'avons volontairement ignoré lors du matching.
Maintenant que les explications sont faites, voyons comment ajouter des méthodes à une structure.
Les méthodes
Outre le fait qu'ajouter des méthodes à une structure permet de faire de l'orienté-objet, cela peut aussi permettre de forcer un développeur à appeler l'un de vos constructeurs plutôt que de le laisser initialiser tous les éléments de votre type lui-même. Exemple :
Runpub struct Distance {
// Ce champ n'est pas public donc impossible d'y accéder directement
// en-dehors de ce fichier !
metre: i32,
}
impl Distance {
pub fn new() -> Distance {
Distance {
metre: 0,
}
}
pub fn new_with_value(valeur: i32) -> Distance {
Distance {
metre: valeur,
}
}
}
// autre fichier
// Si la définition de Distance est dans fichier.rs
mod fichier;
fn main() {
let d = fichier::Distance::new();
// ou
let d = fichier::Distance::new_with_value(10);
}
Quel intérêt vous vous demandez ? Après tout, on irait aussi vite de le faire nous-même ! Dans le cas présent, il n'y en a effectivement pas beaucoup. Cependant, imaginez une structure contenant une vingtaine de champs, voire plus. C'est tout de suite plus agréable d'avoir une méthode nous permettant de le faire en une ligne. Maintenant, ajoutons une méthode pour convertir cette distance en kilomètre :
Runpub struct Distance {
metre: i32,
}
impl Distance {
pub fn new() -> Distance {
Distance {
metre: 0,
}
}
pub fn new_with_value(valeur: i32) -> Distance {
Distance {
metre: valeur,
}
}
pub fn convert_in_kilometers(&self) -> i32 {
self.metre / 1000
}
}
// autre fichier
// Si la définition de Distance est dans fichier.rs
mod fichier;
fn main() {
let d = fichier::Distance::new();
// ou
let d = fichier::Distance::new_with_value(10000);
println!("distance en kilometres : {}", d.convert_in_kilometers());
}
Une chose importante à noter est qu'une méthode ne prenant pas self en premier paramètre est une méthode statique. Les méthodes new et new_with_value sont donc des méthodes statiques tandis que convert_in_kilometers n'en est pas une.
À présent, venons-en au "&" devant le self : cela indique que self est "prêté" à la fonction. On dit donc que "&self" est une référence vers self. Cela est lié au sytème de propriété de Rust (le fameux "borrow checker"). Nous aborderons cela plus en détails dans un autre chapitre.
Maintenant, si vous voulez créer une méthode pour modifier la distance, il vous faudra spécifier que self est mutable (car toutes les variables en Rust sont constantes par défaut). Exemple :
Runimpl Distance {
// les autres méthodes
// ...
pub fn set_distance(&mut self, nouvelle_distance: i32) {
self.metre = nouvelle_distance;
}
}
Tout simplement !
Syntaxe de mise à jour (ou "update syntax")
Une structure peut inclure ".." pour indiquer qu'elle veut copier certains champs d'une autre structure. Exemple :
Runstruct Point3d {
x: i32,
y: i32,
z: i32,
}
let mut point = Point3d { x: 0, y: 0, z: 0 };
// et ici on prend x et z de Point3d
let mut point2 = Point3d { y: 1, .. point };
Destructeur
Maintenant voyons comment faire un destructeur (une méthode appelée automatiquement lorsque notre objet est détruit) :
Runstruct Distance {
metre: i32,
}
impl Distance {
// fonctions membres
}
impl Drop for Distance {
fn drop(&mut self) {
println!("La structure Distance a été détruite !");
}
}
"D'où ça sort ce impl Drop for Distance ?!"
On a implémenté le trait Drop sur notre structure Distance. Quand l'objet est détruit, cette méthode est appelée. Je sais que cela ne vous dit pas ce qu'est un trait, mais nous y reviendrons dans la deuxième partie de ce cours.