Closures: Anonymous Functions That Capture Their Environment

RustRustBeginner
Pratiquer maintenant

This tutorial is from open-source community. Access the source code

💡 Ce tutoriel est traduit par l'IA à partir de la version anglaise. Pour voir la version originale, vous pouvez cliquer ici

Introduction

Bienvenue dans Closures: Anonymous Functions That Capture Their Environment. Ce laboratoire est une partie du Rust Book. Vous pouvez pratiquer vos compétences Rust dans LabEx.

Dans ce laboratoire, vous allez explorer les closures en Rust, qui sont des fonctions anonymes qui peuvent être enregistrées dans des variables ou passées en tant qu'arguments, permettant la réutilisation du code et la personnalisation du comportement en capturant des valeurs de leur portée de définition.

Closures: Anonymous Functions That Capture Their Environment

Les closures en Rust sont des fonctions anonymes que vous pouvez enregistrer dans une variable ou passer en tant qu'arguments à d'autres fonctions. Vous pouvez créer la closure en un lieu et ensuite appeler la closure ailleurs pour l'évaluer dans un contexte différent. Contrairement aux fonctions, les closures peuvent capturer des valeurs de la portée dans laquelle elles sont définies. Nous allons démontrer comment ces fonctionnalités de closure permettent la réutilisation du code et la personnalisation du comportement.

Capturer l'environnement avec les closures

Nous allons tout d'abord examiner comment utiliser les closures pour capturer des valeurs de l'environnement dans lequel elles sont définies pour une utilisation ultérieure. Voici le scénario : de temps en temps, notre société de T-shirts distribue une chemise exclusive et limitée à quelqu'un de notre liste de diffusion en tant que promotion. Les personnes inscrites sur la liste de diffusion peuvent optionnellement ajouter leur couleur préférée à leur profil. Si la personne choisie pour une chemise gratuite a défini sa couleur préférée, elle reçoit une chemise de cette couleur. Si la personne n'a pas spécifié de couleur préférée, elle reçoit la couleur que la société a le plus en stock actuellement.

Il existe de nombreuses façons de mettre en œuvre cela. Pour cet exemple, nous allons utiliser une énumération appelée ShirtColor qui a les variantes Red et Blue (limitant le nombre de couleurs disponibles pour la simplicité). Nous représentons le stock de la société avec une structure Inventory qui a un champ nommé shirts qui contient un Vec<ShirtColor> représentant les couleurs de chemises actuellement en stock. La méthode giveaway définie sur Inventory obtient la préférence de couleur de chemise optionnelle du gagnant de la chemise gratuite et renvoie la couleur de chemise que la personne recevra. Ce montage est montré dans la Liste 13-1.

Nom du fichier : src/main.rs

#[derive(Debug, PartialEq, Copy, Clone)]
enum ShirtColor {
    Red,
    Blue,
}

struct Inventory {
    shirts: Vec<ShirtColor>,
}

impl Inventory {
    fn giveaway(
        &self,
        user_preference: Option<ShirtColor>,
    ) -> ShirtColor {
      1 user_preference.unwrap_or_else(|| self.most_stocked())
    }

    fn most_stocked(&self) -> ShirtColor {
        let mut num_red = 0;
        let mut num_blue = 0;

        for color in &self.shirts {
            match color {
                ShirtColor::Red => num_red += 1,
                ShirtColor::Blue => num_blue += 1,
            }
        }
        if num_red > num_blue {
            ShirtColor::Red
        } else {
            ShirtColor::Blue
        }
    }
}

fn main() {
    let store = Inventory {
      2 shirts: vec![
            ShirtColor::Blue,
            ShirtColor::Red,
            ShirtColor::Blue,
        ],
    };

    let user_pref1 = Some(ShirtColor::Red);
  3 let giveaway1 = store.giveaway(user_pref1);
    println!(
        "The user with preference {:?} gets {:?}",
        user_pref1, giveaway1
    );

    let user_pref2 = None;
  4 let giveaway2 = store.giveaway(user_pref2);
    println!(
        "The user with preference {:?} gets {:?}",
        user_pref2, giveaway2
    );
}

Liste 13-1 : Situation de distribution de chemises par la société de T-shirts

Le store défini dans main a deux chemises bleues et une chemise rouge restantes à distribuer pour cette promotion limitée [2]. Nous appelons la méthode giveaway pour un utilisateur ayant une préférence pour une chemise rouge [3] et un utilisateur sans aucune préférence [4].

Encore une fois, ce code pourrait être implémenté de nombreuses façons, et ici, pour nous concentrer sur les closures, nous sommes restés aux concepts que vous avez déjà appris, excepté le corps de la méthode giveaway qui utilise une closure. Dans la méthode giveaway, nous obtenons la préférence de l'utilisateur en tant que paramètre de type Option<ShirtColor> et appelons la méthode unwrap_or_else sur user_preference [1]. La méthode unwrap_or_else sur Option<T> est définie par la bibliothèque standard. Elle prend un argument : une closure sans aucun argument qui renvoie une valeur T (le même type stocké dans la variante Some de Option<T>, dans ce cas ShirtColor). Si Option<T> est la variante Some, unwrap_or_else renvoie la valeur à l'intérieur de Some. Si Option<T> est la variante None, unwrap_or_else appelle la closure et renvoie la valeur renvoyée par la closure.

Nous spécifions l'expression de closure || self.most_stocked() comme argument pour unwrap_or_else. Il s'agit d'une closure qui ne prend pas de paramètres elle-même (si la closure avait des paramètres, ils apparaîtraient entre les deux tuyaux verticaux). Le corps de la closure appelle self.most_stocked(). Nous définissons la closure ici, et l'implémentation de unwrap_or_else évaluera la closure plus tard si le résultat est nécessaire.

Exécuter ce code affiche ce qui suit :

The user with preference Some(Red) gets Red
The user with preference None gets Blue

Un aspect intéressant ici est que nous avons passé une closure qui appelle self.most_stocked() sur l'instance Inventory actuelle. La bibliothèque standard n'a pas besoin de savoir quoi que ce soit sur les types Inventory ou ShirtColor que nous avons définis, ou sur la logique que nous voulons utiliser dans ce scénario. La closure capture une référence immuable à l'instance Inventory self et la passe avec le code que nous spécifions à la méthode unwrap_or_else. Les fonctions, en revanche, ne sont pas capables de capturer leur environnement de cette manière.

Inférence et annotation de type pour les closures

Il existe d'autres différences entre les fonctions et les closures. Les closures ne nécessitent généralement pas que vous annotiez les types des paramètres ou de la valeur de retour comme les fonctions fn le font. Les annotations de type sont requises pour les fonctions car les types font partie d'une interface explicite exposée à vos utilisateurs. Définir cette interface de manière rigide est important pour s'assurer que tout le monde est d'accord sur les types de valeurs qu'une fonction utilise et renvoie. Les closures, en revanche, ne sont pas utilisées dans une interface exposée de cette manière : elles sont stockées dans des variables et utilisées sans les nommer et sans les exposer aux utilisateurs de notre bibliothèque.

Les closures sont généralement courtes et ne sont pertinentes que dans un contexte restreint plutôt que dans n'importe quel scénario arbitraire. Dans ces contextes limités, le compilateur peut inférer les types des paramètres et le type de retour, de manière similaire à la façon dont il est capable d'inférer les types de la plupart des variables (il existe des cas rares où le compilateur a également besoin d'annotations de type pour les closures).

Comme pour les variables, nous pouvons ajouter des annotations de type si nous voulons augmenter l'explicitude et la clarté au détriment d'une plus grande verbeosité que nécessaire. Annoter les types pour une closure ressemblerait à la définition montrée dans la Liste 13-2. Dans cet exemple, nous définissons une closure et la stockons dans une variable plutôt que de la définir sur place où nous la passons en tant qu'argument, comme nous l'avons fait dans la Liste 13-1.

Nom du fichier : src/main.rs

let expensive_closure = |num: u32| -> u32 {
    println!("calculating slowly...");
    thread::sleep(Duration::from_secs(2));
    num
};

Liste 13-2 : Ajout d'annotations de type optionnelles des types de paramètre et de valeur de retour dans la closure

Avec les annotations de type ajoutées, la syntaxe des closures ressemble plus à la syntaxe des fonctions. Ici, nous définissons une fonction qui ajoute 1 à son paramètre et une closure qui a le même comportement, pour la comparaison. Nous avons ajouté quelques espaces pour aligner les parties pertinentes. Cela illustre comment la syntaxe des closures est similaire à la syntaxe des fonctions, sauf pour l'utilisation des tuyaux et la quantité de syntaxe optionnelle :

fn  add_one_v1   (x: u32) -> u32 { x + 1 }
let add_one_v2 = |x: u32| -> u32 { x + 1 };
let add_one_v3 = |x|             { x + 1 };
let add_one_v4 = |x|               x + 1  ;

La première ligne montre une définition de fonction et la deuxième ligne montre une définition de closure entièrement annotée. Dans la troisième ligne, nous supprimons les annotations de type de la définition de closure. Dans la quatrième ligne, nous supprimons les accolades, qui sont optionnelles car le corps de la closure n'a qu'une seule expression. Toutes ces définitions sont valides et produiront le même comportement lorsqu'elles seront appelées. Les lignes add_one_v3 et add_one_v4 nécessitent que les closures soient évaluées pour être compilées car les types seront inférés à partir de leur utilisation. Cela est similaire à let v = Vec::new(); qui nécessite soit des annotations de type soit des valeurs d'un certain type à être insérées dans le Vec pour que Rust soit capable d'inférer le type.

Pour les définitions de closures, le compilateur inferera un type concret pour chacun de leurs paramètres et pour leur valeur de retour. Par exemple, la Liste 13-3 montre la définition d'une closure courte qui ne renvoie que la valeur qu'elle reçoit en tant que paramètre. Cette closure n'est pas très utile, sauf dans le cadre de cet exemple. Notez que nous n'avons pas ajouté d'annotations de type à la définition. Du fait qu'il n'y a pas d'annotations de type, nous pouvons appeler la closure avec n'importe quel type, ce que nous avons fait ici avec String la première fois. Si nous essayons ensuite d'appeler example_closure avec un entier, nous obtiendrons une erreur.

Nom du fichier : src/main.rs

let example_closure = |x| x;

let s = example_closure(String::from("hello"));
let n = example_closure(5);

Liste 13-3 : Tentative d'appel d'une closure dont les types sont inférés avec deux types différents

Le compilateur nous donne cette erreur :

error[E0308]: mismatched types
 --> src/main.rs:5:29
  |
5 |     let n = example_closure(5);
  |                             ^- help: try using a conversion method:
`.to_string()`
  |                             |
  |                             expected struct `String`, found integer

La première fois que nous appelons example_closure avec la valeur String, le compilateur infère le type de x et le type de retour de la closure comme étant String. Ces types sont ensuite bloqués dans la closure dans example_closure, et nous obtenons une erreur de type lorsque nous essayons ensuite d'utiliser un type différent avec la même closure.

Capturer des références ou transférer la propriété

Les closures peuvent capturer des valeurs de leur environnement de trois manières, qui correspondent directement aux trois manières dont une fonction peut prendre un paramètre : emprunter de manière immuable, emprunter de manière mutable et prendre la propriété. La closure décidera laquelle de ces méthodes utiliser en fonction de ce que le corps de la fonction fait avec les valeurs capturées.

Dans la Liste 13-4, nous définissons une closure qui capture une référence immuable au vecteur nommé list car elle a seulement besoin d'une référence immuable pour afficher la valeur.

Nom du fichier : src/main.rs

fn main() {
    let list = vec![1, 2, 3];
    println!("Before defining closure: {:?}", list);

  1 let only_borrows = || println!("From closure: {:?}", list);

    println!("Before calling closure: {:?}", list);
  2 only_borrows();
    println!("After calling closure: {:?}", list);
}

Liste 13-4 : Définition et appel d'une closure qui capture une référence immuable

Cet exemple illustre également qu'une variable peut être liée à une définition de closure [1], et que nous pouvons plus tard appeler la closure en utilisant le nom de la variable et des parenthèses comme si le nom de la variable était le nom d'une fonction [2].

Parce que nous pouvons avoir plusieurs références immuables à list en même temps, list est toujours accessible dans le code avant la définition de la closure, après la définition de la closure mais avant l'appel de la closure, et après l'appel de la closure. Ce code se compile, s'exécute et imprime :

Before defining closure: [1, 2, 3]
Before calling closure: [1, 2, 3]
From closure: [1, 2, 3]
After calling closure: [1, 2, 3]

Ensuite, dans la Liste 13-5, nous modifions le corps de la closure pour qu'elle ajoute un élément au vecteur list. La closure capture maintenant une référence mutable.

Nom du fichier : src/main.rs

fn main() {
    let mut list = vec![1, 2, 3];
    println!("Before defining closure: {:?}", list);

    let mut borrows_mutably = || list.push(7);

    borrows_mutably();
    println!("After calling closure: {:?}", list);
}

Liste 13-5 : Définition et appel d'une closure qui capture une référence mutable

Ce code se compile, s'exécute et imprime :

Before defining closure: [1, 2, 3]
After calling closure: [1, 2, 3, 7]

Notez qu'il n'y a plus de println! entre la définition et l'appel de la closure borrows_mutably : lorsqu'on définit borrows_mutably, elle capture une référence mutable à list. Nous n'utilisons plus la closure après qu'elle a été appelée, donc le prêt mutable prend fin. Entre la définition de la closure et l'appel de la closure, un prêt immuable pour afficher n'est pas autorisé car aucun autre prêt n'est autorisé lorsqu'il y a un prêt mutable. Essayez d'ajouter un println! là pour voir quel message d'erreur vous obtenez!

Si vous voulez forcer la closure à prendre la propriété des valeurs qu'elle utilise dans l'environnement même si le corps de la closure n'a pas strictement besoin de la propriété, vous pouvez utiliser le mot clé move avant la liste de paramètres.

Cette technique est surtout utile lorsqu'on passe une closure à un nouveau thread pour transférer les données de sorte qu'elles soient possédées par le nouveau thread. Nous discuterons des threads et des raisons pour lesquelles vous voudriez les utiliser en détail au Chapitre 16 lorsque nous parlerons de concurrence, mais pour l'instant, examinons brièvement le lancement d'un nouveau thread en utilisant une closure qui nécessite le mot clé move. La Liste 13-6 montre la Liste 13-4 modifiée pour afficher le vecteur dans un nouveau thread plutôt que dans le thread principal.

Nom du fichier : src/main.rs

use std::thread;

fn main() {
    let list = vec![1, 2, 3];
    println!("Before defining closure: {:?}", list);

  1 thread::spawn(move || {
      2 println!("From thread: {:?}", list)
    }).join().unwrap();
}

Liste 13-6 : Utilisation de move pour forcer la closure du thread à prendre la propriété de list

Nous lançons un nouveau thread, en donnant au thread une closure à exécuter en tant qu'argument. Le corps de la closure imprime la liste. Dans la Liste 13-4, la closure n'a capturé list qu'en utilisant une référence immuable car c'est le moindre accès à list nécessaire pour l'afficher. Dans cet exemple, même si le corps de la closure a toujours seulement besoin d'une référence immuable [2], nous devons spécifier que list doit être transféré dans la closure en plaçant le mot clé move [1] au début de la définition de la closure. Le nouveau thread pourrait finir avant que le reste du thread principal ne finisse, ou le thread principal pourrait finir en premier. Si le thread principal conserve la propriété de list mais finit avant le nouveau thread et libère list, la référence immuable dans le thread serait invalide. Par conséquent, le compilateur exige que list soit transféré dans la closure donnée au nouveau thread pour que la référence soit valide. Essayez de supprimer le mot clé move ou d'utiliser list dans le thread principal après la définition de la closure pour voir quels messages d'erreur du compilateur vous obtenez!

Extraire les valeurs capturées des closures et les traits Fn

Une fois qu'une closure a capturé une référence ou a pris la propriété d'une valeur de l'environnement dans lequel elle est définie (ce qui affecte donc ce qui, le cas échéant, est déplacé vers l'intérieur de la closure), le code dans le corps de la closure définit ce qui se passe avec les références ou les valeurs lorsque la closure est évaluée plus tard (ce qui affecte donc ce qui, le cas échéant, est déplacé vers l'extérieur de la closure).

Le corps d'une closure peut faire l'une des choses suivantes : déplacer une valeur capturée hors de la closure, muter la valeur capturée, ni déplacer ni muter la valeur, ou ne rien capturer de l'environnement au départ.

La manière dont une closure capture et gère les valeurs de l'environnement affecte les traits que la closure implémente, et les traits sont la manière dont les fonctions et les structs peuvent spécifier quels types de closures ils peuvent utiliser. Les closures implémenteront automatiquement un, deux ou les trois traits Fn suivants, de manière additive, selon la façon dont le corps de la closure gère les valeurs :

  • FnOnce s'applique aux closures qui peuvent être appelées une seule fois. Toutes les closures implémentent au moins ce trait car toutes les closures peuvent être appelées. Une closure qui déplace les valeurs capturées hors de son corps n'implémentera que FnOnce et aucun des autres traits Fn car elle ne peut être appelée qu'une seule fois.
  • FnMut s'applique aux closures qui ne déplacent pas les valeurs capturées hors de leur corps, mais qui peuvent muter les valeurs capturées. Ces closures peuvent être appelées plusieurs fois.
  • Fn s'applique aux closures qui ne déplacent pas les valeurs capturées hors de leur corps et qui ne mutent pas les valeurs capturées, ainsi qu'aux closures qui ne capturent rien de leur environnement. Ces closures peuvent être appelées plusieurs fois sans muter leur environnement, ce qui est important dans des cas tels que l'appel d'une closure plusieurs fois simultanément.

Regardons la définition de la méthode unwrap_or_else sur Option<T> que nous avons utilisée dans la Liste 13-1 :

impl<T> Option<T> {
    pub fn unwrap_or_else<F>(self, f: F) -> T
    where
        F: FnOnce() -> T
    {
        match self {
            Some(x) => x,
            None => f(),
        }
    }
}

Rappelez-vous que T est le type générique représentant le type de la valeur dans la variante Some d'un Option. Ce type T est également le type de retour de la fonction unwrap_or_else : le code qui appelle unwrap_or_else sur un Option<String>, par exemple, obtiendra une String.

Ensuite, remarquez que la fonction unwrap_or_else a le paramètre de type générique supplémentaire F. Le type F est le type du paramètre nommé f, qui est la closure que nous fournissons lorsqu'on appelle unwrap_or_else.

La contrainte de trait spécifiée sur le type générique F est FnOnce() -> T, ce qui signifie que F doit être apellable une fois, ne prendre aucun argument et renvoyer un T. En utilisant FnOnce dans la contrainte de trait, on exprime la contrainte selon laquelle unwrap_or_else ne va appeler f qu'une seule fois, au maximum. Dans le corps de unwrap_or_else, on peut voir que si l'Option est Some, f ne sera pas appelé. Si l'Option est None, f sera appelé une fois. Parce que toutes les closures implémentent FnOnce, unwrap_or_else accepte la plus grande variété de closures et est aussi flexible que possible.

Note : Les fonctions peuvent également implémenter les trois traits Fn. Si ce que nous voulons faire ne nécessite pas capturer de valeur de l'environnement, nous pouvons utiliser le nom d'une fonction plutôt qu'une closure là où nous avons besoin de quelque chose qui implémente l'un des traits Fn. Par exemple, sur une valeur Option<Vec<T>>, nous pourrions appeler unwrap_or_else(Vec::new) pour obtenir un nouveau vecteur vide si la valeur est None.

Maintenant, regardons la méthode de la bibliothèque standard sort_by_key, définie sur les slices, pour voir en quoi elle diffère de unwrap_or_else et pourquoi sort_by_key utilise FnMut au lieu de FnOnce pour la contrainte de trait. La closure reçoit un argument sous forme d'une référence à l'élément actuel de la slice considérée et renvoie une valeur de type K qui peut être ordonnée. Cette fonction est utile lorsque vous voulez trier une slice selon un attribut particulier de chaque élément. Dans la Liste 13-7, nous avons une liste d'instances de Rectangle et nous utilisons sort_by_key pour les ordonner par leur attribut width du plus petit au plus grand.

Nom du fichier : src/main.rs

#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

fn main() {
    let mut list = [
        Rectangle { width: 10, height: 1 },
        Rectangle { width: 3, height: 5 },
        Rectangle { width: 7, height: 12 },
    ];

    list.sort_by_key(|r| r.width);
    println!("{:#?}", list);
}

Liste 13-7 : Utilisation de sort_by_key pour ordonner des rectangles par largeur

Ce code imprime :

[
    Rectangle {
        width: 3,
        height: 5,
    },
    Rectangle {
        width: 7,
        height: 12,
    },
    Rectangle {
        width: 10,
        height: 1,
    },
]

La raison pour laquelle sort_by_key est défini pour prendre une closure FnMut est qu'elle appelle la closure plusieurs fois : une fois pour chaque élément de la slice. La closure |r| r.width ne capture, ne muter ni ne déplace rien de son environnement, donc elle répond aux exigences de la contrainte de trait.

En revanche, la Liste 13-8 montre un exemple d'une closure qui implémente seulement le trait FnOnce, car elle déplace une valeur de l'environnement. Le compilateur ne nous laissera pas utiliser cette closure avec sort_by_key.

Nom du fichier : src/main.rs

--snip--

fn main() {
    let mut list = [
        Rectangle { width: 10, height: 1 },
        Rectangle { width: 3, height: 5 },
        Rectangle { width: 7, height: 12 },
    ];

    let mut sort_operations = vec![];
    let value = String::from("by key called");

    list.sort_by_key(|r| {
        sort_operations.push(value);
        r.width
    });
    println!("{:#?}", list);
}

Liste 13-8 : Tentative d'utilisation d'une closure FnOnce avec sort_by_key

C'est une manière artificielle et compliquée (qui ne fonctionne pas) d'essayer de compter le nombre de fois que sort_by_key est appelé lors du tri de list. Ce code tente de faire ce comptage en ajoutant value --- une String de l'environnement de la closure --- dans le vecteur sort_operations. La closure capture value puis déplace value hors de la closure en transférant la propriété de value au vecteur sort_operations. Cette closure peut être appelée une fois ; essayer de l'appeler une deuxième fois ne fonctionnerait pas car value ne serait plus dans l'environnement pour être ajouté à nouveau dans sort_operations! Par conséquent, cette closure n'implémente que FnOnce. Lorsque nous essayons de compiler ce code, nous obtenons cette erreur selon laquelle value ne peut pas être déplacé hors de la closure car la closure doit implémenter FnMut :

error[E0507]: cannot move out of `value`, a captured variable in an `FnMut`
closure
  --> src/main.rs:18:30
   |
15 |       let value = String::from("by key called");
   |           ----- captured outer variable
16 |
17 |       list.sort_by_key(|r| {
   |  ______________________-
18 | |         sort_operations.push(value);
   | |                              ^^^^^ move occurs because `value` has
type `String`, which does not implement the `Copy` trait
19 | |         r.width
20 | |     });
   | |_____- captured by this `FnMut` closure

L'erreur pointe vers la ligne dans le corps de la closure qui déplace value hors de l'environnement. Pour corriger ceci, nous devons modifier le corps de la closure de sorte qu'elle ne déplace pas les valeurs hors de l'environnement. Conserver un compteur dans l'environnement et incrémenter sa valeur dans le corps de la closure est une manière plus directe de compter le nombre de fois que sort_by_key est appelé. La closure dans la Liste 13-9 fonctionne avec sort_by_key car elle capture seulement une référence mutable au compteur num_sort_operations et peut donc être appelée plusieurs fois.

Nom du fichier : src/main.rs

--snip--

fn main() {
    --snip--

    let mut num_sort_operations = 0;
    list.sort_by_key(|r| {
        num_sort_operations += 1;
        r.width
    });
    println!(
        "{:#?}, sorted in {num_sort_operations} operations",
        list
    );
}

Liste 13-9 : Utilisation d'une closure FnMut avec sort_by_key est autorisée.

Les traits Fn sont importants lorsqu'on définit ou utilise des fonctions ou des types qui utilisent des closures. Dans la section suivante, nous parlerons d'itérateurs. De nombreuses méthodes d'itérateur prennent des arguments de closure, donc gardez ces détails sur les closures à l'esprit lorsque nous continuerons!

Sommaire

Félicitations ! Vous avez terminé le laboratoire Closures: Anonymous Functions That Capture Their Environment. Vous pouvez pratiquer d'autres laboratoires sur LabEx pour améliorer vos compétences.