https://lafor.ge/feed.xml

Les closures

2024-01-12

Bonjour à toutes et à tous 😀

Rust est un langage qui possède tout une collection de concepts permettant une grande variété d'usages.

Parmis ceux-ci, je voudrai vous parler d'un en particulier: les closures.

Qu'est-ce qu'une closure ?

Une closure ou "fermeture" est un concept général en informatique qui définit un contexte d'exécution (généralement une fonction) fermé qui possède son propre environnement.

C'est pas clair? C'est normal ^^

Nous allons y aller progressivement comme d'habitude pour saisir toutes les subtilités.

Pointeur de fonction

Premier contact

Partons de la closure la plus simple possible.

let closure = || {};

Deux choses sont à remarquer:

  • Premièremet, il y a apparition dans la syntaxe d'une double-barre verticale ||, c'est ce symbole qui dit à Rust que nous démarrons la déclaration d'une closure. Ensuite nous avons un bloc classique de Rust.

  • Deuxièmement, la closure est affectable à une variable comme n'importe quelle valeur.

Si l'on tente de debug la closure, on obtient une erreur et un conseil du compilateur.

let closure = || {};
                 -- consider calling this closure
dbg!(closure);
^^^^^^^^^^^^^ `{closure}` cannot be formatted using `{:?}` because it doesn't implement `Debug`

La closure elle-même n'est pas debuggable mais par contre, elle est appelable.

Appellons-la

dbg!(closure())

Cette fois-ci, nous obtenons bien un résulat

closure() = ()

Bon, on progresse, certe lentement mais on progresse.

Quel est le type de closure ?

Ça c'est une qustion intéressante.

Il en a deux, mais je réserve le deuxième pour une partie qui lui sera dédié.

Le premier type est ce que l'on appelle un pointeur de fonction.

Dans notre cas, nous pouvons l'écrire de deux façons différentes:

let closure : fn() -> () = || {};

ou en passant sous silence le type de retour

let closure : fn() = || {};

On voit que la syntaxe est extrêmement similaire à ce que l'on utilise déjà lors de la déclaration de fonctions.

fn closure() -> () {
    ()
}

fn closure() {
    ()
}

fn closure() {}

On commence par déclarer une fonction avec l'opérateur fn, puis les paramètres (ici aucun), entre paranthèses, puis une flèche -> et finalement le type de retour, ici unit ().

Et c'est parfaitement normal car c'est le même mécanisme qui est à l'oeuvre. Le code généré sera le même.

En fait closure est une adresse quelque part dans la mémoire.

On peut demander à Rust de nous l'afficher via

dbg!(&closure as *const _); //  &closure as *const _ = 0x00000010baaff410

Si l'on veut non pas l'addresse de closure mais l'adresse du code de la closure pointé par la variable closure on peut écrire quelque chose comme cela

dbg!(closure as fn()); // closure as fn() = 0x00007ff7ca7c1c80

Temporiser un retour

Ne rien renvoyer n'est pas très excitant, essayons de lui faire retourner quelque chose, un 42 par exemple.

let universal_answer : fn() -> u8 = || 42;

Je vous donne son type fn() -> u8, une fonction qui ne prend pas de paramètre et qui renvoit un u8 et toujours le même 42.

Ce qui signifie que l'on a enfermé dans une variable une valeur que l'on peut récupérer à tout moment.

dbg!(universal_answer()); // universal_answer() = 42

Autrement dit, nous avons temporiser l'appel de la fonction dans une variable.

Cette variable est appelable autant de fois que l'on désire.

dbg!(universal_answer()); // universal_answer() = 42
dbg!(universal_answer()); // universal_answer() = 42
dbg!(universal_answer()); // universal_answer() = 42

Paramétrisation simple

Bon c'est pas tout ça mais notre fonction retourne quelque chose mais tout le temps la même chose.

C'est pas très intéressant.

Déclarons une autre closure, nous allons l'appeler identity, elle renverra ce que l'on lui défini à l'appel de la closure.

Pour cela nous allons introduire une syntaxe

let identity = |x: u8| x;

Pour définir un paramètre il faut créer ceux-ci entre les barres verticales. Ensuite c'est la syntaxe classique qui s'applique: on défini le nom du paramètre suivi de son type séparé par deux :.

Pour l'appeler c'est le même fonctionnement qu'une fonction standard

dbg!(identity(42)); // identity(42) = 42

On peut également la typer explicitement:

let identity : fn(u8) -> u8 = |x : u8| x;

Dans cette syntaxe il est alors possible de ne pas redéfinir le type du paramètre, car la signature le contient déjà.

let identity : fn(u8) -> u8 = |x| x;

Il est même possible de ne pas donner explicitement le type de retour et laisser Rust se débrouiller.

let identity : fn(u8) -> _ = |x| x;

À l'inverse le type de retour peut être défini dans la déclaration du la closure.

let identity = |x| -> u8 { x }; // les accolades sont obligatoires

Toutes ces syntaxes alternatives peuvent sembler superflues, mais dans la pratique, elles auront presque toutes un intérêt en fonction du contexte de leur utilisation.

Résult attendu, mais toujours pas transcendant.

Essayons de lui donner un comportement plus évolué.

let add_10 = |x| x + 10; // je laisse Rust typer comme un grand

Nous avons maintenant une closure qui ajoute 10 à ce que l'on lui donne entré.

dbg!(add_10(1)); // 11
dbg!(add_10(1000)); // 1010
dbg!(add_10(100000000)); // 100000010

Attention

Ce n'est pas de la généricité!

Le premier appel fixe le type.

Si c'est integer_u8 en premier, le type sera u8, si c'est l'inverse, ça sera u64.

let add_10 = |x| x + 10;

let integer_u64: u64 = add_10(565345345341354154151);
let integer_u8: u8 = add_10(2);

Provoque

let integer_u8: u8 = add_10(2);
                     ^^^^^^^^^ expected `u8`, found `u64`

Ceci est dû car le code effectivement compilé sera

let add_10 : fn(u64) -> u64 = |x| x + 10;

Paramètres multiples

Et pourquoi pas deux paramètres ?

let add = |x : u8, y: u8| x + y;

Maintenant nous commençons avoir quelque chose d'intéressant.

dbg!(add(5, 2)) // add(5, 2) = 7

On peut alors créer des comportement différents.

let mul = |x : u8, y: u8| x * y;
dbg!(mul(5, 2)) // mul(5, 2) = 10

Tous les paramètres n'ont pas besoin d'être du même type et vous pouvez en mettre autant que vous voulez.

|x: u8, y: f32, z: bool| {...}

Comme paramètre de fonction

Si une closure a un type, alors elle peut être passé en paramètre d'une autre fonction.

fn run_closure(closure: fn(u8, u8) -> u8, x : u8, y: u8) {
    dbg!(closure(x, y));
}

On se contente de reprendre la signature que l'on a défini plus haut.

On perd ici le nom de la closure, mais sa signature étant correcte, Rust n'a pas réellement besoin de savoir si c'est une addition ou une multiplication pour laisser le CPU l'éxécuter. Tout ce qu'il a besoin de savoir c'est quels sont le nombre et le type d'entrées et quel est le type de retour. Le reste est de la cuisine interne à la closure.

Pour l'appeler c'est aussi simple que de passer un paramètre quelconque.

let add = |x, y| x + y;
let mul = |x, y| x * y;
run_closure(add, 5, 2); // 7
run_closure(mul, 5, 2); // 10

Vous pouvez même vous abstenir de définir la variable tout court

run_closure(|x, y| x + y, 5, 2); // 7
run_closure(|x, y| x * y, 5, 2); // 10

Variations

Tout que l'on a défini jusqu'à maintenant peut l'être de 3 façons différentes qui aménent toutes au même code généré.

La forme closure

fn main() {
    let add_closure = |x: u8, y: u8| x + y;
    dbg!(add_closure(5, 2));
}

La forme fonction locale

fn main() {
    fn add_local(x: u8, y: u8) -> u8 {
        x + y
    }
    dbg!(add_local(5, 2));
}

Ou même la forme, fonction classique

fn add_global(x: u8, y: u8) -> u8 {
    x + y
}

fn main() {
    dbg!(add_global(5, 2));
}

Pour le moment toutes ces écritures sont équivalentes.

En effet nous pouvons écrire ceci et c'est parfaitement valide.

run_closure(add_global, 5, 2);
run_closure(add_local, 5, 2);
run_closure(add_closure, 5, 2);
run_closure(|x, y| x + y, 5, 2);

La seul chose qui diffère entre toutes écriture est validité d'existence de leur déclaration.

  • La fonction est accessible de n'importe où dans le code, elle est défini en même temps que le main et peut même lui survivre, (dans les faits tout sera nettoyé à la fin de main).
  • La fonction local n'a d'existence que dans le main et ne peut donc pas être appelé de l'extérieur de main, elle peut par contre être passé en paramètre et appelée autant de fois que nécessaire.
  • La closure affecté à une variable a le même comportement que la fonction local, on verra dans la suite pourquoi.
  • La closure anonyme par contre n'a de sens que dans le run_closure

Comme retour de fonction

Dans l'informatique il y a un terme qui se dit High Order Function. Moins pompeusement appelé, il s'agit d'une fonction qui retourne une fonction.

fn return_universal_awswer() -> fn() -> u8 {
    || 42
}

Le type de retour sera alors fn() -> u8.

Lorsque l'on appelera cette fonction, ce n'est non pas 42 qui sera retourné mais quelque chose qui retournera 42 lorsque l'on l'appelera.

fn main() {
    let future_universal_answer = return_universal_awswer();
    dbg!(future_universal_answer()); // 42
}

Closure

Vous allez voir que depuis tout à l'heure, nous n'avons pas utilisé une seule closure, mais juste un pointeur de fonction.

Il est temps de se plonger dans le vrai fonctionnement des closures. 😝

Faire référence

Bien commençons à donner un peu d'intérêt à notre notation sous la forme de closure.

let a = 20;
let add_with_ref_value = |x: u8, y: u8| x + y + &a;
dbg!(add_with_ref_value(5, 2)); // 27

On remarque ici, que Rust permet d'additionner des u8 et des &u8 grâce à l'implémentation par défaut ci-dessous

impl<'a> Add<i32> for &'a i32 {}
// et
impl Add<&i32> for i32 {}

Si on essaie de faire la même chose avec la fonction locale, nous avons déjà un souci.

let a = 20;
fn add_local(x: u8, y: u8) -> u8 {
    x + y + &a
}

Le compilo n'est pas content et un peu énigmatique.

can't capture dynamic environment in a fn item

         x + y + &a
                  ^

= help: use the `|| { ... }` closure form instead

can't capture dynamic environment in a fn item, ce mot de capture est très important pour la suite.

La force d'une closure par rapport à une fonction c'est qu'elle capture l'environnement. Donc clairement la notation closure est la voie royale pour faire ce dont on a besoin.

Muter une référence

Il est également possible de modifier une référence dans une closure.

let mut a = 2;
let double_ref = || *&mut a *= 2; // peut-être écrit aussi comme a *= 2
double_ref();

Ah échec!

cannot borrow `double_ref` as mutable, as it is not declared as mutable
    let double_ref = || *&mut a *= 2;
                              - calling `double_ref` requires mutable binding due to mutable borrow of `a`
    double_ref();
    ^^^^^^^^^^ cannot borrow as mutable
consider changing this to be mutable
    let mut double_ref = || *&mut a *= 2;
        +++

Mais comme d'habitude, le langage nous apprend ce qu'il faut, il suffit de lire. 😊

On remplace le let double_ref par un let mut double_ref.

let mut a = 2;
let mut double_ref = || *&mut a *= 2;
double_ref();
dbg!(a); // 4

Maintenant, cela compile. 🎉

Nous sommes désormais sur les terres du Borrow Checker, nous allons devoir nous plier à ses règles.

Pour rappel:

  • il ne peut pas y avoir plusieurs références mutables sur un même espace mémoire
  • si une référence mutable est active, alors il ne peut pas y avoir d'autre référence immutables sur l'espace mémoire référencé
  • si une référence est défini, elle doit être valide

Bon allons titiller le Maître de Maison.

Tout d'abord, commençons avec les références mutables, multiples.

let mut a = 2;
let mut double_ref = || *&mut a *= 2;
let mut triple_ref = || *&mut a *= 3;
double_ref();

Ça explose comme prévu 💥

cannot borrow `a` as mutable more than once at a time

let mut double_ref = || *&mut a *= 2;
                     --       - first borrow occurs due to use of `a` in closure
                     |
                     first mutable borrow occurs here
let mut triple_ref = || *&mut a *= 3;
                     ^^       - second borrow occurs due to use of `a` in closure
                     |
                     second mutable borrow occurs here
double_ref();
---------- first borrow later used here

Et comme prévu c'est le fait de borrow mut plusieurs fois qui l'ennuie.

Si on fait un petit dessin cela donne ceci

missing alt

A droite des rectangle double_ref et triple_ref, j'ai défini les début et fin de vie des closures.

  • les références utilisées par double_ref doivent vivre entre le carré et le triangle
  • les références utilisées par triple_ref doivent vivre entre le cercle et la croix

A gauche j'ai exprimé les lignes de vie des borrow mut.

On voit ici que double ref impose que sa référence soit valide de la déclaration de closure jusqu'à la fin de son exécution.

Autrement dit, nous avons deux références mutables pointant sur une même zone mémoire pendant un instant, ici représenté en hachuré.

Même si, triple_ref n'est jamais appelé, Rust à un doute, et quand il a un doute il interdit.

Deux manières de résoudre le problème.

Soit on appelle jamais, mais ce n'est pas intéressant.

let mut a = 2;
let mut double_ref = || *&mut a *= 2;
let mut triple_ref = || *&mut a *= 3;
dbg!(a); // 2

Et alors les lignes de vie ne se croisent jamais.

missing alt

Soit on place astucieusement nos appels et déclaration pour ne pas tomber hors la loi;

let mut a = 2;
let mut double_ref = || *&mut a *= 2;
double_ref();
let mut triple_ref = || *&mut a *= 3;
triple_ref();
dbg!(a); // 12

Là encore pas de croisement.

missing alt

Si nous tentons d'appeler add_with_ref_value en ayant déclaré double_ref après add_with_ref_value

let mut a = 2;
let add_with_ref_value = |x: u8, y: u8| x + y + &a;
let mut double_ref = || *&mut a *= 2;
add_with_ref_value(2, 5);

Instantannément cela explose

cannot borrow `a` as mutable because it is also borrowed as immutable

let add_with_ref_value = |x: u8, y: u8| x + y + &a;
                         --------------          -
                         |                       |
                         |                       first borrow occurs due to use of `a` in closure
                         |
                         immutable borrow occurs here
let mut double_ref = || *&mut a *= 2;
                     ^^       - second borrow occurs due to use of `a` in closure
                     |
                     mutable borrow occurs here
add_with_ref_value(2, 5);
------------------ immutable borrow later used here

Clairement, il est pas d'accord, et effectivement, il y a un croisement.

missing alt

La référence non mutable étant toujours active à la déclaration de double_ref, il lui est impossible d'acquérir une référence mutable sur a, même si double_ref ne sera jamais appelé.

Pour résoudre la situation, il suffit d'intervertir les déclarations.

let mut a = 2;
let mut double_ref = || *&mut a *= 2;
let add_with_ref_value = |x: u8, y: u8| x + y + &a;
dbg!(add_with_ref_value(2, 5)); // 9
dbg!(a); // 2

Les lignes de vie ne croisant plus, le borrow checker est heureux. 😇

missing alt

Maintenant, si nous voulons appeler les deux closures, nous allons avoir un souci.

let mut a = 2;
let mut double_ref = || *&mut a *= 2;
let add_with_ref_value = |x: u8, y: u8| x + y + &a;
add_with_ref_value(2, 5);
double_ref();
cannot borrow `a` as immutable because it is also borrowed as mutable

let mut double_ref = || *&mut a *= 2;
                     --       - first borrow occurs due to use of `a` in closure
                     |
                     mutable borrow occurs here
let add_with_ref_value = |x: u8, y: u8| x + y + &a;
                         ^^^^^^^^^^^^^^          - second borrow occurs due to use of `a` in closure
                         |
                         immutable borrow occurs here
add_with_ref_value(2, 5);
double_ref();
---------- mutable borrow later used here

Le dessin montre bien que la ligne de vie des références de double_ref englobe complètement celle des références utilisées par add_with_ref_value.

missing alt

Intervertir les appels des closures.

missing alt

Ou les déclarations des closures.

missing alt

Ne résout pas la situation, il reste toujours une intersection.

Finalement la solution est de déclarer successivement les closures et de les appeler.

Soit dans un sens

let mut a = 2;
let mut double_ref = || *&mut a *= 2;
double_ref();
let add_with_ref_value = |x: u8, y: u8| x + y + &a;
dbg!(add_with_ref_value(2, 5)); // 11
dbg!(a); // 4
missing alt

Soit dans l'autre

let mut a = 2;
let add_with_ref_value = |x: u8, y: u8| x + y + &a;
dbg!(add_with_ref_value(2, 5)); // 9
let mut double_ref = || *&mut a *= 2;
double_ref();
dbg!(a); // 4
missing alt

Dans les deux cas, la référence mutable est la seule active lorsqu'elle est active.

Retourner une closure

Vous souvenez des High Order Function ?

Nous allons les rendre bien plus intéressantes.

Nous allons créer une fonction qui mémorise un paramètre et qui utilise ce paramètre dans le calcul.

On veut:

let adder_bias_10 = adder_with_bias(10);
let adder_bias_20 = adder_with_bias(20);
adder_bias_10(2, 5); // 17
adder_with_bias(10)(4, 5); // 19
dder_bias_20(2, 5); // 27

Vous voyez un peu l'idée. 🙂

En voici une implémentation.

fn adder_with_bias(bias: u8) -> fn(u8, u8) -> u8 {
    |x, y| x + y + bias
}

Mais malheureusement, cela ne se passe pas aussi bien que prévu 🧨

mismatched types

 fn return_adder_with_bias(bias: u8) -> fn(u8, u8) -> u8 {
                                        ---------------- expected `fn(u8, u8) -> u8` because of return type
     |x, y| x + y + bias
     ^^^^^^^^^^^^^^^^^^^ expected fn pointer, found closure

 note: expected fn pointer `fn(u8, u8) -> u8`
             found closure `{closure@src\main.rs:110:5: 110:11}`
 closures can only be coerced to `fn` types if they do not capture any variables

     |x, y| x + y + bias
                    ^^^^ `bias` captured here

Bien des choses sont intéressantes ici:

  • expected fn pointer, found closure clairement on retrourne une closure mais la fonction attend un pointeur de fonction.

  • closures can only be coerced to "fn" types if they do not capture any variables, le compilateur nous dis que c'est la capture de la variable qui provoque le passage d'un pointeur de fonction vers une closure.

  • Et nous avons même qu'est ce qui a été capturé. "bias" captured here.

Bon plus qu'à comprendre comment retourner la closure.

Le compilateur n'est malheureusement pas assez explicite sur la manière de résoudre la situation.

Donc dans le cas présent le seul moyen est de le savoir à l'avance. 😐

Pour renvoyer une closure d'une qui a pour signature fn(u8, u8) -> u8 , il faut renvoyer une implémentation du trait Fn(u8, u8) -> u8.

On reviendra en détail sur les implication de ce trait très en détail dans la partie suivante.

fn adder_with_bias(bias: u8) -> impl Fn(u8, u8) -> u8 {
    |x, y| x + y + bias
}

Hum, presque, l'erreur a changé au moins. 🤷‍♂️

error: closure may outlive the current function, but it borrows `bias`, which is owned by the current function

|x, y| x + y + bias
^^^^^^         ---- `bias` is borrowed here
|
may outlive borrowed value `bias`

help: to force the closure to take ownership of `bias`, use the `move` keyword

move |x, y| x + y + bias
++++

Décortiquons ce qui est raconté:

  • closure may outlive the current function : oui on la retourne donc normal

  • but it borrows "bias", which is owned by the current function : donc borrow checker

  • to force the closure to take ownership of "bias", use the "move" keyword : donc move modifie le comportement de la closure par rapport à la référence à bias et en prend l'ownership.

En Rust ce qui est copiable sera copié lorsqu'elle doit être déplacé.

Notre bias: u8 sera donc copié.

Une closure est définitivement bien plus qu'une fonction et semble avoir son propre contexte d'exécution.

Finalement cela nous donne la fonction suivante:

fn adder_with_bias(bias: u8) -> impl Fn(u8, u8) -> u8 {
    move |x, y| x + y + bias
}

Et ça compile enfin! 🎉

Référence et capture

Maintenant question:

Pourquoi cette implémentation fonctionne sans move?

fn main() {
    let bias_10 = 10;
    let adder = |x, y| x + y + bias_10;
    adder(5, 2) // 17
}

Alors que celle-ci échoue ?

fn main() {
    let adder = adder_with_bias(10);
    adder(5, 7)
}

fn adder_with_bias(bias: u8) -> impl Fn(u8, u8) -> u8 {
    |x, y| x + y + bias
}

Tout d'abord, il faut savoir que le comportement naturelle d'une closure est de référencé son environnement et non de le capturer.

On va également revenir sur ce terme de "capture"

Donc lorsque l'on écrit:

fn main() {
    let bias_10 = 10;
    let adder = |x, y| x + y + bias_10;
    adder(5, 2) // 17
}

C'est plus cela qui est réellement défini

fn main() {
    let bias_10 = 10;
    let adder = |x, y| x + y + &bias_10;
    adder(5, 2) // 17
}

Ici pas de souci, bias_10 est défini tout au long de la vie de adder.

missing alt

Revenons à notre retour de closure.

fn adder_with_bias(bias: u8) -> impl Fn(u8, u8) -> u8 {
    |x, y| x + y + bias
}

Est plutôt cette définition

fn adder_with_bias(bias: u8) -> impl Fn(u8, u8) -> u8 {
    |x, y| x + y + &bias
}

Vous sentez l'arnaque arriver ?

Le fameux problème de "on ne peut pas définir un élément et en renvoyer une référence"

fn fail<'a>() -> &'a u8 { // lifetime obligatoire
    let a = 12;
    &a // erreur de borrow : returns a reference to data owned by the current function
}

bias est défini dans le contexte de la fonction adder_with_bias. La closure vient référencer bias.

Mais, il y a un soucis, la closure, sera appelée dans main, après le retour de adder_with_bias.

Or, bias n'existe plus à ce moment là.

missing alt

Lorsque la closure est déplacé dans le main.

La référence &bias est donc désormais invalide et le Borrow Checker interdit ces situations.

missing alt

Bon très bien, et ça change quoi de rajouter un move devant la closure ?

fn adder_with_bias(bias: u8) -> impl Fn(u8, u8) -> u8 {
    move |x, y| x + y + bias
}

Le move modifie le comportement de la closure, au lieu de référencer son environnement, il va se l'apporoprier, le capturer.

Cela se manifest par le déplacement de bias dans la closure (ici on copie car u8 est copiable).

missing alt

Lorsque la closure arrive dans le contexte de main, l'original de bias est déjà mort, mais ce n'est pas grave, nous avons conservé une copie.

La closure peut "s'auto-référencer" pour récupérer bias. Le Borrow Checker est content. 😄

missing alt

Fabriquer nos closures à la main

Je sais pas vous mais moi, ce mélange de fonctions et de données qui s'auto-référence, ça me rappelle fortement un objet et donc une classe.

Nous sommes en Rust, donc ça sera une structure et une implémentation mais le concept sera le même à l'arrivé.

struct ClosureAdderWithBias {
    bias: u8
}

impl ClosureAdderWithBias {
    pub fn call(&self, x: u8, y: u8) -> u8 {
        x + y + self.bias
    }
}

On peut alors créer une méthode qui en retourne une instance

fn adder_with_bias(bias: u8) -> ClosureAdderWithBias {
    ClosureAdderWithBias { bias }
}

Que l'on utilise ainsi

fn main() {
    let closure_with_bias_10 = adder_with_bias(10);
    let closure_with_bias_20 = adder_with_bias(20);
    closure_with_bias_10.call(5, 2); // 17
    closure_with_bias_10.call(4, 2); // 16
    closure_with_bias_20.call(4, 2); // 26
}

Sympa non ? 🤩

Et on peut également émuler le fonctionnement de la closure sans move.

fn main() {
    let bias_10 = 10;
    let adder = |x, y| x + y + &bias_10;
    adder(5, 2) // 17
}

Au lieu de créer une structure qui prend la propriété, nous allons juste référencer.

struct ClosureAdder<'a> {
    bias: &'a u8,
}

impl ClosureAdder<'_> {
    pub fn call(&self, x: u8, y: u8) -> u8 {
        x + y + self.bias
    }
}

La lifetime 'a commence à nous montrer pourquoi le Borrow Checker n'a pas été content tout à l'heure ^^

On peut alors l'utiliser ainsi 👇

fn main() {
    let bias_10 = 10;
    let adder_with_bias_10 = ClosureAdder { bias: &bias_10 };
    adder_with_bias_10.call(5, 2); // 17
}

Et maintenant la mutation pour en finir.

fn main() {
    let mut a = 10;
    let mut twice = || *&mut a *= 2;
    twice();
    dbg!(a); // 20
}

Cela nous donne alors cette implémentation par structure qui prend en paramètre une référence mutable.

struct ClosureTwice<'a> {
    value: &'a mut u8,
}

impl ClosureTwice<'_> {
    pub fn call(&mut self) {
        *self.value *= 2
    }
}

À l'utilisation

fn main() {
    let mut a = 10;
    let mut twice = ClosureTwice { value: &mut a };
    twice.call();
    dbg!(a); // 20
}

FnOnce, Fn, FnMut

Implémentation

C'est pas mal mais on sent qu'il manque quelque chose.

adder_with_bias retourne un impl Fn pas une structure custom.

fn adder_with_bias(bias: u8) -> impl Fn(u8, u8) -> u8 {
    move |x, y| x + y + bias
}

Mais du coup, pourquoi ne pas implémenter Fn sur notre ClosureAdderWithBias?

Alors oui mais c'est un peu, obscur ^^'

Vous n'êtes pas sensé être là et le langage vous le fait sentir. 👿

Déjà vous avez l'obligation de passer en nightly. 🌜

rustup default nightly

Ensuite, il faut rajouter en haut du fichier cette directive

#![feature(unboxed_closures, fn_traits)]

Puis la signature de l'implémentation est bizarre.

Dans notre cas cela donne:

impl Fn<(u8, u8)> for ClosureAdderWithBias {
    extern "rust-call" fn call(&self, args: (u8, u8)) -> Self::Output {
        todo!()
    }
}
  • On voit le Fn<(u8, u8)> qui correspond à la signature des paramètres d'entrées de la closure.

  • De la magie noire extern "rust-call".

  • Un paramètre args: (u8, u8) qui aussi a la même signature.

  • Et finalement Self::Output que l'on ne peut pas définir

Sauf que Fn est supertrait de FnMut

trait Fn<Args: Tuple>: FnMut<Args>

Et FnMut est le supertrait de FnOnce

trait FnMut<Args: Tuple>: FnOnce<Args>

Implémentons tout le monde

struct ClosureAdderWithBias {
    bias: u8,
}

impl FnMut<(u8, u8)> for ClosureAdderWithBias {
    extern "rust-call" fn call_mut(&mut self, args: (u8, u8)) -> Self::Output {
        todo!()
    }
}

impl FnOnce<(u8, u8)> for ClosureAdderWithBias {
    type Output = u8; // notre Self::Output est définissable ici :)

    extern "rust-call" fn call_once(self, args: (u8, u8)) -> Self::Output {
        todo!()
    }
}

impl Fn<(u8, u8)> for ClosureAdderWithBias {
    extern "rust-call" fn call(&self, args: (u8, u8)) -> Self::Output {
        todo!()
    }
}

On peut alors implémenter la logique de la closure

impl Fn<(u8, u8)> for ClosureAdderWithBias {
    extern "rust-call" fn call(&self, args: (u8, u8)) -> Self::Output {
        args.0 + args.1 + self.bias
    }
}

Le fait d'utiliser les vrais outils du langage nous ouvre la porte à deux choses:

Premièrement, nous n'avons plus besoin d'appeler explicitement la méthode ClosureAdderWithBias::call.

fn main() {
    let closure_with_bias_10 : ClosureAdderWithBias = adder_with_bias(10);
    // appel explicite
    dbg!(closure_with_bias_10.call((5, 2))); // 17
    // surcre syntaxique
    dbg!(closure_with_bias_10(5, 2)); // 17
}

Deuxièmement, ClosureAdderWithBias implémentant Fn(u8, u8) -> u8, nous pouvons en faire un type opaque.

fn adder_with_bias(bias: u8) -> impl Fn(u8, u8) -> u8 {
    ClosureAdderWithBias { bias }
}

Notre structure est une closure !! 🎉🎉🤩😎

Les différences

Pourquoi trois traits différents ?

Pour répondre à cette question, analysons les signatures des méthodes implémentées.

fn call(&self, args: Args) -> Self::Output;
fn call_mut(&mut self, args: Args) -> Self::Output;
fn call_once(self, args: Args) -> Self::Output;

Nous avons trois méthodes qui ont des signatures semblables mais qui diffèrent par un élément:

  • call utilise une référence immutable &self, elle peut être rejouée autant de fois que l'on veut sans se soucier de l'état, elle est pure

  • call_mut possède une référence mutable &mut self, elle peut être rappelée autant de fois que l'on veut mais peut muter son état interne

  • call_once a un passage par valeur de self, à la fin de l'appel, la structure sera détruite et donc la méthode ne peut pas être rappellée

Voyons les différences

// lifetime '_ obligatoire
// elle indique que la valeur modifiée doit exister
// au moins aussi longtemps que la closure
fn twice(value: &mut u8) -> impl FnMut() + '_ {
    // le move est optionnel car &mut est forcément unique
    // donc le compilateur ne se pose pas de question et deplace
    // le &mut dans la closure
    || *value *= 2
}

fn display(data: u8) -> impl FnOnce() {
    move || {
        dbg!(data); // data est consommé ici
    }
}

// lifetime '_ obligatoire
// la référence doit exister aussi longtemps que la closure
// car la closure possède une copie de cette référence
// pas la valeur elle-même
fn adder_with_bias(bias: &u8) -> impl Fn(u8, u8) -> u8 + '_ {
    // le move force la copie de la référence
    move |x: u8, y: u8| x + y + bias
}

Commençons avec la mutabilité.

fn main() {
    let mut value = 10;
    // on remarque l'obligation du "let mut"
    let mut twice_closure = twice(&mut value);
    twice_closure();
    twice_closure();
    twice_closure();
    // on se débarrasse de la closure pour pouvoir borrow
    drop(twice_closure);
    dbg!(&value); // 80
}

Continuons avec le call_once

fn main() {
    let data = 42;
    let display_closure = display(data);
    display_closure(); // 42
    display_closure(); // erreur : value used here after move
}

Et finalement par où tout à commencé

fn main() {
    let bias = 10;
    let adder_with_10 = adder_with_bias2(&bias);
    dbg!(adder_with_10(5,2)); // 17
    dbg!(adder_with_10(4,2)); // 16
}

Petit dessin pour résumer l'oignon de super-traits et les différents contextes capturable.

missing alt
  • fn : est un pointeur de fonction défini à l'avance, il ne prend pas de contexte
  • Fn : peut lire des référence de contexte
  • FnMut : peut modifier des référence de contexte
  • FnOnce : prend les droits de propriété du contexte

Async

Dernier truc avant de nous quitter.

Vous avez surement vu des closures qui ressemble à

let future1 = || async { 42 };

Et bien c'est simplement un double sucre syntaxique

let future2 = || -> Ready<u8> { future::ready(42) };

Qui devient

struct ClosureFuture;

impl FnMut<()> for ClosureFuture {
    extern "rust-call" fn call_mut(&mut self, args: ()) -> Self::Output {
        todo!()
    }
}

impl FnOnce<()> for ClosureFuture {
    type Output = future::Ready<u8>;

    extern "rust-call" fn call_once(self, args: ()) -> Self::Output {
        todo!()
    }
}

impl Fn<()> for ClosureFuture {
    extern "rust-call" fn call(&self, args: ()) -> Self::Output {
        future::ready(42_u8)
    }
}

Attention

Les closures asynchrones existent mais ne sont pas stables!

async || { 42 }

Leurs implémentation resemblerait à cela

struct AsyncClosure;

impl AsyncClosure {
    async fn call(&self) -> u8 {
        42
    }
}

Or les traits async ne sont pas encore stabilisés ce qui rend l'existence des closures async pour le moment impossible.

Mais si vous voulez faire de la black magic, vous pouvez avec cette crate.

Conclusion

On a vu que le pointeur de fonction est une fonction tout à fait classique, tous les paramètres doivent être passés explicitement par copie ou par référence.

Un pointeur de fonction ne capture pas d'environnement, car c'est une routine de code quelque part en mémoire.

Une closure n'est rien d'autre qu'une structure qui implémente Fn, FnOnce ou FnMut en fonction des besoins.

La sensation de capture de l'environnement, n'est du qu'à un sucre syntaxique du langage qui vient nous faciliter la vie.

Par défaut, une closure ne fait que référencer son environnement, il est possible de lui donner le droit de propriété en spécifiant explicetement que l'on désire le faire avec l'opérateur move.

Merci de votre lecture, n'hésitez pas à le partager s'il vous a plu ❤️

avatar

Auteur: Akanoa

Je découvre, j'apprends, je comprends et j'explique ce que j'ai compris dans ce blog.

Ce travail est sous licence CC BY-NC-SA 4.0.