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() ); }
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, siobjet
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 queobjet
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));
}
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)); }
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)); }
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.