Cours JavaScript et TypeScript · Module 12: Programmation fonctionnelle

Les fermetures (closures) en programmation fonctionnelle

8 min de lecture
Dans cette leçon4

Un compteur d'incidents stocké dans une variable globale peut être modifié par n'importe quel bout de code : le script du rapport, un vieux module de sécurité, une faute de frappe d'un stagiaire. Quand le compteur affiche une absurdité, on ne sait pas à qui s'adresser. Il nous faut une variable à laquelle seules certaines fonctions choisies ont accès.

À Jurassic Park, chaque enclos a son propre système de sécurité : la tension de la clôture, le niveau d'alerte, le compteur d'incidents. Ces données sont privées et inaccessibles de l'extérieur, mais les fonctions qui gèrent l'enclos y ont pleinement accès. C'est exactement ainsi que fonctionnent les fermetures (closures) : elles créent pour les données un environnement privé, accessible uniquement via certaines fonctions.

Qu'est-ce qu'une fermeture ?

Une fermeture (closure) est une fonction qui « se souvient » des variables de la portée (scope) dans laquelle elle a été créée, même après que cette portée a cessé d'exister.

En JavaScript, chaque fonction mémorise au moment de sa création son environnement lexical, c'est-à-dire les variables visibles à l'endroit où elle a été écrite dans le code. L'exemple le plus simple est un compteur :

1function createCounter() {
2  let count = 0; // Variable privée - inaccessible de l'extérieur
3
4  return {
5    increment() { count++; return count; },
6    decrement() { count--; return count; },
7    getCount() { return count; },
8  };
9}
10
11const counter = createCounter();
12counter.increment(); // 1
13counter.increment(); // 2
14counter.getCount();  // 2
15// count est totalement privée - impossible d'y accéder directement

L'appel de createCounter est terminé depuis longtemps, et pourtant count est toujours vivante, car les trois méthodes gardent une référence vers elle. counter.count renverra undefined, car la variable n'est pas une propriété de l'objet, mais une partie de la fermeture. La fermeture se souvient de la variable elle-même, et non d'une copie de sa valeur, c'est pourquoi le deuxième increment voit le résultat du premier. Les méthodes du compteur ne sont pas pures, car elles modifient count, mais cette modification est enfermée en un seul endroit.

La confidentialité des données grâce aux fermetures

Les fermetures permettent de créer de véritables variables privées en JavaScript - c'est un motif clé de la programmation fonctionnelle. Construisons le système de sécurité d'un enclos en deux étapes. D'abord l'intérieur du coffre-fort, c'est-à-dire l'état privé et une fonction auxiliaire :

1function createSecuritySystem(zoneName) {
2  let fenceVoltage = 10000;
3  let alertLevel = 0;
4  let incidentLog = [];
5
6  // Fonction auxiliaire privée
7  function logEvent(event) {
8    incidentLog.push({ event, time: Date.now(), zone: zoneName });
9  }
10
11  // ...suite dans le bloc suivant

Les trois variables let et la fonction logEvent n'existent qu'à l'intérieur d'un seul appel de la fabrique. Nous ne les renvoyons pas, donc depuis l'extérieur, il est impossible de les lire comme de les modifier.

Vers l'extérieur ne sort qu'un objet doté de trois méthodes. C'est la seule porte du coffre-fort :

1  return {
2    raiseFence() {
3      fenceVoltage += 2000;
4      logEvent('Fence voltage raised to ' + fenceVoltage);
5      return fenceVoltage;
6    },
7    triggerAlert() {
8      alertLevel++;
9      logEvent('Alert level: ' + alertLevel);
10      return alertLevel;
11    },
12    getStatus() {
13      return {
14        zone: zoneName,
15        voltage: fenceVoltage,
16        alert: alertLevel,
17        incidents: incidentLog.length,
18      };
19    },
20  };
21}

Chaque méthode voit les variables privées et logEvent, mais décide elle-même de ce qu'elle rend accessible : getStatus donne le nombre d'incidents, et non le tableau du journal lui-même. Vérifions que deux enclos ont vraiment des coffres-forts séparés :

1const zoneA = createSecuritySystem('Zone A');
2const zoneB = createSecuritySystem('Zone B');
3
4zoneA.raiseFence();  // 12000
5zoneA.triggerAlert(); // 1
6zoneB.getStatus();   // { zone: 'Zone B', voltage: 10000, alert: 0, incidents: 0 }
7// Chaque zone a ses propres données, isolées

zoneA a augmenté la tension et déclenché l'alerte, et zoneB n'en sait rien, car chaque appel de la fabrique crée une nouvelle fermeture indépendante. Attention à une faille : si une méthode renvoyait le tableau incidentLog lui-même, le code extérieur recevrait une référence vers l'original et pourrait y ajouter des éléments avec push. Renvoie une copie, par exemple [...incidentLog]. Les classes ont des champs privés marqués du signe #, que tu connais grâce au module sur les classes, et les fermetures donnent le même effet sans classes.

Les fabriques de fonctions (Function Factories)

Les fermetures sont idéales pour créer des fabriques - des fonctions qui produisent des fonctions spécialisées. La plus petite fabrique accepte un seuil et renvoie un prédicat qui s'en souvient :

1function createFilter(minValue) {
2  return (item) => item.value >= minValue;
3}
4
5const isStrongSignal = createFilter(70);
6isStrongSignal({ value: 85 }); // true
7isStrongSignal({ value: 12 }); // false

Chaque appel de createFilter donne un nouveau filtre avec son propre seuil, prêt à être passé à la méthode filter. Une fabrique peut aussi enfermer tout un ensemble de règles. Object.entries transforme l'objet des règles en un tableau de paires [champ, règle], que parcourt la boucle for...of :

1// Fabrique de validateurs
2function createValidator(rules) {
3  return function validate(data) {
4    const errors = [];
5    for (const [field, rule] of Object.entries(rules)) {
6      const value = data[field];
7      if (rule.required && (value === undefined || value === '')) {
8        errors.push(field + ' is required');
9      }
10      if (rule.min !== undefined && value < rule.min) {
11        errors.push(field + ' must be >= ' + rule.min);
12      }
13      if (rule.max !== undefined && value > rule.max) {
14        errors.push(field + ' must be <= ' + rule.max);
15      }
16    }
17    return { valid: errors.length === 0, errors };
18  };
19}
20
21const validateDinosaur = createValidator({
22  name: { required: true },
23  weight: { required: true, min: 1, max: 100000 },
24  health: { required: true, min: 0, max: 100 },
25});
26
27validateDinosaur({ name: 'Rex', weight: 8000, health: 95 });
28// { valid: true, errors: [] }
29
30validateDinosaur({ name: '', weight: -5, health: 150 });
31// { valid: false, errors: ['name is required', 'weight must be >= 1', 'health must be <= 100'] }

Remarque les comparaisons avec undefined au lieu d'un raccourci du type rule.min && .... La règle min: 0 est une valeur fausse (falsy), donc le raccourci l'ignorerait silencieusement, et la condition !value prendrait une santé égale à 0 pour une absence de données. À l'intérieur de validate, il y a bien un push, mais uniquement sur le tableau local errors, donc vue de l'extérieur, la fonction reste pure.

La mémoïsation avec les fermetures

La mémoïsation est une technique de mise en cache des résultats de calculs coûteux. Les fermetures fournissent un cache privé. La clé dans la Map est le texte produit par JSON.stringify(args), car deux tableaux d'arguments distincts ne sont jamais égaux en tant qu'objets :

1function memoize(fn) {
2  const cache = new Map();
3
4  return function(...args) {
5    const key = JSON.stringify(args);
6    if (cache.has(key)) {
7      console.log('Cache hit for:', key);
8      return cache.get(key);
9    }
10    const result = fn(...args);
11    cache.set(key, result);
12    return result;
13  };
14}

Testons maintenant la mémoïsation sur l'analyse d'un échantillon d'ADN, qui simule un calcul coûteux avec une boucle de cent mille étapes :

1// Analyse d'ADN coûteuse
2function analyzeDNA(sampleId, depth) {
3  // Simulation d'un long calcul
4  let result = 0;
5  for (let i = 0; i < depth * 1000; i++) {
6    result += Math.sqrt(i);
7  }
8  return { sampleId, score: result / (depth * 1000) };
9}
10
11const memoizedAnalyze = memoize(analyzeDNA);
12memoizedAnalyze('TREX-001', 100); // calcule
13memoizedAnalyze('TREX-001', 100); // depuis le cache - instantané !

Le deuxième appel affiche Cache hit for: ["TREX-001",100] et renvoie le résultat mémorisé sans rien calculer. Ne mémoïse que des fonctions pures : si l'analyse tirait son résultat au sort avec Math.random(), le cache figerait le premier tirage et les appels suivants mentiraient. C'est pourquoi la simulation utilise ici le déterministe Math.sqrt.

Dans du code réel, tu croiseras aussi une version plus courte, basée sur un objet ordinaire et l'opérateur ||= (l'affectation logique OU d'ES2021) :

1const memoize = (fn) => {
2  const cache = {};
3  return (...args) => cache[args] ||= fn(...args);
4};

cache[args] transforme le tableau d'arguments en texte, par exemple 'TREX-001,100', et ||= n'affecte le résultat que si la clé contient une valeur fausse. Inconvénient : un résultat égal à 0, '' ou false sera recalculé à chaque fois. C'est pourquoi je te recommande la version avec Map - elle est plus longue, mais ne tombe pas dans ce piège. Ce sont aussi les fermetures qui font fonctionner le currying de la leçon précédente, et dans la leçon suivante, tu découvriras la récursivité.

Retiens : une fermeture est le coffre-fort de l'enclos - la fonction emporte avec elle la clé des variables de l'endroit où elle est née, et le reste du parc ne voit que les portes que tu installes toi-même.

Code de cette leçon : index.js
1// Les fermetures en programmation fonctionnelle
2console.log("=== Jurassic Park - Systemes de Securite Prives ===\n");
3
4// --- EXEMPLE 1 : Confidentialite des donnees ---
5console.log("--- Confidentialite des donnees ---");
6
7function createSecuritySystem(zoneName) {
8  let fenceVoltage = 10000;
9  let alertLevel = 0;
10  const incidentLog = [];
11
12  function logEvent(event) {
13    incidentLog.push({ event, zone: zoneName, time: Date.now() });
14  }
15
16  return {
17    raiseFence() {
18      fenceVoltage += 2000;
19      logEvent("Fence raised to " + fenceVoltage);
20      return fenceVoltage;
21    },
22    triggerAlert() {
23      alertLevel++;
24      logEvent("Alert: " + alertLevel);
25      return alertLevel;
26    },
27    getStatus() {
28      return {
29        zone: zoneName,
30        voltage: fenceVoltage,
31        alert: alertLevel,
32        incidents: incidentLog.length,
33      };
34    },
35  };
36}
37
38const zoneA = createSecuritySystem("Zone A");
39const zoneB = createSecuritySystem("Zone B");
40
41zoneA.raiseFence();
42zoneA.raiseFence();
43zoneA.triggerAlert();
44
45console.log("Zone A:", zoneA.getStatus());
46console.log("Zone B:", zoneB.getStatus());
47console.log("Chaque zone a des donnees isolees !\n");
48
49// --- EXEMPLE 2 : Fabrique de validateurs ---
50console.log("--- Fabrique de validateurs ---");
51
52function createValidator(rules) {
53  return function validate(data) {
54    const errors = [];
55    for (const [field, rule] of Object.entries(rules)) {
56      if (rule.required && !data[field]) {
57        errors.push(field + " is required");
58      }
59      if (rule.min !== undefined && data[field] < rule.min) {
60        errors.push(field + " must be >= " + rule.min);
61      }
62      if (rule.max !== undefined && data[field] > rule.max) {
63        errors.push(field + " must be <= " + rule.max);
64      }
65    }
66    return { valid: errors.length === 0, errors };
67  };
68}
69
70const validateDino = createValidator({
71  name: { required: true },
72  weight: { required: true, min: 1, max: 100000 },
73  health: { required: true, min: 0, max: 100 },
74});
75
76console.log("Valid:", validateDino({ name: "Rex", weight: 8000, health: 95 }));
77console.log("Invalid:", validateDino({ name: "", weight: -5, health: 150 }));
78
79// --- EXEMPLE 3 : Memoisation ---
80console.log("\n--- Memoisation ---");
81
82function memoize(fn) {
83  const cache = new Map();
84  return function(...args) {
85    const key = JSON.stringify(args);
86    if (cache.has(key)) {
87      console.log("  Cache HIT:", key);
88      return cache.get(key);
89    }
90    console.log("  Computing:", key);
91    const result = fn(...args);
92    cache.set(key, result);
93    return result;
94  };
95}
96
97function expensiveAnalysis(species, depth) {
98  let sum = 0;
99  for (let i = 0; i < depth * 100; i++) sum += i;
100  return { species, score: sum };
101}
102
103const memoAnalysis = memoize(expensiveAnalysis);
104memoAnalysis("T-Rex", 10);     // Computing
105memoAnalysis("T-Rex", 10);     // Cache HIT
106memoAnalysis("Raptor", 5);     // Computing
107memoAnalysis("Raptor", 5);     // Cache HIT
108memoAnalysis("T-Rex", 10);     // Cache HIT

Tu as repéré une erreur dans cette leçon ?

Teste tes connaissances

Réponds aux questions de cette leçon. Choisis une réponse pour voir tout de suite si elle est correcte.

  1. 1. Qu'est-ce qu'une fermeture (closure) en JavaScript ?

  2. 2. Qu'est-ce que la mémoïsation dans le contexte de la programmation fonctionnelle ?

Voici 2 des 3 questions de cette leçon. Réponds aux autres dans le jeu.

Tâches pratiques dans le jeu

  • Éditeur de code

    Construis une fabrique de suivi d'incidents avec des données privées et une fonction memoize.

  • Tri horizontal

    Ordonne les éléments d'une fabrique de fonctions avec fermeture :

  • Éditeur de code

    Réécris des boucles for impératives à l'aide de filter, map et reduce avec pipe.

Articles utiles