Les closures
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!
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.
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 _ = 0x00000010baaff410
Si l'on veut non pas l'addresse de
closure
mais l'adresse du code de la closure pointé par la variableclosure
on peut écrire quelque chose comme celadbg!; // 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 = ;
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() = 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() = 42
dbg!; // universal_answer() = 42
dbg!; // 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 = ;
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) = 42
On peut également la typer explicitement:
let identity : fn = ;
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 = ;
Il est même possible de ne pas donner explicitement le type de retour et laisser Rust se débrouiller.
let identity : fn = ;
À l'inverse le type de retour peut être défini dans la déclaration du la closure.
let identity = ; // 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 = ; // je laisse Rust typer comme un grand
Nous avons maintenant une closure qui ajoute 10 à ce que l'on lui donne entré.
dbg!; // 11
dbg!; // 1010
dbg!; // 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 serau8
, si c'est l'inverse, ça serau64
.let add_10 = ; let integer_u64: u64 = add_10; let integer_u8: u8 = add_10;
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 = ;
Paramètres multiples
Et pourquoi pas deux paramètres ?
let add = ;
Maintenant nous commençons avoir quelque chose d'intéressant.
dbg! // add(5, 2) = 7
On peut alors créer des comportement différents.
let mul = ;
dbg! // 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.
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 = ;
let mul = ;
run_closure; // 7
run_closure; // 10
Vous pouvez même vous abstenir de définir la variable tout court
run_closure; // 7 run_closure; // 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
La forme fonction locale
Ou même la forme, fonction classique
Pour le moment toutes ces écritures sont équivalentes.
En effet nous pouvons écrire ceci et c'est parfaitement valide.
run_closure;
run_closure;
run_closure;
run_closure;
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 demain
). - La fonction local n'a d'existence que dans le
main
et ne peut donc pas être appelé de l'extérieur demain
, 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.
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.
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 = ;
dbg!; // 27
On remarque ici, que Rust permet d'additionner des
u8
et des&u8
grâce à l'implémentation par défaut ci-dessous// et
Si on essaie de faire la même chose avec la fonction locale, nous avons déjà un souci.
let a = 20;
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 = ; // 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 = ;
double_ref;
dbg!; // 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 = ;
let mut triple_ref = ;
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
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 = ;
let mut triple_ref = ;
dbg!; // 2
Et alors les lignes de vie ne se croisent jamais.
Soit on place astucieusement nos appels et déclaration pour ne pas tomber hors la loi;
let mut a = 2;
let mut double_ref = ;
double_ref;
let mut triple_ref = ;
triple_ref;
dbg!; // 12
Là encore pas de croisement.
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 = ;
let mut double_ref = ;
add_with_ref_value;
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.
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 = ;
let add_with_ref_value = ;
dbg!; // 9
dbg!; // 2
Les lignes de vie ne croisant plus, le borrow checker est heureux. 😇
Maintenant, si nous voulons appeler les deux closures, nous allons avoir un souci.
let mut a = 2;
let mut double_ref = ;
let add_with_ref_value = ;
add_with_ref_value;
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
.
Intervertir les appels des closures.
Ou les déclarations des closures.
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 = ;
double_ref;
let add_with_ref_value = ;
dbg!; // 11
dbg!; // 4
Soit dans l'autre
let mut a = 2;
let add_with_ref_value = ;
dbg!; // 9
let mut double_ref = ;
double_ref;
dbg!; // 4
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;
let adder_bias_20 = adder_with_bias;
adder_bias_10; // 17
adder_with_bias; // 19
dder_bias_20; // 27
Vous voyez un peu l'idée. 🙂
En voici une implémentation.
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.
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 normalbut it borrows "bias", which is owned by the current function
: donc borrow checkerto force the closure to take ownership of "bias", use the "move" keyword
: doncmove
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:
Et ça compile enfin! 🎉
Référence et capture
Maintenant question:
Pourquoi cette implémentation fonctionne sans
move
?
Alors que celle-ci échoue ?
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:
C'est plus cela qui est réellement défini
Ici pas de souci, bias_10
est défini tout au long de la vie de adder
.
Revenons à notre retour de closure.
Est plutôt cette définition
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"
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à.
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.
Bon très bien, et ça change quoi de rajouter un
move
devant la closure ?
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).
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. 😄
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é.
On peut alors créer une méthode qui en retourne une instance
Que l'on utilise ainsi
Sympa non ? 🤩
Et on peut également émuler le fonctionnement de la closure sans move
.
Au lieu de créer une structure qui prend la propriété, nous allons juste référencer.
La lifetime 'a
commence à nous montrer pourquoi le Borrow Checker n'a pas été content tout à l'heure ^^
On peut alors l'utiliser ainsi 👇
Et maintenant la mutation pour en finir.
Cela nous donne alors cette implémentation par structure qui prend en paramètre une référence mutable.
À l'utilisation
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.
Mais du coup, pourquoi ne pas implémenter
Fn
sur notreClosureAdderWithBias
?
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
Puis la signature de l'implémentation est bizarre.
Dans notre cas cela donne:
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
Et FnMut
est le supertrait de FnOnce
Implémentons tout le monde
On peut alors implémenter la logique de la closure
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
.
Deuxièmement, ClosureAdderWithBias
implémentant Fn(u8, u8) -> u8
, nous pouvons en faire un type opaque.
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.
;
;
;
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 purecall_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 internecall_once
a un passage par valeur deself
, à 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
+ '_
// 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
+ '_
Commençons avec la mutabilité.
Continuons avec le call_once
Et finalement par où tout à commencé
Petit dessin pour résumer l'oignon de super-traits et les différents contextes capturable.
fn
: est un pointeur de fonction défini à l'avance, il ne prend pas de contexteFn
: peut lire des référence de contexteFnMut
: peut modifier des référence de contexteFnOnce
: 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 = ;
Et bien c'est simplement un double sucre syntaxique
let future2 = ;
Qui devient
;
Attention
Les closures asynchrones existent mais ne sont pas stables!
async ||
Leurs implémentation resemblerait à cela
;
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 ❤️
Ce travail est sous licence CC BY-NC-SA 4.0.