Thanks!
X
Paypal
Github sponsorship
Patreon
Acheter version papier

Rust

Fork me on GitHub

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.
  • Les structures unitaires (on dit aussi structure opaque).
  • Les structures "classiques" (comme en C).
  • Les structures "newtype" (un mélange entre les tuples et les structures "classiques").

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 :

Run// La structure tuple
let t = Tuple(0, 2, false);

// La structure unitaire
let u = Unitaire;

// La structure "classique"
let c = Classique {
    // on convertit une &'static str en String
    name: "Moi".to_owned(),
    age: 18,
    a_un_chat: false,
};

// La structure "newtype"
let st = StructureTuple(1);

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.

Avec les exemples que je vous ai donné au-dessus, je pense que certains d'entre vous se demandent à quoi peut bien servir la "structure tuple" ? Hé bien pas à grand-chose dans la majorité des cas, mais il y en a un où elle est très utile :

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(),
);

"D'accord. Et la structure unitaire ?"

Maintenant regardons à quoi sert une structure unitaire. Elles sont pratiques pour être utilisées pour la généricité. Particulièrement dans les ECS (entity component system, ou bien "système de composants d'entité" en bon 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 dites ? Après tout, on irait aussi vite de le faire nous-même ! Dans le cas présent, il n'y en a pas beaucoup, c'est vrai. 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.