Blocs impl

Nous avons vu précédemment que Rust permet d’associer des fonctions à des types, afin notamment de définir des méthodes. Pour déclarer ce type de fonctions, et d’autres entités attachées à un type, on utilise les blocs d’implémentation, qui sont l’objet de ce chapitre.

Introduction

En Rust, on ne mélange pas la déclaration des données d’un type et celle de l’API associée comme en C++. On déclare les méthodes, fonctions et constantes associées à un type dans des blocs d’implémentation séparés, introduits par le mot-clé impl.

En voici un exemple, que nous allons analyser dans la suite de ce chapitre :

#[derive(Debug)]
struct Vec2(f32, f32);

impl Vec2 {
    const X: Self = Self(1.0, 0.0);
    const Y: Self = Self(0.0, 1.0);

    fn new(x: f32, y: f32) -> Self {
        Self(x, y)
    }

    fn x(&self) -> f32 {
        self.0
    }

    fn y(&self) -> f32 {
        self.1
    }

    fn dot(&self, other: &Self) -> f32 {
        self.x() * other.x() + self.y() * other.y()
    }
}


fn main() {
    let v = (Vec2::X).dot(&Vec2::Y);
    println!("{v:?}");
}

Implémentation inhérente

Le premier élément nouveau dans le code ci-dessus, c’est le bloc d’implémentation, introduit par le mot-clé impl. Il en existe deux formes, la forme simple discutée ici (bloc d’implémentation inhérent) et une forme plus complexe impliquant les traits que nous aborderons dans le chapitre associé.

En Rust, on peut écrire autant de blocs d’implémentation qu’on veut pour un type, mais on ne peut écrire des blocs d’implémentation inhérents que pour les types que nous avons déclaré. Il est interdit d’en écrire pour des types définis par d’autres crates, y compris la bibliothèque standard.

La raison est que si c’était autorisé, cela nuirait à l’interopérabilité entre bibliothèques. En effet, si par exemple deux bibliothèques décidaient indépendamment d’ajouter une fonction foo() au type usize, nous ne pourrions pas utiliser ces bibliothèques simultanément, car le compilateur ne saurait pas quelle version de la méthode usize::foo() il doit utiliser lorsqu’on appelle cette méthode. Il faudrait donc remplacer tous nos appels à usize::foo() par une syntaxe explicite du genre usize::<foo from bibliotheque1>(), ce qui serait un cauchemar.

Ce problème est appelé le problème de la cohérence, et est analogue à la One Definition Rule de C++, le comportement indéfini en moins. Nous verrons plus tard comment Rust permet de le contourner partiellement grâce au mécanisme des traits.

Self et self

Dans un bloc d’implémentation, on peut utiliser deux nouveaux mots-clés :

  • Self, avec une majuscule, désigne le type auquel s’applique le bloc d’implémentation.
  • self, avec une minuscule, est utilisable en premier argument d’une fonction et désigne une variable d’un type “lié à” Self. Une fonction avec un tel argument peut être utilisée via la syntaxe receveur.methode(), et on appelle une telle fonction une méthode.

Le paramètre self a une palette de syntaxes assez riche. Dans l’exemple ci-dessus, nous utilisons &self, qui est un raccourci vers la syntaxe plus explicite self: &Self, et permet de dire que cette méthode accepte son paramètre self par référence.

Le fonctionnement des références en Rust sera expliqué dans un prochain chapitre. Pour l’heure vous pouvez retenir que comme en C++, c’est un moyen de garder une variable à sa position actuelle en mémoire et d’en passer un genre de pointeur à la fonction qui l’utilise.

Constantes, fonctions et méthodes associées

En Rust, comme en C++, chaque type a un scope associé. Les déclarations que nous effectuons au sein d’un bloc d’implémentation sont ajoutées à ce scope, et on les appelle entités associées.

#[derive(Debug)]
struct Vec2(f32, f32);

impl Vec2 {
    const XY: Self = Self(1.0, 1.0);
}

fn main() {
    println!("{:?}", Vec2::XY);
}

A l’heure où ces lignes sont écrites, on ne peut pas déclarer tout en n’importe quoi dans un bloc d’implémentation. Seules les déclarations de constantes et de fonctions/méthodes associées sont autorisées dans les blocs d’implémentation inhérents.

Une différence surprenante vue du C++ est que Rust ne possède presque pas de syntaxe dédiée pour les constructeurs. On utilise simplement des fonctions associées pour jouer ce rôle. En présence de plusieurs constructeurs, cela permet de donner des noms qui clarifient l’intention.

Pour donner un exemple le type File de la bibliothèque standard, qui permet de manipuler des fichiers, a une fonction associée File::open() pour ouvrir un fichier en lecture seule et une autre fonction associée File::create() pour ouvrir un fichier en écriture en le créant s’il n’existe pas.

Mais il existe quand même quelques fonctions constructeur spéciales en Rust, ce sont celles qui permettent de construire les tuple structs et variantes de types énumérés de type tuple :

#![allow(unused)]
fn main() {
// Ce code...
struct Tuple(u32, u16);
// ...définit implicitement une fonction constructeur Tuple, qui prend un u32
//    et un u16 en paramètre et retourne un Tuple de ces valeurs.

// Et ce code...
enum Enum {
    Tuple(usize),
}
// ...définit implicitement une fonction constructeur Enum::Tuple, qui prend un
// usize et retourne la variante Enum::Tuple avec cette valeur à l'intérieur.
}

Quand aux destructeurs de C++, leur équivalent en Rust est le trait Drop, que nous pouvons implémenter avec une syntaxe sur laquelle nous reviendrons dans le chapitre sur les traits :

struct Dechet;

impl Drop for Dechet {
    fn drop(&mut self) {
        println!("Tu OSES me jeter ?!");
    }
}

fn main() {
    let _ = Dechet;
}