ES3, Chap 6. — Les fermetures en JavaScript
Ce billet fait partie de la collection ES3 dans le détail et en constitue le Chapitre 6.
Dans cet article nous allons parler d'un des sujets le plus souvent questionné en JavaScript : les fermetures (« closures »).
Introduction
Ce sujet n'est pas nouveau et a été abordé maintes fois. Nous allons cependant essayer de l'aborder d'un point de vue théorique dans un premier temps et voir ensuite comment le JavaScript s'en occupe techniquement.
Il serait intéressant d'avoir pris connaissance en amont des deux chapitres précédents dédiés à la chaîne des portées et à l'objet des variables qui aideront à la compréhension du présent chapitre sans pour autant être indispensables à la compréhension globale.
Théorie générale
Avant d'aborder la discussion sur les fermetures en JavaScript, il semble important de définir un certain nombre de concepts de la théorie générale de la programmation fonctionnelle.
Comme vous le savez peut-être, dans les langages de programmation fonctionnelle (et JavaScript en est un), les fonctions sont des données. C.-à-d. qu'elles peuvent être affectées à des variables, passées en tant qu'arguments dans les paramètres des fonctions ou être retournées par les fonctions. Ces fonctions ont des noms et des structures spéciales.
Définitions
Un argument fonctionnel (« functionnal argument » ou « funarg ») est un paramètre de fonction dont la valeur est elle-même une fonction.
Code JavaScript
// création et déclaration d'un paramètre fonctionnel `funarg`
function umbrella(funarg) {
funarg();
}
// appel et définition d'un argument fonctionnel `functionnalArgument`
umbrella(function functionnalArgument() {
console.log('corporation');
});
L'argument fonctionnel de l'exemple ci-dessus est une expression de fonction passée à la fonction umbrella.
La fonction qui reçoit comme paramètre l'argument fonctionnel est appelée une fonction d'ordre supérieur (« higher-order function »).
Un autre nom donné à une fonction d'ordre supérieur est fonction fonctionnelle ou, plus près des mathématiques, un opérateur. Dans l'exemple ci-dessus, umbrella est donc une fonction d'ordre supérieur.
Comme mentionné plus haut, un argument fonctionnel peut-être non seulement passé à travers les paramètres des fonctions appelées, mais également retourné comme valeur par une autre fonction.
Une fonction qui retourne une autre fonction est appelée une fonction avec valeur fonctionnelle (« _function-valued function »)_.
Code JavaScript
(function functionValued() {
return function () {
console.log("appel d'une fonction retournée");
};
})()();
Une fonction pouvant être utilisée comme une donnée standard (c.-à-d. être passée en tant qu'argument ou recevoir des arguments fonctionnels ou encore être retournée en tant que valeur fonctionnelle) est appelée une fonction de première classe.
En JavaScript, toutes les fonctions sont des fonctions de première classe.
Une fonction se recevant elle-même via un argument, est une fonction auto-applicative (« self-applicative ») :
Code JavaScript
(function selfApplicative(funarg) {
if (funarg && funarg === selfApplicative) {
console.log('auto-applicative');
return;
}
selfApplicative(selfApplicative);
})();
et une fonction qui se retourne elle-même est, quand à elle, appelée une fonction auto-réplicative (« self-replicative ») :
Code JavaScript
(function selfReplicative() {
console.log('auto-réplicative');
return selfReplicative;
})();
L'un des motifs les plus intéressants des fonctions auto-réplicatives est leur forme déclarative fonctionnant avec un seul argument de collection au lieu d'accepter la collection elle-même :
Code JavaScript
// fonction impérative qui // accepte une collection function starsList(modes) { modes.forEach(function (mode) { console.log(mode); }); } // et s'utilisant ainsi starsList(['jill', 'chris', 'barry']); // `jill` // `chris` // `barry` // vs // forme déclarative des // fonction auto-replicative function stars(mode) { console.log(mode); return stars; // on retourne la fonction elle-même } // s'utilisant en *déclarant* les S.T.A.R.S stars ('jill') ('chris') ('barry');
Cependant, dans la pratique, travailler avec des collections sera plus efficient et intuitif.
Les variables locales qui sont définies dans les arguments fonctionnels passés sont bien sûr accessibles lors de l'activation (appel) de la fonction. Et cela grâce à un objet des variables qui stocke les données du contexte est créé à chaque fois en entrant dans le contexte :
Code JavaScript
function umbrella(funarg) {
// activation locale de funarg
// la variable `localVar` est disponible
// à l'intérieur de `funarg`
funarg(10); // `20`
funarg(20); // `30`
}
umbrella(function (localParam) {
var localVar = 10;
console.log(localParam + localVar);
});
Cependant, comme nous l'avons vu dans le chapitre précédent sur sur la chaîne des portées, les fonctions en JavaScript peuvent être encapsulées dans des fonctions parentes et utiliser des variables depuis des contextes parents. Quand une variable utilisée dans une fonction provient d'une fonction parente, cela conduit à une particularité connue sous le nom de problème de l'argument fonctionnel (« Funarg problem »).
Problème de l'argument fonctionnel
Dans un langage de programmation avec pile d'exécution, les variables locales des fonctions sont stockées dans la pile (« stack »). Cette pile est augmentée chaque fois qu'une fonction est appelée et ses variables et arguments sont stockés dans le niveau ajouté.
Après retour d'une fonction, les variables et arguments de cette fonction sont supprimés de la pile en même temps que le niveau associé. Ce modèle est une grosse restriction pour l'utilisation de fonctions en tant que valeurs fonctionnelles puisque la fonction qui stockait la valeur retournée n'est plus dans la pile.
Ce problème apparaît le plus souvent quand une fonction utilise des variables libres.
Une variable libre est une variable qui est utilisée par une fonction mais qui n'est, ni un paramètre de la fonction, ni une variable locale de la fonction.
Code JavaScript
function biohazard() {
var freeVar = 10;
function stars(localParam) {
console.log(localParam + freeVar);
}
return stars;
}
var residentEvil = biohazard();
residentEvil(20); // `30`
Dans cet exemple, la variable freeVar est libre pour la fonction stars.
Si ce système utilisait un modèle avec pile d'exécution pour stocker les variables locales, cela signifierait qu'au retour de la fonction biohazard toutes les variables auraient été supprimées de la pile. Et cela aurait causé une erreur lors de l'activation de stars depuis l'extérieur.
Cependant pour ce cas particulier, dans une implémentation orientée pile, retourner la fonction stars n'aurait pas été possible du tout, puisque stars est aussi locale à biohazard et aurait donc également été supprimée au retour de la fonction biohazard.
Un autre problème avec les objets fonctionnels est lié au passage de fonctions en tant qu'arguments dans un système avec une implémentation de portée dynamique.
Pseudo-code
var z = 10
function zombi() {
console.log(z)
}
zombi() // `10` avec une portée statique ou une portée dynamique
(function () {
var z = 20
zombi() // `10` avec une portée statique, `20` avec une portée dynamique
})()
// et la même chose en passant `zombi`
// en tant qu'argument du premier paramètre `funarg`
(function (funarg) {
var z = 30
funarg() // `10` avec une portée statique, `30` avec une portée dynamique
})(zombi)
Nous voyons que dans un système avec une portée dynamique, la résolution des variables est gérée grâce à une pile dynamique de variables. Donc les variables libres sont cherchées dans la chaîne dynamique de l'activation courante, mise en place lors de la phase d'appel de la fonction, et non dans une portée (lexicale) statique qui est créée lors de la phase de création de la fonction.
Donc même si z existe (contrairement à l'exemple précédent où la variable locale aurait été supprimée de la pile), une question se pose : quelle valeur de z (c.-à-d. z depuis quel contexte, depuis quelle portée) devrait être utilisée dans les différents appels de la fonction zombi ?
Ces deux cas de figure lèvent deux types de problème :
- comment faire fonctionner les valeurs fonctionnelles retournées depuis des fonctions (« upward funarg ») et
- comment faire fonctionner les arguments fonctionnelles passés à des fonctions (« downward funarg »).
Pour résoudre ces problèmes (et leurs variantes), le concept de fermeture a été proposé.
Une fermeture
Une fermeture (« closure ») est la combinaison d'un pend de code et des données du contexte dans lequel ce pend de code est créé.
Imageons cela avec le pend de code suivant :
JavaScript
var t = 20;
function mansion() {
console.log(t); // variable libre `t` valant `20`
}
et les données du contexte :
Pseudo-code
// Fermeture pour le pend de code `mansion`
mansionClosure = {
call: mansion, // référence à la fonction
lexicalEnvironment: { t: 20 } // contexte pour la recherche de variables libres
}
Même si le mot « lexical » est souvent omis quand on parle de portée lexicale, le fait est qu'une fermeture sauve les variables parentes dans un champ lexical dédié au pend de code, là où il est défini. Lors des prochaines activations de ce code, les variables libres seront cherchées à travers ce contexte lexical fermé (et ainsi comme nous l'avons vu dans l'exemple plus haut, la variable z sera toujours résolue à 10 pour une portée statique).
Dans la définition nous avons utilisé le terme générique de « pend de code » mais celui-ci peut être plus précis en fonction du langage. En JavaScript par exemple, le terme est complètement remplaçable par « fonction ». Mais cela n'est pas nécessairement le cas de tous les langages, comme par exemple en Ruby, où les fermetures peuvent être appliquées à autre chose que des fonctions.
Comme type d'implémentation pour le stockage de variables après la destruction d'un contexte, l'implémentation basée sur une pile ne convient pas du tout (car cela contredit la définition d'une structure basée sur une pile). Dans ce cas, les données gérées par la fermeture d'un contexte parent peuvent être sauvée dynamiquement en mémoire comme dans un tas (« heap »), c.-à-d. dans une implémentation basée sur un tas. Une telle implémentation utilise un ramasse-miettes (« garbage collector ») et des références par comptage. Ces systèmes sont moins rapides que les systèmes basés sur une pile. Cependant, les implémentations peuvent toujours optimiser cela en vérifiant lors de la phase d'analyse d'une fonction quelles variables libres sont utilisées et décider en fonction de cela de les placer dans une pile ou dans un tas.
Les fermetures en JavaScript
Maintenant que nous avons discuté de la théorie, nous allons parler des fermetures (« closures ») spécifiquement dans le contexte du JavaScript. Il est nécessaire de noter ici que le JavaScript utilise uniquement une portée (lexicale) statique (alors que dans certains langages, comme en Perl, les variables peuvent être déclarées en utilisant des portées dynamiques ou statiques).
Code JavaScript
var g = 10;
function virus() {
console.log(g);
}
(function (funarg) {
var g = 20;
// la variable `g` pour le `funarg` est sauvée de manière statique
// dans le contexte lexical dans lequel elle est créée
funarg(); // `10`, mais pas `20`
})(virus);
Techniquement, les variables d'un contexte parent sont sauvées dans la propriété interne [[Scope]] de la fonction. Aussi si vous souhaitez complètement comprendre [[Scope]] et la chaîne des portées, cela a été discuté en détail dans le chapitre 4. Ainsi les problématiques de fermetures devraient disparaître d'elles-même.
En s'appuyant sur l'algorithme de création des fonctions, nous voyons que toutes les fonctions en JavaScript sont des fermetures, puisque toutes créent une chaîne des portées du contexte parent. Ce qu'il faut retenir étant qu'au moment ou une fonction est activée (appelée), la portée parente est déjà sauvée depuis le moment de sa création.
JavaScript
var g = 10;
function virus() {
console.log(g);
}
Pseudo-code
// `virus` est une fermeture
virus = virusFunctionObject = {
[[Call]]: <pend de code de `virus`>,
[[Scope]]: [{
g: 10
}, // === `GO`
<...> // autres propriétés
}
Pour des questions d'optimisations, quand une fonction n'utilise pas de variables libres, l'implémentation peut ne pas sauver de chaîne des portées. Cependant rien n'est mentionné à ce propos dans la spécification ECMA-262-3. C'est donc une liberté prise au niveau de l'implémentation (et par l'algorithme technique) car « toutes les fonctions sauvent une chaîne des portées dans leur propriété interne [[Scope]] lors de la création ».
Plusieurs implémentations permettent un accès direct à cette portée fermée. Par exemple dans Rhino, une propriété non standard __parent__ correspond à la propriété interne [[Scope]][0] dont nous avons discuté dans le chapitre sur l'objet des variables :
Code JavaScript
var GO = this;
var t = 10;
var virus = (function () {
var g = 20;
return function () {
console.log(g);
};
})();
virus(); // `20`
console.log(virus.__parent__.g); // `20`
virus.__parent__.g = 30;
virus(); // `30`
// Nous pouvons nous déplacer à travers la chaîne des portées jusqu'au bout
console.log(virus.__parent__.__parent__ === GO); // `true`
console.log(virus.__parent__.__parent__.t); // `10`
Une valeur [[Scope]] pour tous
Il est nécessaire de noter que la propriété fermée [[Scope]] en JavaScript est le même objet pour plusieurs des fonctions internes créées dans le contexte parent. Cela signifie que la modification d'une variable dans une fermeture va se refléter lors de la lecture de cette variable depuis une autre fermeture.
En clair : toutes les fonctions internes partagent la même chaîne des portées parente.
Code JavaScript
var tyran;
var nemesis;
function weapons() {
var x = 1;
tyran = function () { return ++x; };
nemesis = function () { return --x; };
x = 2; // affectation de AO(<weapons>)[`x`], qui est dans la propriété `[[Scope]]` de chaque fermeture
console.log(tyran()); // `3`, via tyran.[[Scope]]
}
weapons();
console.log(tyran()); // `4`, via tyran.[[Scope]]
console.log(nemesis()); // `3`, via nemesis.[[Scope]]
Il y a une erreur très répandue liée à cette fonctionnalité. Souvent les développeurs obtiennent un résultat qu'ils n'attendaient pas quand ils créent des fonctions dans des boucles, essayant d'associer à chaque fonction de la boucle une variable de comptage, s'attendant à ce que chaque fonction garde sa « propre » valeur.
Code JavaScript
var data = [];
for (var k = 0; k < 3; k++) {
data[k] = function () {
var x = true;
console.log(k);
};
}
data[0](); // `3`, et pas `0`
data[1](); // `3`, et pas `1`
data[2](); // `3`, et pas `2`
Ce comportement est expliqué par l'exemple précédent. Une même portée liée au contexte est créée pour ces trois fonctions. Chaque fonction la référence dans sa propriété interne [[Scope]], et la variable k depuis cette portée parente peut-être facilement changée.
Regardez plutôt ce qu'il se passe étape par étape pour la chaîne des portées :
Phase d'entrée dans le contexte global :
Pseudo-code
// activeGlobalContext.Scope = activeGlobalContext.AO
activeGlobalContext.Scope = [
{ data: <référence à `data`>, k: undefined }
]
Phase d'exécution du contexte global :
Pseudo-code
// activeGlobalContext.Scope = activeGlobalContext.AO
activeGlobalContext.Scope = [
{ data: [<...>], k: 3 }
]
Phase d'entrée de la fonction data[1] (par exemple) :
Pseudo-code
// activeFunctionContext.Scope = activeFunctionContext.AO + data[1].[[Scope]]
activeFunctionContext.Scope = [
{ x: undefined }, // objet d'activation courant `AO`
{ data: [<...>], k: 3 } // l'objet global (`activeFunctionContext.AO`)
]
Phase d'exécution de la fonction data[1] (par exemple) :
Pseudo-code
// activeFunctionContext.Scope = activeFunctionContext.AO + data[1].[[Scope]]
activeFunctionContext.Scope = [
{ x: true },
{ data: [<...>], k: 3 }
]
// c.-à-d
data[1].[[Scope]].k === globalContext.Scope.k === `3`
Au moment de l'activation de la fonction, c'est la dernière valeur de k affectée qui s'affiche, c.-à-d. 3.
Cela est dû au fait que toutes les variables sont créées avant l'exécution du code, c.-à-d. lors de la phase d'entrée dans le contexte. Ce comportement est aussi connu sous le nom de hissage (« hoisting ») de variable.
La création d'un contexte fermé additionnel peut résoudre ce problème :
Code JavaScript
var data = [];
for (var k = 0; k < 3; k++) {
data[k] = (function helper(x) {
return function () {
console.log(x);
};
})(k); // passage de la valeur `k` pour chaque objet des variables
}
// maintenant c'est le résultat souhaité
data[0](); // `0`
data[1](); // `1`
data[2](); // `2`
Regardons ce qu'il se passe dans ce cas.
En premier lieu, la fonction helper est créée et immédiatement activée avec comme premier argument k.
Puis, la valeur retournée par la fonction helper est aussi une fonction, contenant les éléments du tableau data original.
Cette technique produit les effets suivants : à l'activation, helper crée à chaque fois un nouvel objet d'activation qui contient le paramètre x, et la valeur de ce paramètre est la valeur de l'argument k passé.
Donc, les propriétés [[Scope]] de chaque fonction retournée sont les suivantes lors de la phase d'exécution :
Pseudo-code
data[0].[[Scope]] === [
{ x: 0 },
{ data: [<...>], k: 3 }
]
data[1].[[Scope]] === [
{ x: 1 },
{ data: [...], k: 3 }
]
data[2].[[Scope]] === [
{ x: 2 },
{ data: [...], k: 3 }
]
Nous voyons maintenant que la propriété [[Scope]] des fonctions a une référence sur la valeur souhaitée via la variable x qui est capturée par la portée additionnelle créée.
Notons que les fonctions retournées gardent toujours leur référence à la variable k. La variable k garde toujours à travers toutes les fonctions la valeur 3.
Parfois les fermetures JavaScript incomplètes sont réduites au motif montré plus haut, avec la création de fonctions additionnelles de capture des valeurs désirées. D'un point de vue pratique, ce motif se doit d'exister, mais d'un point de vue théorique comme mentionné : toutes les fonctions en JavaScript sont des fermetures, et pas seulement dans ce cas de figure.
Le motif décrit plus haut n'est pas le seul utilisable. Pour conserver la valeur souhaitée de la variable k il est aussi possible, par exemple, d'utiliser cette approche :
Code JavaScript
var data = [];
for (var k = 0; k < 3; k++) {
(data[k] = function () {
console.log(arguments.callee.x);
}).x = k; // sauver `k` en tant que propriété de la fonction
}
// et tout est encore correct
data[0](); // `0`
data[1](); // `1`
data[2](); // `2`
Notons que ES6 standardise la portée de structure, qui peut être mise en place en utilisant les mots clés let ou const en tant que déclaration de variable. L'exemple ci-dessus peut alors simplement être réécrit ainsi :
Code JavaScript
let data = []; for (let k = 0; k < 3; k++) { data[k] = function () { console.log(k); }; } // toujours des sorties correctes data[0](); // `0` data[1](); // `1` data[2](); // `2`
Argument fonctionnel et return
Une autre fonctionnalité est le retour des fermetures. En JavaScript, une instruction avec le mot-clé return dans une fermeture rend le contrôle de flux depuis un contexte appelant.
Voici un exemple pour comprendre le comportement standard de return en JavaScript :
Code JavaScript
function getElement() {
[1, 2, 3].forEach(function (element) {
if (element % 2 == 0) {
// retour de la fonction « forEach »,
// mais pas retour de la fonction `getElement`
console.log('trouvé: ' + element); // trouvé: 2
return element;
}
});
return null;
}
console.log(getElement()); // `null`, et non `2`
ainsi en JavaScript, lancer et attraper certaines exceptions peut aider :
Code JavaScript
function getElement() {
var $break = {};
try {
[1, 2, 3].forEach(function (element) {
if (element % 2 === 0) {
// « return » pour getElement depuis cette fermeture
console.log('trouvé: ' + element); // trouvé: 2
$break.data = element;
throw $break;
}
});
} catch (e) {
if (e === $break) {
return $break.data;
}
}
return null;
}
console.log(getElement()); // `2`
Théorie et exception
Comme nous l'avons noté, les développeurs réduisent souvent les fermetures à de simples fonctions internes retournées par leurs contextes parents. Ceci réduit les fermetures à être exploitables uniquement dans les fonctions anonymes.
Laissez-moi vous le dire à nouveau : toutes les fonctions , indépendamment de leur type (expressions nomées et anonymes ou déclarations), parce qu'elles possèdent une chaîne des portées, sont des fermetures.
Une exception à cette règle subsiste pour les fonctions créées via le constructeur Function dont la propriété [[Scope]] ne contient que l'objet global.
Aussi pour clarifier cette question, donnons deux versions des fermetures en JavaScript :
Les fermetures en JavaScript sont :
d'un point de vu théorique : toutes les fonctions, puisqu'elles sauvegardent à la création les variables du contexte parent. Même pour une simple fonction globale, qui référence une variable globale qui renvoie à une variable libre, le mécanisme de chaîne de portée est utilisé ;
d'un point de vu pratique : ces fonctions sont intéressantes car :
- elles continuent à exister quand leur contexte parent est terminé, c.-à-d. les fonctions encapsulées dans des fonctions parentes ;
- utilisent des variables libres.
Usage pratique des fermetures
Dans la pratique les fermetures permettent la création de structures élégantes, favorisant la personnalisation de différents calculs définis par un argument fonctionnel. En voici des exemples non exhaustifs.
Argument fonctionnel
Voici un exemple avec la méthode sort des tableaux qui accepte en tant que premier paramètre un argument fonctionnel de « condition de tri » :
Code JavaScript
[1, 2, 3].sort(function (a, b) {
// ... conditions de tri de votre choix
});
Voici un autre exemple avec la méthode find. Il est parfois intéressant d'utiliser des fonctions de recherche en utilisant des arguments fonctionnels définissant les conditions de recherche :
Code JavaScript
someCollection.find(function (element) {
return element.someProperty === 'condition de recherche';
});
Association fonctionnelle
Voici un exemple de ce que l'on appelle de l'association fonctionnelle (« mapping functionnals ») avec la méthode map d'un tableau. Celle-ci va associer à un nouveau tableau une valeur calculée à chaque élément :
Code JavaScript
[1, 2, 3].map(function (element) {
return element * 2;
}); // `[2, 4, 6]`
Boucle de fonction
Il est aussi intéressant d'autres fois d'appliquer les fonctions fonctionnelles, par exemple dans une méthode forEach qui applique des instructions pour chaque élément d'un tableau :
Code JavaScript
[1, 2, 3].forEach(function (element) {
if (element % 2 !== 0) {
console.log(element);
}
});
// affiche `1`
// affiche `3`
apply et call
Au passage, les méthodes de fonction apply et call utilisent également des arguments fonctionnels. Nous avons déjà discuté de ces méthodes dans une note à propos de la valeur de this mais ici, nous allons voir leurs rôles avec les arguments fonctionnels ou fonctions appliquées en tant qu'argument (à une liste d'arguments avec apply et des positions d'argument avec call) :
Code JavaScript
(function () {
console.log([].join.call(arguments, ';'));
}).apply(this, [1, 2, 3]);
// affiche `1;2;3`
Appel différés
Un autre point important des fermetures est la possibilité des appels différés (« deferred calls ») :
Code JavaScript
var a = 10;
setTimeout(function () {
console.log(a);
}, 1000);
// une seconde d'attente...
// ...puis affichaqe de `10`
Fonction de rappel
Plus simplement, un cas d'usage répandu des fermetures est celui des fonctions de rappel (« callback functions ») :
Code JavaScript
// ...
var x = 10;
xmlHttpRequestObject.onreadystatechange = function () {
// la fonction de rappel est appelée en différé,
// quand les données sont prêtes.
// La variable `x` est ici disponible indépendamment
// du fait que lorsque le contexte interne existe,
// l'exécution du code externe est déjà fini.
console.log(x); // `10`
};
// ...
Encapsulations privées
Les fermetures servent également à « masquer » des variables dans une portée encapsulante lors de l'exécution d'instructions :
Code JavaScript
var residentEvil = {};
// initialisation
(function (object) {
var veronica = 10;
object.getVirus = function _getVirus() {
return veronica;
};
})(residentEvil);
console.log(residentEvil.getVirus()); // retourne la valeur `veronica` enfermée : `10`
console.log(veronica); // « erreur : `veronica` n'est pas défini(e) »
Conclusion
Cet article vous en a dit plus à propos de la théorie générale des fermetures afin de mieux aborder son application en JavaScript même si le fait d'avoir étudié la chaîne des portées en amont nous a bien facilité la tâche. Nous entrerons prochainement dans le détail de la programmation orientée objet dans le domaine du JavaScript !
Références
Lectures additionnelles :
Ce texte est une libre adaptation française de l’excellent billet Тонкости ECMA-262-3. Часть 6. Замыкания. de Dmitry Soshnikov.