La syntaxe des méthodes

Les méthodes sont similaires aux fonctions : nous les déclarons avec le mot-clé fn et un nom, elles peuvent avoir des paramètres et une valeur de retour, et elles contiennent du code qui est exécuté quand on la méthode est appellée depuis un autre endroit. Contrairement aux fonctions, les méthodes diffèrent des fonctions parce qu'elles sont définies dans le contexte d'une structure (ou d'une énumération ou d'un objet de trait, que nous aborderons respectivement aux chapitres 6 et 17) et que leur premier paramètre est toujours self, un mot-clé qui représente l'instance de la structure sur laquelle on appelle la méthode.

Définir des méthodes

Remplaçons la fonction aire qui prend une instance de Rectangle en paramètre par une méthode aire définie sur la structure Rectangle, comme dans l'encart 5-13.

Fichier : src/main.rs

#[derive(Debug)]
struct Rectangle {
    largeur: u32,
    hauteur: u32,
}

impl Rectangle {
    fn aire(&self) -> u32 {
        self.largeur * self.hauteur
    }
}

fn main() {
    let rect1 = Rectangle { largeur: 30, hauteur: 50 };

    println!(
        "L'aire du rectangle est de {} pixels carrés.",
        rect1.aire()
    );
}

Encart 5-13 : Définition d'une méthode aire sur la structure Rectangle

Pour définir la fonction dans le contexte de Rectangle, nous démarrons un bloc impl (implémentation) pour Rectangle. Tout ce qui sera dans ce bloc impl sera lié au type Rectangle. Puis nous déplaçons la fonction aire entre les accolades du impl et nous remplaçons le premier paramètre (et dans notre cas, le seul) par self dans la signature et dans tout le corps. Dans main, où nous avons appelé la fonction aire et passé rect1 en argument, nous pouvons utiliser à la place la syntaxe des méthodes pour appeler la méthode aire sur notre instance de Rectangle. La syntaxe des méthodes se place après l'instance : on ajoute un point suivi du nom de la méthode et des parenthèses contenant les arguments s'il y en a.

Dans la signature de aire, nous utilisons &self à la place de rectangle: &Rectangle. Le &self est un raccourci pour self: &Self. Au sein d'un bloc impl, le type de Self est un alias pour le type sur lequel porte le impl. Les méthodes doivent avoir un paramètre self du type Self comme premier paramètre afin que Rust puisse vous permettre d'abréger en renseignant uniquement self en premier paramètre. Veuillez noter qu'il nous faut quand même utiliser le & devant le raccourci self, pour indiquer que cette méthode emprunte l'instance de Self, comme nous l'avions fait pour rectangle: &Rectangle. Les méthodes peuvent prendre possession de self, emprunter self de façon immuable comme nous l'avons fait ici, ou emprunter self de façon mutable, comme pour n'importe quel autre paramètre.

Nous avons choisi &self ici pour la même raison que nous avions utilisé &Rectangle quand il s'agissait d'une fonction ; nous ne voulons pas en prendre possession, et nous voulons seulement lire les données de la structure, pas les modifier. Si nous voulions que la méthode modifie l'instance sur laquelle on l'appelle, on utiliserait &mut self comme premier paramètre. Il est rare d'avoir une méthode qui prend possession de l'instance en utilisant uniquement self comme premier argument ; cette technique est généralement utilisée lorsque la méthode transforme self en quelque chose d'autre et que vous voulez empêcher le code appelant d'utiliser l'instance d'origine après la transformation.

En complément de l'application de la syntaxe des méthodes et ainsi de ne pas être obligé de répéter le type de self dans la signature de chaque méthode, la principale raison d'utiliser les méthodes plutôt que de fonctions est pour l'organisation. Nous avons mis tout ce qu'on pouvait faire avec une instance de notre type dans un bloc impl plutôt que d'imposer aux futurs utilisateurs de notre code à rechercher les fonctionnalités de Rectangle à divers endroits de la bibliothèque que nous fournissons.

Notez que nous pourions faire en sorte qu'une méthode porte le même nom qu'un des champs de la structure. Par exemple, nous pourions définir une méthode sur Rectangle qui s'appelle elle aussi largeur :

Fichier : src/main.rs

#[derive(Debug)]
struct Rectangle {
    largeur: u32,
    hauteur: u32,
}

impl Rectangle {
    fn largeur(&self) -> bool {
        self.largeur > 0
    }
}

fn main() {
    let rect1 = Rectangle {
        largeur: 30,
        hauteur: 50,
    };

    if rect1.largeur() {
        println!("Le rectangle a une largeur non nulle ; elle vaut {}", rect1.largeur);
    }
}

Ici, nous avons défini la méthode largeur pour qu'elle retourne true si la valeur dans le champ largeur est supérieur ou égal à 0, et false si la valeur est 0 : nous pouvons utiliser un champ à l'intérieur d'une méthode du même nom, pour n'importe quel usage. Dans le main, lorsque nous ajoutons des parenthèses après rect1.largeur, Rust comprend que nous parlons de la méthode largeur. Lorsque nous n'utilisons pas les parenthèses, Rust sait nous parlons du champ largeur.

Souvent, mais pas toujours, lorsque nous appellons une méthode avec le même nom qu'un champ, nous voulons qu'elle renvoie uniquement la valeur de ce champ et ne fasse rien d'autre. Ces méthodes sont appelées des accesseurs, et Rust ne les implémente pas automatiquement pour les champs des structures comme le font certains langages. Les accesseurs sont utiles pour rendre le champ privé mais rendre la méthode publique et ainsi donner un accès en lecture seule à ce champ dans l'API publique de ce type. Nous développerons les notions de publique et privé et comment définir un champ ou une méthode publique ou privée au chapitre 7.

Où est l'opérateur -> ?

En C et en C++, deux opérateurs différents sont utilisés pour appeler les méthodes : on utilise . si on appelle une méthode directement sur l'objet et -> si on appelle la méthode sur un pointeur vers l'objet et qu'il faut d'abord déréférencer le pointeur. En d'autres termes, si objet est un pointeur, objet->methode() est similaire à (*objet).methode().

Rust n'a pas d'équivalent à l'opérateur -> ; à la place, Rust a une fonctionnalité appelée référencement et déréférencement automatiques. L'appel de méthodes est l'un des rares endroits de Rust où on retrouve ce comportement.

Voilà comment cela fonctionne : quand on appelle une méthode avec objet.methode(), Rust ajoute automatiquement le &, &mut ou * pour que objet corresponde à la signature de la méthode. Autrement dit, ces deux lignes sont identiques :


#![allow(unused)]
fn main() {
#[derive(Debug,Copy,Clone)]
struct Point {
    x: f64,
    y: f64,
}

impl Point {
   fn distance(&self, autre: &Point) -> f64 {
       let x_carre = f64::powi(autre.x - self.x, 2);
       let y_carre = f64::powi(autre.y - self.y, 2);

       f64::sqrt(x_carre + y_carre)
   }
}
let p1 = Point { x: 0.0, y: 0.0 };
let p2 = Point { x: 5.0, y: 6.5 };
p1.distance(&p2);
(&p1).distance(&p2);
}

La première ligne semble bien plus propre. Ce comportement du (dé)référencement automatique fonctionne parce que les méthodes ont une cible claire : le type de self. Compte tenu du nom de la méthode et de l'instance sur laquelle elle s'applique, Rust peut déterminer de manière irréfutable si la méthode lit (&self), modifie (&mut self) ou consomme (self) l'instance. Le fait que Rust rend implicite l'emprunt pour les instances sur lesquelles on appelle les méthodes améliore significativement l'ergonomie de la possession.

Les méthodes avec davantage de paramètres

Entraînons-nous à utiliser des méthodes en implémentant une seconde méthode sur la structure Rectangle. Cette fois-ci, nous voulons qu'une instance de Rectangle prenne une autre instance de Rectangle et qu'on retourne true si le second Rectangle peut se dessiner intégralement à l'intérieur de self (le premier Rectangle) ; sinon, on renverra false. En d'autres termes, une fois qu'on aura défini la méthode peut_contenir, on veut pouvoir écrire le programme de l'encart 5-14.

Fichier : src/main.rs

fn main() {
    let rect1 = Rectangle {
        largeur: 30,
        hauteur: 50
    };
    let rect2 = Rectangle {
        largeur: 10,
        hauteur: 40
    };
    let rect3 = Rectangle {
        largeur: 60,
        hauteur: 45
    };

    println!("rect1 peut-il contenir rect2 ? {}", rect1.peut_contenir(&rect2));
    println!("rect1 peut-il contenir rect3 ? {}", rect1.peut_contenir(&rect3));
}

Encart 5-14 : Utilisation de la méthode peut_contenir qui reste à écrire

Et on s'attend à ce que le texte suivant s'affiche, puisque les deux dimensions de rect2 sont plus petites que les dimensions de rect1, mais rect3 est plus large que rect1 :

rect1 peut-il contenir rect2 ? true
rect1 peut-il contenir rect3 ? false

Nous voulons définir une méthode, donc elle doit se trouver dans le bloc impl Rectangle. Le nom de la méthode sera peut_contenir et elle prendra une référence immuable vers un autre Rectangle en paramètre. On peut déterminer le type du paramètre en regardant le code qui appelle la méthode : rect1.peut_contenir(&rect2) prend en argument &rect2, une référence immuable vers rect2, une instance de Rectangle. Cela est logique puisque nous voulons uniquement lire rect2 (plutôt que de la modifier, ce qui aurait nécessité une référence mutable) et nous souhaitons que main garde possession de rect2 pour qu'on puisse le réutiliser après avoir appelé la méthode peut_contenir. La valeur de retour de peut_contenir sera un booléen et l'implémentation de la méthode vérifiera si la largeur et la hauteur de self sont respectivement plus grandes que la largeur et la hauteur de l'autre Rectangle. Ajoutons la nouvelle méthode peut_contenir dans le bloc impl de l'encart 5-13, comme le montre l'encart 5-15.

Fichier : src/main.rs

#[derive(Debug)]
struct Rectangle {
    largeur: u32,
    hauteur: u32,
}

impl Rectangle {
    fn aire(&self) -> u32 {
        self.largeur * self.hauteur
    }

    fn peut_contenir(&self, autre: &Rectangle) -> bool {
        self.largeur > autre.largeur && self.hauteur > autre.hauteur
    }
}

fn main() {
    let rect1 = Rectangle {
        largeur: 30,
        hauteur: 50
    };
    let rect2 = Rectangle {
        largeur: 10,
        hauteur: 40
    };
    let rect3 = Rectangle {
        largeur: 60,
        hauteur: 45
    };

    println!("rect1 peut-il contenir rect2 ? {}", rect1.peut_contenir(&rect2));
    println!("rect1 peut-il contenir rect3 ? {}", rect1.peut_contenir(&rect3));
}

Encart 5-15 : Implémentation de la méthode peut_contenir sur Rectangle qui prend une autre instance de Rectangle en paramètre

Lorsque nous exécutons ce code avec la fonction main de l'encart 5-14, nous obtenons l'affichage attendu. Les méthodes peuvent prendre plusieurs paramètres qu'on peut ajouter à la signature après le paramètre self, et ces paramètres fonctionnent de la même manière que les paramètres des fonctions.

Les fonctions associées

Toutes les fonctions définies dans un bloc impl s'appellent des fonctions associées car elles sont associées au type renseigné après le impl. Nous pouvons aussi y définir des fonctions associées qui n'ont pas de self en premier paramètre (et donc ce ne sont pas des méthodes) car elles n'ont pas besoin d'une instance du type sur lequel elles travaillent. Nous avons déjà utilisé une fonction comme celle-ci : la fonction String::from qui est définie sur le type String.

Les fonctions associées qui ne ne sont pas des méthodes sont souvent utilisées comme constructeurs qui vont retourner une nouvelle instance de la structure. Par exemple, on pourrait écrire une fonction associée qui prend une unique dimension en paramètre et l'utilise à la fois pour la largeur et pour la hauteur, ce qui rend plus aisé la création d'un Rectangle carré plutôt que d'avoir à indiquer la même valeur deux fois :

Fichier : src/main.rs

#[derive(Debug)]
struct Rectangle {
    largeur: u32,
    hauteur: u32,
}

impl Rectangle {
    fn carre(cote: u32) -> Rectangle {
        Rectangle {
            largeur: cote,
            hauteur: cote
        }
    }
}

fn main() {
    let mon_carre = Rectangle::carre(3);
}

Pour appeler cette fonction associée, on utilise la syntaxe :: avec le nom de la structure ; let mon_carre = Rectangle::carre(3); en est un exemple. Cette fonction est cloisonnée dans l'espace de noms de la structure : la syntaxe :: s'utilise aussi bien pour les fonctions associées que pour les espaces de noms créés par des modules. Nous aborderons les modules au chapitre 7.

Plusieurs blocs impl

Chaque structure peut avoir plusieurs blocs impl. Par exemple, l'encart 5-15 est équivalent au code de l'encart 5-16, où chaque méthode est dans son propre bloc impl.

#[derive(Debug)]
struct Rectangle {
    largeur: u32,
    hauteur: u32,
}

impl Rectangle {
    fn aire(&self) -> u32 {
        self.largeur * self.hauteur
    }
}

impl Rectangle {
    fn peut_contenir(&self, autre: &Rectangle) -> bool {
        self.largeur > autre.largeur && self.hauteur > autre.hauteur
    }
}

fn main() {
    let rect1 = Rectangle {
        largeur: 30,
        hauteur: 50
    };
    let rect2 = Rectangle {
        largeur: 10,
        hauteur: 40
    };
    let rect3 = Rectangle {
        largeur: 60,
        hauteur: 45
    };

    println!("rect1 peut-il contenir rect2 ? {}", rect1.peut_contenir(&rect2));
    println!("rect1 peut-il contenir rect3 ? {}", rect1.peut_contenir(&rect3));
}

Encart 5-16 : Réécriture de l'encart 5-15 en utilisant plusieurs blocs impl

Il n'y a aucune raison de séparer ces méthodes dans plusieurs blocs impl dans notre exemple, mais c'est une syntaxe valide. Nous verrons un exemple de l'utilité d'avoir plusieurs blocs impl au chapitre 10, où nous aborderons les types génériques et les traits.

Résumé

Les structures vous permettent de créer des types personnalisés significatifs pour votre domaine. En utilisant des structures, on peut relier entre elles des données associées et nommer chaque donnée pour rendre le code plus clair. Dans des blocs impl, vous pouvez définir des fonctions qui sont associées à votre type, et les méthodes sont un genre de fonction associée qui vous permet de renseigner le comportement que doivent suivre les instances de votre structure.

Mais les structures ne sont pas le seul moyen de créer des types personnalisés : nous allons maintenant voir les énumérations de Rust, une fonctionnalité que vous pourrez bientôt ajouter à votre boîte à outils.