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() {}

Encart 13-1 : une fonction pour remplacer un calcul hypothétique qui prend environ deux secondes à s'exécuter

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);
}

Encart 13-2 : une fonction main avec des valeurs codées en dur pour simuler la saisie d'une valeur d'intensité par l'utilisateur et la génération d'un nombre aléatoire

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);
}

Encart 13-3 : la logique métier qui affiche les programmes d'entraînement en fonction des entrées et des appels à la fonction simuler_gros_calcul.

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);
}

Encart 13-4 : extraction des appels à simuler_gros_calcul dans un seul endroit et stockage du résultat dans la variable resultat_lent.

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);
}

Encart 13-5 : définition d'une fermeture et son enregistrement dans la variable fermeture_lente.

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);
}

Encart 13-6 : appel de la fermeture fermeture_lente que nous avons définie

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);
}

Encart 13-7 : ajout d'annotations de type optionnelles sur les paramètres et les valeurs de retour de la fermeture

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);
}

Encart 13-8 : tentative d'appeler une fermeture dont les types sont déduits avec deux types différents

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() {}

Encart 13-9 : définition d'une structure Cache qui possède une fermeture dans calcul et un résultat optionnel dans valeur.

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 trait Fn.

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() {}

Encart 13-10 : la logique de Cache

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);
}

Encart 13-11 : utilisation de Cache dans la fonction generer_exercices pour masquer la logique du cache.

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));
}

Encart 13-12 : exemple d'une fermeture qui se réfère à une variable présente dans la portée qui la contient.

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 partie Once 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émenter Fn ou FnMut, 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.