Les fermetures : fonctions anonymes qui peuvent utiliser leur environnement
Les fermetures en Rust sont des fonctions anonymes qui peuvent être sauvegardées dans une variable ou qui peuvent être passées en argument à d'autres fonctions. Il est possible de créer une fermeture à un endroit du code et ensuite de l'appeler dans un contexte différent pour l'exécuter. Contrairement aux fonctions, les fermetures ont la possibilité de capturer les valeurs présentes dans le contexte où elles sont appelées. Nous allons montrer comment les fonctionnalités des fermetures permettent de réutiliser du code et suivre des comportements personnalisés.
Créer une abstraction de comportement avec une fermeture
Travaillons sur un exemple d'une situation où il est utile de stocker une fermeture qui s'exécutera ultérieurement. Au cours de ce chapitre, nous allons parler de la syntaxe des fermetures, de l'inférence de type et des traits.
Imaginons la situation suivante : nous travaillons dans une startup qui crée une application destinée à générer des programmes d'entraînements physiques personnalisés. L'application dorsale est écrite en Rust et repose sur un algorithme qui génère les exercices en fonction de beaucoup de facteurs tels que l'âge de l'utilisateur, son indice de masse corporelle, ses préférences et une intensité qu'il aura paramétré. L'algorithme réellement utilisé n'est pas important pour cet exemple : ce qui est important c'est que le calcul prenne plusieurs secondes. Nous voulons appeler l'algorithme uniquement lorsque nous en avons besoin, et seulement une fois, afin que l'utilisateur n'ait pas à attendre plus longtemps que nécessaire.
Pour simuler l'appel à cet algorithme hypothétique, nous allons utiliser la
fonction simuler_gros_calcul
présent dans l'encart 13-1, qui affichera
calcul très lent ...
et attendra deux secondes avant de retourner le nombre
qui lui a été donné :
Fichier : src/main.rs
use std::thread; use std::time::Duration; fn simuler_gros_calcul(intensite: u32) -> u32 { println!("calcul très lent ..."); thread::sleep(Duration::from_secs(2)); intensite } fn main() {}
Ensuite, nous avons la fonction main
qui contient les parties de
l'application d'entraînement qui sont importantes pour cet exemple. Cette
fonction représente le code que l'application appellera lorsqu'un utilisateur
demande un programme d'entraînement. Comme l'interaction avec l'interface
frontale de l'application n'apporte rien dans l'étude de l'utilisation des
fermetures qui nous occupe ici, nous allons nous contenter de coder en dur les
valeurs représentant les entrées de notre programme puis afficher les résultats
obtenus.
Les paramètres d'entrées nécessaires sont :
intensite
qui est un nombre saisi par utilisateur lorsqu'il demande un entraînement afin d'indiquer s'il veut un entraînement de faible ou de haute intensité.- Un nombre aléatoire faisant varier les programmes d'entraînement
Le résultat sera le programme d'entraînement recommandé. L'encart 13-2 montre la
fonction main
que nous allons utiliser.
Fichier : src/main.rs
use std::thread; use std::time::Duration; fn simuler_gros_calcul(intensite: u32) -> u32 { println!("calcul très lent ..."); thread::sleep(Duration::from_secs(2)); intensite } fn generer_exercices(intensite: u32, nombre_aleatoire: u32) {} fn main() { let valeur_utilisateur_simule = 10; let nombre_aleatoire_simule = 7; generer_exercices(valeur_utilisateur_simule, nombre_aleatoire_simule); }
Nous avons codé en dur la variable valeur_utilisateur_simule
à 10 et la
variable nombre_aleatoire_simule
à 7 pour des raisons de simplicité ; dans un
vrai programme nous obtiendrions la valeur d'intensité à partir de l'interface
frontale et nous utiliserions la crate rand
pour générer un nombre aléatoire,
comme nous l'avons fait dans l'exemple du jeu du plus ou du moins dans le
chapitre 2. La fonction main
appelle une fonction generer_exercices
avec ces
valeurs d'entrée simulées.
Maintenant que nous avons le contexte, passons à l'algorithme. La fonction
generer_exercices
dans l'encart 13-3 contient la logique métier de
l'application qui nous préoccupe le plus dans cet exemple. Le reste des
changements de code dans cet exemple seront appliqués à cette fonction :
Fichier : src/main.rs
use std::thread; use std::time::Duration; fn simuler_gros_calcul(intensite: u32) -> u32 { println!("calcul très lent ..."); thread::sleep(Duration::from_secs(2)); intensite } fn generer_exercices(intensite: u32, nombre_aleatoire: u32) { if intensite < 25 { println!( "Aujourd'hui, faire {} pompes !", simuler_gros_calcul(intensite) ); println!( "Ensuite, faire {} abdominaux !", simuler_gros_calcul(intensite) ); } else { if nombre_aleatoire == 3 { println!("Faites une pause aujourd'hui ! Rappelez-vous de bien vous hydrater !"); } else { println!( "Aujourd'hui, courrez pendant {} minutes !", simuler_gros_calcul(intensite) ); } } } fn main() { let valeur_utilisateur_simule = 10; let nombre_aleatoire_simule = 7; generer_exercices(valeur_utilisateur_simule, nombre_aleatoire_simule); }
Le code de l'encart 13-3 a plusieurs appels à la fonction de calcul lent : le
premier bloc if
appelle simuler_gros_calcul
deux fois, le if
à l'intérieur
du else
ne l'appelle pas du tout, et le code à l'intérieur du second else
l'appelle une seule fois.
Le comportement souhaité de la fonction generer_exercices
est de vérifier
d'abord si l'utilisateur veut un entraînement de faible intensité (indiqué par
un nombre inférieur à 25) ou un entraînement de haute intensité (un nombre de 25
ou plus).
Les plans d'entraînement à faible intensité recommanderont un certain nombre de pompes et d'abdominaux basés sur l'algorithme complexe que nous simulons.
Si l'utilisateur souhaite un entraînement de haute intensité, il y a une logique en plus : si la valeur du nombre aléatoire généré par l'application est 3, l'application recommandera une pause et une hydratation à la place. Sinon, l'utilisateur recevra un nombre de minutes de course à pied calculé par l'algorithme complexe.
Ce code fonctionne comme la logique métier le souhaite, mais imaginons que
l'équipe de science des données nous informe qu'il va y avoir des changements
dans la façon dont nous devrons appeler l'algorithme à l'avenir. Pour simplifier
la mise à jour lorsque ces changements se produiront, nous voulons remanier ce
code de sorte qu'il n'appelle la fonction simuler_gros_calcul
qu'une seule fois.
Nous voulons également nous débarrasser de l'endroit où nous appelons la
fonction deux fois inutilement, sans ajouter d'autres appels à cette fonction au
cours de ce processus. Autrement dit, nous ne voulons pas l'appeler si le
résultat n'en a pas besoin, et nous voulons ne l'appeler qu'une seule fois.
Remaniement en utilisant des fonctions
Nous pourrions restructurer le programme d'entraînement de plusieurs manières.
Tout d'abord, nous allons essayer d'extraire l'appel en double à la fonction
simuler_gros_calcul
dans une variable, comme dans l'encart 13-4 :
Fichier : src/main.rs
use std::thread; use std::time::Duration; fn simuler_gros_calcul(intensite: u32) -> u32 { println!("calcul très lent ..."); thread::sleep(Duration::from_secs(2)); intensite } fn generer_exercices(intensite: u32, nombre_aleatoire: u32) { let resultat_lent = simuler_gros_calcul(intensite); if intensite < 25 { println!("Aujourd'hui, faire {} pompes !", resultat_lent); println!("Ensuite, faire {} abdominaux !", resultat_lent); } else { if nombre_aleatoire == 3 { println!("Faites une pause aujourd'hui ! Rappelez-vous de bien vous hydrater !"); } else { println!("Aujourd'hui, courrez pendant {} minutes !", resultat_lent); } } } fn main() { let valeur_utilisateur_simule = 10; let nombre_aleatoire_simule = 7; generer_exercices(valeur_utilisateur_simule, nombre_aleatoire_simule); }
Ce changement unifie tous les appels à simuler_gros_calcul
et résout le
problème du premier bloc if
qui appelle inutilement la fonction à deux
reprises. Malheureusement, nous appelons maintenant cette fonction et attendons
le résultat dans tous les cas, ce qui inclut le bloc if
interne qui n'utilise
pas du tout la valeur du résultat.
Nous voulons nous référer à simuler_gros_calcul
qu'une seule fois dans
generer_exercices
, mais retarder le gros calcul jusqu'au moment où nous avons
avons réellement besoin du résultat. C'est un cas d'utilisation des fermetures !
Remanier le code avec des fermetures pour stocker du code
Au lieu d'appeler systématiquement la fonction simuler_gros_calcul
avant
les blocs if
, nous pouvons définir une fermeture et la stocker dans une
variable au lieu de le faire pour le résultat, comme le montre l'encart 13-5.
Nous pouvons en fait déplacer l'ensemble du corps de simuler_gros_calcul
dans
la fermeture que nous introduisons ici.
Fichier : src/main.rs
use std::thread; use std::time::Duration; fn generer_exercices(intensite: u32, nombre_aleatoire: u32) { let fermeture_lente = |nombre| { println!("calcul très lent ..."); thread::sleep(Duration::from_secs(2)); nombre }; if intensite < 25 { println!("Aujourd'hui, faire {} pompes !", fermeture_lente(intensite)); println!("Ensuite, faire {} abdominaux !", fermeture_lente(intensite)); } else { if nombre_aleatoire == 3 { println!("Faites une pause aujourd'hui ! Rappelez-vous de bien vous hydrater !"); } else { println!( "Aujourd'hui, courrez pendant {} minutes !", fermeture_lente(intensite) ); } } } fn main() { let valeur_utilisateur_simule = 10; let nombre_aleatoire_simule = 7; generer_exercices(valeur_utilisateur_simule, nombre_aleatoire_simule); }
La définition de la fermeture vient après le =
pour l'assigner à la variable
fermeture_lente
. Pour définir une fermeture, on commence par une paire de
barres verticales (|
), à l'intérieur desquelles on renseigne les paramètres de
la fermeture ; cette syntaxe a été choisie en raison de sa similitude avec les
définitions des fermetures en Smalltalk et en Ruby. Cette fermeture a un
paramètre nombre
: si nous avions plus d'un paramètre, nous les séparerions
par des virgules, comme ceci : |param1, param2|
.
Après les paramètres, on ajoute des accolades qui contiennent le corps de la
fermeture, celles-ci sont facultatives si le corps de la fermeture est une seule
expression. Après les accolades, nous avons besoin d'un point-virgule
pour terminer l'instruction let
. La valeur à la dernière ligne dans le corps
de la fermeture (nombre
) sera la valeur retournée par la fermeture lorsqu'elle
sera exécutée, et cette ligne ne se termine pas par un point-virgule, exactement
comme dans le corps des fonctions.
Notez que cette instruction let
signifie que la variable fermeture_lente
contient la définition d'une fonction anonyme, pas la valeur résultante à
l'appel de cette fonction anonyme. Rappelons que nous utilisons une fermeture
pour définir le code à appeler dans un seul endroit, stocker ce code et
l'appeler plus tard ; le code que nous voulons appeler est maintenant stocké
dans fermeture_lente
.
Maintenant que nous avons défini la fermeture, nous pouvons changer le code dans
les blocs if
pour appeler la fermeture afin d'exécuter le code et obtenir la
valeur résultante. L'appel d'une fermeture fonctionne comme pour l'appel d'une
fonction : nous renseignons le nom de la variable qui stocke la définition de la
fermeture et la complétons avec des parenthèses contenant les valeurs du ou des
arguments que nous voulons utiliser pour cet appel, comme dans l'encart 13-6.
Fichier : src/main.rs
use std::thread; use std::time::Duration; fn generer_exercices(intensite: u32, nombre_aleatoire: u32) { let fermeture_lente = |nombre| { println!("calcul très lent ..."); thread::sleep(Duration::from_secs(2)); nombre }; if intensite < 25 { println!("Aujourd'hui, faire {} pompes !", fermeture_lente(intensite)); println!("Ensuite, faire {} abdominaux !", fermeture_lente(intensite)); } else { if nombre_aleatoire == 3 { println!("Faites une pause aujourd'hui ! Rappelez-vous de bien vous hydrater !"); } else { println!( "Aujourd'hui, courrez pendant {} minutes !", fermeture_lente(intensite) ); } } } fn main() { let valeur_utilisateur_simule = 10; let nombre_aleatoire_simule = 7; generer_exercices(valeur_utilisateur_simule, nombre_aleatoire_simule); }
Désormais, le calcul lent n'est défini qu'à un seul endroit et nous n'exécutons ce code qu'aux endroits où nous avons besoin des résultats.
Cependant, nous avons réintroduit l'un des problèmes de l'encart 13-3 : nous
continuons d'appeler la fermeture deux fois dans le premier bloc if
, qui
appellera le code lent à deux reprises et fera attendre l'utilisateur deux fois
plus longtemps que nécessaire. Nous pourrions résoudre ce problème en créant une
variable locale à ce bloc if
pour conserver le résultat de l'appel à la
fermeture, mais les fermetures nous ouvrent d'autres solutions. Commençons
d'abord par expliquer pourquoi il n'y a pas d'annotation de type dans la
définition des fermetures et des traits liés aux fermetures.
L'inférence de type et l'annotation des fermetures
Les fermetures ne nécessitent pas d'annoter le type des paramètres ou de la
valeur de retour comme le font les fonctions fn
. Les annotations de type sont
nécessaires pour les fonctions car elles font partie d'une interface explicite
exposée à leurs utilisateurs. Définir cette interface de manière rigide est
nécessaire pour s'assurer que tout le monde s'accorde sur les types de valeurs
qu'une fonction utilise et retourne. Mais les fermetures ne sont pas utilisées
dans une interface exposée de cette façon : elles sont stockées dans des
variables et utilisées sans les nommer ni les exposer aux utilisateurs de notre
bibliothèque.
En outre, les fermetures sont généralement brèves et ne sont pertinentes que dans un contexte précis plutôt que pour des cas génériques. Dans ce contexte précis, le compilateur est capable de déduire le type des paramètres et le type de retour, tout comme il est capable d'inférer le type de la plupart des variables.
Demander aux développeurs d'annoter le type dans ces petites fonctions anonymes serait pénible et largement redondant avec l'information dont dispose déjà le compilateur.
Comme pour les variables, nous pouvons ajouter des annotations de type si nous voulons rendre explicite et clarifier le code au risque d'être plus verbeux que ce qui est strictement nécessaire. Annoter les types de la fermeture que nous avons définie dans l'encart 13-5 ressemblerait à l'encart 13-7.
Fichier : src/main.rs
use std::thread; use std::time::Duration; fn generer_exercices(intensite: u32, nombre_aleatoire: u32) { let fermeture_lente = |nombre: u32| -> u32 { println!("calcul très lent ..."); thread::sleep(Duration::from_secs(2)); nombre }; if intensite < 25 { println!("Aujourd'hui, faire {} pompes !", fermeture_lente(intensite)); println!("Ensuite, faire {} abdominaux !", fermeture_lente(intensite)); } else { if nombre_aleatoire == 3 { println!("Faites une pause aujourd'hui ! Rappelez-vous de bien vous hydrater !"); } else { println!( "Aujourd'hui, courrez pendant {} minutes !", fermeture_lente(intensite) ); } } } fn main() { let valeur_utilisateur_simule = 10; let nombre_aleatoire_simule = 7; generer_exercices(valeur_utilisateur_simule, nombre_aleatoire_simule); }
La syntaxe des fermetures et des fonctions semble plus similaire avec les annotations de type. Ce qui suit est une comparaison verticale entre la syntaxe d'une définition d'une fonction qui ajoute 1 à son paramètre, et d'une fermeture qui a le même comportement. Nous avons ajouté des espaces pour aligner les parties pertinentes. Ceci met en évidence la similarité entre la syntaxe des fermetures et celle des fonctions, hormis l'utilisation des barres verticales et certaines syntaxes facultatives :
fn ajouter_un_v1 (x: u32) -> u32 { x + 1 }
let ajouter_un_v2 = |x: u32| -> u32 { x + 1 };
let ajouter_un_v3 = |x| { x + 1 };
let ajouter_un_v4 = |x| x + 1 ;
La première ligne affiche la définition d'une fonction et la deuxième ligne une
définition d'une fermeture entièrement annotée. La troisième ligne supprime les
annotations de type de la définition de la fermeture, et la quatrième ligne
supprime les accolades qui sont facultatives, parce que le corps d'une fermeture
n'a qu'une seule expression. Ce sont toutes des définitions valides qui
suivront le même comportement lorsqu'on les appellera. L'appel aux fermetures
est nécessaire pour que ajouter_un_v3
et ajouter_un_v4
puissent être compilés
car les types seront déduits en fonction de leur utilisation.
Les définitions des fermetures auront un type concret déduit pour chacun de
leurs paramètres et pour leur valeur de retour. Par exemple, l'encart 13-8
montre la définition d'une petite fermeture qui renvoie simplement la valeur
qu'elle reçoit comme paramètre. Cette fermeture n'est pas très utile sauf pour
les besoins de cet exemple. Notez que nous n'avons pas ajouté d'annotation de
type à la définition : si nous essayons alors d'appeler la fermeture deux fois,
en utilisant une String
comme argument la première fois et un u32
la
deuxième fois, nous obtiendrons une erreur :
Fichier : src/main.rs
fn main() {
let fermeture_exemple = |x| x;
let s = fermeture_exemple(String::from("hello"));
let n = fermeture_exemple(5);
}
Le compilateur nous renvoie l'erreur suivante :
$ cargo run
Compiling closure-example v0.1.0 (file:///projects/closure-example)
error[E0308]: mismatched types
--> src/main.rs:5:29
|
5 | let n = fermeture_exemple(5);
| ^- help: try using a conversion method: `.to_string()`
| |
| expected struct `String`, found integer
For more information about this error, try `rustc --explain E0308`.
error: could not compile `closure-example` due to previous error
La première fois que nous appelons fermeture_exemple
avec une String
, le
compilateur déduit que le type de x
et le type de retour de la fermeture sont
de type String
. Ces types sont ensuite verrouillés dans fermeture_exemple
,
et nous obtenons une erreur de type si nous essayons d'utiliser un type
différent avec la même fermeture.
Stockage des fermetures avec des paramètres génériques et le trait Fn
Revenons à notre application de génération d'entraînements. Dans l'encart 13-6, notre code appelait toujours la fermeture lente plus de fois que nécessaire. Une option pour résoudre ce problème est de sauvegarder le résultat de la fermeture lente dans une variable pour une future utilisation et d'utiliser la variable à chaque endroit où nous en avons besoin au lieu de rappeler la fermeture à nouveau. Cependant, cette méthode pourrait donner lieu à du code très répété.
Heureusement, une autre solution s'offre à nous. Nous pouvons créer une structure qui stockera la fermeture et la valeur qui en résulte. La structure n'exécutera la fermeture que si nous avons besoin de la valeur résultante, et elle mettra en cache la valeur résultante pour que le reste de notre code n'ait pas la responsabilité de sauvegarder et de réutiliser le résultat. Vous connaissez peut-être cette technique sous le nom de mémoïsation ou d'évaluation paresseuse.
Pour faire en sorte qu'une structure détienne une fermeture, il faut préciser le type de fermeture, car une définition de structure a besoin de connaître les types de chacun de ses champs. Chaque instance de fermeture a son propre type anonyme unique : cela signifie que même si deux fermetures ont la même signature, leurs types sont toujours considérés comme différents. Pour définir des structures, des énumérations ou des paramètres de fonction qui utilisent des fermetures, nous utilisons des génériques et des traits liés, comme nous l'avons vu au chapitre 10.
Les traits Fn
sont fournis par la bibliothèque standard. Toutes les fermetures
implémentent au moins un des traits suivants : Fn
, FnMut
ou FnOnce
. Nous
verrons la différence entre ces traits dans la section
“Capturer l'environnement avec les
fermetures” ; dans
cet exemple, nous pouvons utiliser le trait Fn
.
Nous ajoutons des types au trait lié Fn
pour représenter les types de
paramètres et les valeurs de retour que les fermetures doivent avoir pour
correspondre à ce trait lié. Dans ce cas, notre fermeture a un paramètre de type
u32
et renvoie un u32
, le trait lié que nous précisons est donc
Fn (u32) -> u32
.
L'encart 13-9 montre la définition de la structure Cache
qui possède une
fermeture et une valeur de résultat optionnelle :
Fichier : src/main.rs
struct Cache<T> where T: Fn(u32) -> u32, { calcul: T, valeur: Option<u32>, } fn main() {}
La structure Cache
a un champ calcul
du type générique T
. Le trait lié T
précise que c'est une fermeture en utilisant le trait Fn
. Toute fermeture que
l'on veut stocker dans le champ calcul
doit avoir un paramètre u32
(ce qui
est précisé entre parenthèse après le Fn
) et doit retourner un u32
(ce qui
est précisé après le ->
).
Remarque : les fonctions peuvent aussi implémenter chacun de ces trois traits
Fn
. Si ce que nous voulons faire ne nécessite pas de capturer une valeur de l'environnement, nous pouvons utiliser une fonction plutôt qu'une fermeture lorsque nous avons besoin de quelque chose qui implémente un traitFn
.
Le champ valeur
est de type Option<u32>
. Avant d'exécuter la fermeture,
valeur
sera initialisée à None
. Lorsque du code utilisant un Cache
demande
le résultat de la fermeture, le Cache
exécutera la fermeture à ce moment-là
et stockera le résultat dans une variante Some
dans le champ valeur
.
Ensuite, si le code demande à nouveau le résultat de la fermeture, le Cache
renverra le résultat contenu dans la variante Some
au lieu d'exécuter à
nouveau la fermeture.
La logique autour du champ valeur
que nous venons de décrire est définie dans
l'encart 13-10 :
Fichier : src/main.rs
struct Cache<T> where T: Fn(u32) -> u32, { calcul: T, valeur: Option<u32>, } impl<T> Cache<T> where T: Fn(u32) -> u32 { fn new(calcul: T) -> Cache<T> { Cache { calcul, valeur: None, } } fn valeur(&mut self, arg: u32) -> u32 { match self.valeur { Some(v) => v, None => { let v = (self.calcul)(arg); self.valeur = Some(v); v }, } } } fn main() {}
Nous voulons que Cache
gère les valeurs des champs de structure plutôt que de
laisser la possibilité au code appelant la possibilité de modifier directement
les valeurs dans ces champs, donc nous faisons en sorte que ces champs soient
privés.
La fonction Cache::new
prend un paramètre générique T
, que nous avons
défini comme ayant le même trait lié que la structure Cache
. Puis Cache::new
renvoie une instance Cache
qui contient la fermeture présente dans le champ
calcul
et une valeur None
dans le champ valeur
, car nous n'avons pas
encore exécuté la fermeture.
Lorsque le code appelant veut le résultat de l'exécution de la fermeture, au
lieu d'appeler directement la fermeture, il appellera la méthode valeur
. Cette
méthode vérifie si nous avons déjà une valeur dans un Some
dans
self.valeur
; et si c'est le cas, elle renvoie la valeur contenue dans le
Some
sans exécuter de nouveau la fermeture.
Si self.valeur
est None
, nous appelons la fermeture stockée dans
self.calcul
, et nous sauvegardons le résultat dans self.valeur
pour une
utilisation future, puis nous retournons la valeur.
L'encart 13-11 montre comment utiliser cette structure Cache
dans la fonction
generer_exercices
de l'encart 13-6 :
Fichier : src/main.rs
use std::thread; use std::time::Duration; struct Cache<T> where T: Fn(u32) -> u32, { calcul: T, valeur: Option<u32>, } impl<T> Cache<T> where T: Fn(u32) -> u32 { fn new(calcul: T) -> Cache<T> { Cache { calcul, valeur: None, } } fn valeur(&mut self, arg: u32) -> u32 { match self.valeur { Some(v) => v, None => { let v = (self.calcul)(arg); self.valeur = Some(v); v }, } } } fn generer_exercices(intensite: u32, nombre_aleatoire: u32) { let mut resultat_lent = Cache::new(|nombre| { println!("calcul très lent ..."); thread::sleep(Duration::from_secs(2)); nombre }); if intensite < 25 { println!("Aujourd'hui, faire {} pompes !", resultat_lent.valeur(intensite)); println!("Ensuite, faire {} abdominaux !", resultat_lent.valeur(intensite)); } else { if nombre_aleatoire == 3 { println!("Faites une pause aujourd'hui ! Rappelez-vous de bien vous hydrater !"); } else { println!( "Aujourd'hui, courrez pendant {} minutes !", resultat_lent.valeur(intensite) ); } } } fn main() { let valeur_utilisateur_simule = 10; let nombre_aleatoire_simule = 7; generer_exercices(valeur_utilisateur_simule, nombre_aleatoire_simule); }
Au lieu de sauvegarder la fermeture dans une variable directement, nous
sauvegardons une nouvelle instance de Cache
qui contient la fermeture.
Ensuite, à chaque fois que nous voulons le résultat, nous appelons la méthode
valeur
sur cette instance de Cache
. Nous pouvons appeler la méthode valeur
autant de fois que nous le souhaitons, ou ne pas l'appeler du tout, et le calcul
lent sera exécuté une fois au maximum.
Essayez d'exécuter ce programme avec la fonction main
de l'encart 13-2.
Modifiez les valeurs des variables valeur_utilisateur_simule
et
nombre_aleatoire_simule
pour vérifier que dans tous les cas des différents
blocs if
et else
, calcul très lent ...
n'apparaît qu'une seule fois et
seulement si nécessaire. Le Cache
se charge de la logique nécessaire pour
s'assurer que nous n'appelons pas le calcul lent plus que nous n'en avons
besoin afin que generer_exercices
puisse se concentrer sur la logique métier.
Limitations de l'implémentation de Cache
La mise en cache des valeurs est un comportement généralement utile que nous
pourrions vouloir utiliser dans d'autres parties de notre code avec différentes
fermetures. Cependant, il y a deux problèmes avec l'implémentation actuelle de
Cache
qui rendraient difficile sa réutilisation dans des contextes différents.
Le premier problème est qu'une instance de Cache
suppose qu'elle obtienne
toujours la même valeur, indépendamment du paramètre arg
de la méthode
valeur
. Autrement dit, ce test sur Cache
échouera :
struct Cache<T>
where
T: Fn(u32) -> u32,
{
calcul: T,
valeur: Option<u32>,
}
impl<T> Cache<T>
where
T: Fn(u32) -> u32
{
fn new(calcul: T) -> Cache<T> {
Cache {
calcul,
valeur: None,
}
}
fn valeur(&mut self, arg: u32) -> u32 {
match self.valeur {
Some(v) => v,
None => {
let v = (self.calcul)(arg);
self.valeur = Some(v);
v
},
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn appel_avec_differentes_valeurs() {
let mut c = Cache::new(|a| a);
let v1 = c.valeur(1);
let v2 = c.valeur(2);
assert_eq!(v2, 2);
}
}
Ce test crée une nouvelle instance de Cache
avec une fermeture qui retourne
la valeur qui lui est passée. Nous appelons la méthode valeur
sur cette
instance de Cache
avec une valeur arg
de 1 et ensuite une valeur arg
de 2, et nous nous attendons à ce que l'appel à valeur
avec la valeur arg
de 2 retourne 2.
Exécutez ce test avec l'implémentation de Cache
de l'encart 13-9 et de
l'encart 13-10, et le test échouera sur le assert_eq!
avec ce message :
$ cargo test
Compiling cacher v0.1.0 (file:///projects/cacher)
Finished test [unoptimized + debuginfo] target(s) in 0.72s
Running unittests (target/debug/deps/cacher-074d7c200c000afa)
running 1 test
test tests::appel_avec_differentes_valeurs ... FAILED
failures:
---- tests::appel_avec_differentes_valeurs stdout ----
thread 'main' panicked at 'assertion failed: `(left == right)`
left: `1`,
right: `2`', src/lib.rs:43:9
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
failures:
tests::appel_avec_differentes_valeurs
test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
error: test failed, to rerun pass '--lib'
Le problème est que la première fois que nous avons appelé c.valeur
avec 1,
l'instance Cache
a sauvegardé Some(1)
dans self.valeur
. Par la suite, peu
importe ce que nous passons à la méthode valeur
, elle retournera toujours 1.
Essayez de modifier Cache
pour tenir une table de hachage plutôt qu'une seule
valeur. Les clés de la table de hachage seront les valeurs arg
qui lui sont
passées, et les valeurs de la table de hachage seront le résultat de l'appel à
la fermeture avec cette clé. Plutôt que de regarder directement si self.valeur
a une valeur Some
ou une valeur None
, la fonction valeur
recherchera arg
dans la table de hachage et retournera la valeur si elle est présente. S'il
n'est pas présent, le Cache
appellera la fermeture et sauvegardera la valeur
résultante dans la table de hachage associée à sa clé arg
.
Le second problème avec l'implémentation actuelle de Cache
est qu'il n'accepte
que les fermetures qui prennent un paramètre de type u32
et renvoient un
u32
. Nous pourrions vouloir mettre en cache les résultats des fermetures qui
prennent une slice d'une chaîne de caractères et renvoient des valeurs usize
,
par exemple. Pour corriger ce problème, essayez d'introduire des paramètres plus
génériques pour augmenter la flexibilité de la fonctionnalité offerte par
Cache
.
Capturer l'environnement avec les fermetures
Dans l'exemple du générateur d'entraînement, nous n'avons utilisé les fermetures que comme des fonctions anonymes internes. Cependant, les fermetures ont une capacité supplémentaire que les fonctions n'ont pas : elles peuvent capturer leur environnement et accéder aux variables de la portée dans laquelle elles sont définies.
L'encart 13-12 montre un exemple de fermeture stockée dans la variable
egal_a_x
qui utilise la variable x
de l'environnement environnant de la
fermeture :
Fichier : src/main.rs
fn main() { let x = 4; let egal_a_x = |z| z == x; let y = 4; assert!(egal_a_x(y)); }
Ici, même si x
n'est pas un des paramètres de egal_a_x
, la fermeture
egal_a_x
est autorisée à utiliser la variable x
définie dans la même
portée que celle où est définie egal_a_x
.
Nous ne pouvons pas faire la même chose avec les fonctions ; si nous essayons avec l'exemple suivant, notre code ne se compilera pas :
Fichier : src/main.rs
fn main() {
let x = 4;
fn egal_a_x(z: i32) -> bool {
z == x
}
let y = 4;
assert!(egal_a_x(y));
}
Nous obtenons l'erreur suivante :
$ cargo run
Compiling equal-to-x v0.1.0 (file:///projects/equal-to-x)
error[E0434]: can't capture dynamic environment in a fn item
--> src/main.rs:5:14
|
5 | z == x
| ^
|
= help: use the `|| { ... }` closure form instead
For more information about this error, try `rustc --explain E0434`.
error: could not compile `equal-to-x` due to previous error
Le compilateur nous rappelle même que cela ne fonctionne qu'avec les fermetures !
Lorsqu'une fermeture capture une valeur de son environnement, elle utilise la mémoire pour stocker les valeurs à utiliser dans son corps. Cette utilisation de la mémoire a un coût supplémentaire que nous ne voulons pas payer dans les cas les plus courants où nous voulons exécuter du code qui ne capture pas son environnement. Comme les fonctions ne sont jamais autorisées à capturer leur environnement, la définition et l'utilisation des fonctions n'occasionneront jamais cette surcharge.
Les fermetures peuvent capturer les valeurs de leur environnement de trois
façons différentes, qui correspondent directement aux trois façons dont une
fonction peut prendre un paramètre : prendre possession, emprunter de manière
immuable et emprunter de manière mutable. Ces moyens sont codés dans les trois
traits Fn
comme ceci :
FnOnce
consomme les variables qu'il capture à partir de sa portée, désignée sous le nom de l'environnement de la fermeture. Pour consommer les variables capturées, la fermeture doit prendre possession de ces variables et les déplacer dans la fermeture lorsqu'elle est définie. La partieOnce
du nom représente le fait que la fermeture ne puisse pas prendre prendre possession des mêmes variables plus d'une fois, donc elle ne peut être appelée qu'une seule fois.FnMut
peut changer l'environnement car elle emprunte des valeurs de manière mutable.Fn
emprunte des valeurs de l'environnement de manière immuable.
Lorsque nous créons une fermeture, Rust déduit quel trait utiliser en se basant
sur la façon dont la fermeture utilise les valeurs de l'environnement. Toutes
les fermetures implémentent FnOne
car elles peuvent toute être appelées au
moins une fois. Les fermetures qui ne déplacent pas les variables capturées
implémentent également FnMut
, et les fermetures qui n'ont pas besoin d'accès
mutable aux variables capturées implémentent aussi Fn
. Dans l'encart 13-12, la
fermeture egal_a_x
emprunte x
immuablement (donc egal_a_x
a le trait Fn
)
parce que le corps de la fermeture ne fait que lire la valeur de x
.
Si nous voulons forcer la fermeture à prendre possession des valeurs qu'elle
utilise dans l'environnement, nous pouvons utiliser le mot-clé move
avant la
liste des paramètres. Cette technique est très utile lorsque vous passez une
fermeture à une nouvelle tâche pour déplacer les données afin qu'elles
appartiennent à la nouvelle tâche.
Remarque : les fermetures
move
peuvent toujours implémenterFn
ouFnMut
, même si elles capturent les variables en les déplaçant. C'est possible car les traits implémentés par un type de fermeture sont déterminés par ce que font ces fermetures avec les valeurs déplacées et pas d'après la façon dont elles les capturent. Le mot-clémove
ne définit que ce dernier aspect.
Nous verrons d'autres exemples de fermetures utilisant move
au chapitre 16
lorsque nous parlerons de la concurrence. Pour l'instant, voici le code de
l'encart 13-12 avec le mot-clé move
ajouté à la définition de la fermeture et
utilisant des vecteurs au lieu d'entiers, car les entiers peuvent être copiés
plutôt que déplacés ; notez aussi que ce code ne compile pas encore.
Fichier : src/main.rs
fn main() {
let x = vec![1, 2, 3];
let egal_a_x = move |z| z == x;
println!("On ne peut pas utiliser x ici : {:?}", x);
let y = vec![1, 2, 3];
assert!(egal_a_x(y));
}
Nous obtenons l'erreur suivante :
$ cargo run
Compiling equal-to-x v0.1.0 (file:///projects/equal-to-x)
error[E0382]: borrow of moved value: `x`
--> src/main.rs:6:40
|
2 | let x = vec![1, 2, 3];
| - move occurs because `x` has type `Vec<i32>`, which does not implement the `Copy` trait
3 |
4 | let egal_a_x = move |z| z == x;
| -------- - variable moved due to use in closure
| |
| value moved into closure here
5 |
6 | println!("On ne peut pas utiliser x ici : {:?}", x);
| ^ value borrowed here after move
For more information about this error, try `rustc --explain E0382`.
error: could not compile `equal-to-x` due to previous error
La valeur x
est déplacée dans la fermeture lorsque la fermeture est définie,
parce que nous avons ajouté le mot-clé move
. La fermeture a alors la propriété
de x
, et main
n'est plus autorisé à utiliser x
dans l'instruction
println!
. Supprimer println!
corrigera cet exemple.
La plupart du temps, lorsque vous renseignez l'un des traits liés Fn
, vous
pouvez commencer par Fn
et le compilateur vous dira si vous avez besoin de
FnMut
ou FnOnce
en fonction de ce qui se passe dans le corps de la
fermeture.
Pour illustrer les situations où des fermetures qui capturent leur environnement sont utiles comme paramètres de fonction, passons à notre sujet suivant : les itérateurs.