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, siobjetest 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&,&mutou*pour queobjetcorresponde à 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.