3. Fonctions, portée, closures et this
Sections
Réglages de lecture
Aucune voix pour cette langue dans ce navigateur.
Plus de voix
Code et termes techniques en anglais
Désactivé : la voix de la page lit tout, sans pause entre deux voix.
Aucune voix pour cette langue dans ce navigateur.
Plus de voix
D’autres voix : Microsoft Edge propose des voix naturelles en ligne, et Windows (Paramètres › Heure et langue › Voix), macOS et Android en installent d’autres.
Code : les fichiers examples/l03_*, errors/l03_duplicate_function.js, et les côtés C# et Java dans compare/l03_closures.cs, compare/L03MethodRef.java et compare_fail/L03Closures.java.
Trois façons d’écrire une fonction
Section intitulée « Trois façons d’écrire une fonction »import { show } from './show.js';
function add(a, b) { return a + b;}const subtract = function (a, b) { return a - b;};const multiply = (a, b) => a * b;
show('add(2, 3)', add(2, 3));show('subtract(2, 3)', subtract(2, 3));show('multiply(2, 3)', multiply(2, 3));
// Trop peu ou trop d'arguments : pas d'erreurshow('add(2)', add(2));show('add(2, 3, 4)', add(2, 3, 4));show('add.length', add.length);
// Valeurs par défaut et paramètres restfunction greet(name = 'reader', ...titles) { return `Hello, ${[...titles, name].join(' ')}!`;}show('greet()', greet());show("greet('Hopper', 'Admiral')", greet('Hopper', 'Admiral'));show('greet(undefined)', greet(undefined));show('greet(null)', greet(null));
// Pas de surcharge : dans le corps d'une fonction, la seconde déclaration remplace la premièrefunction overloads() { function describe(page) { return `page ${page}`; } function describe(page, locale) { return `page ${page} in ${locale}`; } return describe('mission');}show('overloads()', overloads());
// Les fonctions sont des objets : on peut les stocker, les passer et leur donner des propriétésconst operations = { add, subtract, multiply };show('Object.keys(operations)', Object.keys(operations));// map appelle son callback avec (élément, indice, tableau) : les arguments en trop sont utilisés en silenceshow('[1, 2, 3].map(multiply)', [1, 2, 3].map(multiply));show("['1', '2', '3'].map(parseInt)", ['1', '2', '3'].map(parseInt));show('typeof add', typeof add);show('add.name', add.name);add(2, 3) 5subtract(2, 3) -1multiply(2, 3) 6add(2) NaNadd(2, 3, 4) 5add.length 2greet() 'Hello, reader!'greet('Hopper', 'Admiral') 'Hello, Admiral Hopper!'greet(undefined) 'Hello, reader!'greet(null) 'Hello, !'overloads() 'page mission in undefined'Object.keys(operations) [ 'add', 'subtract', 'multiply' ][1, 2, 3].map(multiply) [ 0, 2, 6 ]['1', '2', '3'].map(parseInt) [ 1, NaN, NaN ]typeof add 'function'add.name 'add'| C# | Java | JavaScript | |
|---|---|---|---|
| Fonction nommée | une méthode | une méthode | function add(a, b) { … }, une déclaration |
| Fonction dans une variable | Func<int, int, int> add = (a, b) => a + b; |
IntBinaryOperator add = (a, b) -> a + b; |
une expression de fonction, ou une fonction fléchée (a, b) => a + b |
| Mauvais nombre d’arguments | erreur de compilation | erreur de compilation | accepté : les manquants valent undefined, ceux en trop sont ignorés |
| Valeur par défaut | int b = 0 |
— | b = 0, utilisée quand l’argument vaut undefined |
| Arguments variables | params int[] rest |
int... rest |
...rest, un vrai tableau |
| Surcharge | par signature | par signature | aucune |
Un appel ne vérifie rien : add(2) s’exécute avec b à undefined, et 2 + undefined vaut NaN. Une valeur par défaut ne remplace que undefined, donc greet(null) garde le null. Il n’y a pas de surcharge non plus : dans une fonction, un second function describe remplace le premier en silence, et le code qui a besoin de deux comportements inspecte ses arguments. Au niveau supérieur d’un module, le même doublon est refusé avant que quoi que ce soit s’exécute, parce que les déclarations de niveau supérieur d’un module suivent les règles de let :
errors/l03_duplicate_function.js:5function describe(page, locale) {^
SyntaxError: Identifier 'describe' has already been declared
Node.js v24.21.0Les fonctions sont des objets : elles ont des propriétés comme name et length, et on peut les stocker et les faire circuler comme les délégués en C# ou les interfaces fonctionnelles en Java, sans type nommé. En passer une comme callback se combine avec l’absence de vérification en un piège classique. Array.prototype.map appelle son callback avec trois arguments : l’élément, son indice, et le tableau. multiply utilise les deux premiers, et renvoie 1 × 0, 2 × 1 et 3 × 2. parseInt prend l’indice comme base : parseInt('1', 0) traite la base 0 comme 10, la base 1 n’existe pas, et '3' n’est pas un chiffre en base 2. Écris map((text) => parseInt(text, 10)) pour dire quels arguments tu passes.
Le hoisting et la zone morte temporelle
Section intitulée « Le hoisting et la zone morte temporelle »import { attempt, show } from './show.js';
// Une déclaration de fonction peut être appelée avant sa ligneshow('square(4)', square(4));function square(n) { return n * n;}
// var est hissée avec la valeur undefined, pour toute la fonctionfunction withVar() { const before = total; var total = 10; return [before, total];}show('withVar()', withVar());
// let, const et class sont hissées aussi, mais les lire avant leur ligne lève une exceptionfunction withLet() { const before = total; let total = 10; return [before, total];}attempt('withLet()', withLet);attempt('typeof total, in the TDZ', () => { const kind = typeof total; let total = 1; return kind;});show('typeof neverDeclared', typeof neverDeclared);attempt('new Later()', () => new Later());class Later {}
// var n'a pas de portée de bloc : le if ne la contient pasfunction blocks() { if (true) { var fromVar = 'visible'; let fromLet = 'hidden'; } return [fromVar, typeof fromLet];}show('blocks()', blocks());square(4) 16withVar() [ undefined, 10 ]withLet() ReferenceError: Cannot access 'total' before initializationtypeof total, in the TDZ ReferenceError: Cannot access 'total' before initializationtypeof neverDeclared 'undefined'new Later() ReferenceError: Cannot access 'Later' before initializationblocks() [ 'visible', 'undefined' ]Avant d’exécuter une fonction ou un module, le moteur crée toutes les variables qu’il déclare. C’est ce qu’on appelle le hoisting (la remontée), et ce que contient une variable avant sa ligne dépend de la façon dont elle a été déclarée :
- une déclaration de fonction est prête tout de suite, et c’est pourquoi
square(4)fonctionne au-dessus de sa définition, comme une méthode peut être appelée avant d’apparaître dans une classe C# ; - un
varcontientundefined, et appartient à toute la fonction, pas au bloc où il est écrit ; - un
let, unconstou uneclassexiste mais ne peut pas être lu tant que sa ligne ne s’est pas exécutée. L’intervalle entre le début de la portée et cette ligne est la zone morte temporelle, et lire la variable dans cette zone lève uneReferenceError, même à traverstypeof, qui sinon renvoie'undefined'pour un nom jamais déclaré.
C# refuse à la compilation d’utiliser une variable locale avant sa déclaration. JavaScript ne peut refuser qu’au moment où la ligne s’exécute, et let et const en font au moins une erreur bruyante au lieu d’un undefined silencieux. var est la raison de les préférer : il ignore les blocs, et il s’accorde mal avec les closures, comme le montre la section suivante.
Closures
Section intitulée « Closures »import { attempt, show } from './show.js';
function counter() { let count = 0; // privé : seules les fonctions renvoyées peuvent l'atteindre return { increment: () => ++count, current: () => count, };}const a = counter();const b = counter();a.increment();a.increment();b.increment();show('a.current()', a.current());show('b.current()', b.current());show('a.count', a.count);
// La variable est capturée, donc un changement ultérieur est visiblelet label = 'draft';const readLabel = () => label;label = 'published';show('readLabel()', readLabel());
// Boucles : var donne une variable pour toute la boucle, let en donne une par itérationconst withVar = [];for (var i = 0; i < 3; i++) { withVar.push(() => i);}const withLet = [];for (let j = 0; j < 3; j++) { withLet.push(() => j);}show('withVar, called', withVar.map((f) => f()));show('withLet, called', withLet.map((f) => f()));
// Un callback qui s'exécute plus tard voit la valeur de ce moment-làconst timeline = [];for (var k = 0; k < 3; k++) { setTimeout(() => timeline.push(k), 0);}setTimeout(() => show('timeline', timeline), 0);attempt('k, after the loop', () => k);a.current() 2b.current() 1a.count undefinedreadLabel() 'published'withVar, called [ 3, 3, 3 ]withLet, called [ 0, 1, 2 ]k, after the loop 3timeline [ 3, 3, 3 ]Une fonction garde l’accès aux variables de la portée où elle a été créée, même après que cette portée a rendu la main : count vit aussi longtemps que les fonctions qui l’utilisent, un count par appel de counter. C’est une closure (fermeture), et avant que les classes aient des champs privés, c’était la façon de cacher un état. La fonction capture la variable, pas sa valeur du moment : readLabel voit 'published'.
C’est dans les boucles que cela compte. Un var dans un for est une seule variable pour toute la boucle, donc les trois fonctions lisent le même i, qui vaut 3 quand elles s’exécutent. Avec let, la spécification crée une nouvelle variable à chaque itération et y copie la valeur courante, donc chaque fonction garde la sienne. Les timers montrent la même chose avec des callbacks qui s’exécutent une fois la boucle terminée ; la leçon 7 explique quand.
C# et Java rencontrent la même question, et y répondent différemment :
> dotnet run l03_closures.csfor: 3, 3, 3foreach: 0, 1, 2method group: GA plays> javac L03Closures.javaL03Closures.java:10: error: local variables referenced from a lambda expression must be final or effectively final suppliers.add(() -> i); ^1 errorC# capture les variables, comme JavaScript : une boucle for a une seule variable, et ses lambdas affichent 3, 3, 3. Sa boucle foreach donne à chaque itération sa propre variable depuis C# 5, un changement cassant fait exactement pour cette raison. Java refuse de compiler une lambda qui capture une variable qui change (JLS 15.27.2), et t’oblige à la copier dans une variable effectivement finale.
En C# et en Java, this est l’objet dont la méthode s’exécute, décidé là où la méthode est écrite. En JavaScript, this est un paramètre implicite de toute fonction non fléchée, et sa valeur est décidée par chaque appel (OrdinaryCallBindThis).
import { attempt, show } from './show.js';
function whoAmI() { return this?.name;}const lesson = { name: 'lesson', whoAmI };const journal = { name: 'journal' };
// Liaison implicite : l'objet avant le pointshow('lesson.whoAmI()', lesson.whoAmI());
// Liaison par défaut : un appel simple, undefined en code strict (modules et classes)show('whoAmI()', whoAmI());const detached = lesson.whoAmI;show('detached()', detached());
// Liaison explicite : call, apply et bindshow('whoAmI.call(journal)', whoAmI.call(journal));show('whoAmI.apply(journal, [])', whoAmI.apply(journal, []));const bound = whoAmI.bind(journal);show('bound()', bound());lesson.bound = bound;show('lesson.bound()', lesson.bound());show('bound.call(lesson)', bound.call(lesson));
// Liaison par new : un nouvel objetfunction Page(name) { this.name = name;}show("new Page('index')", new Page('index'));const BoundPage = Page.bind(journal);show("new BoundPage('index')", new BoundPage('index'));show('journal, unchanged', journal);
// Les fonctions fléchées n'ont pas de this propre : elles utilisent celui qui les entoureconst site = { name: 'site', pages: ['mission', 'journal'], withArrow() { return this.pages.map((page) => `${this.name}/${page}`); }, withFunction() { return this.pages.map(function (page) { return `${this?.name}/${page}`; }); }, arrowMethod: () => typeof this,};show('site.withArrow()', site.withArrow());show('site.withFunction()', site.withFunction());show('site.arrowMethod()', site.arrowMethod());
// Une méthode de classe passée comme callback perd son objetclass Player { name = 'GA'; play() { return `${this.name} plays`; }}const player = new Player();attempt("['C'].map(player.play)", () => ['C'].map(player.play));show("['C'].map(() => player.play())", ['C'].map(() => player.play()));const play = player.play.bind(player);show("['C'].map(play)", ['C'].map(play));lesson.whoAmI() 'lesson'whoAmI() undefineddetached() undefinedwhoAmI.call(journal) 'journal'whoAmI.apply(journal, []) 'journal'bound() 'journal'lesson.bound() 'journal'bound.call(lesson) 'journal'new Page('index') Page { name: 'index' }new BoundPage('index') Page { name: 'index' }journal, unchanged { name: 'journal' }site.withArrow() [ 'site/mission', 'site/journal' ]site.withFunction() [ 'undefined/mission', 'undefined/journal' ]site.arrowMethod() 'undefined'['C'].map(player.play) TypeError: Cannot read properties of undefined (reading 'name')['C'].map(() => player.play()) [ 'GA plays' ]['C'].map(play) [ 'GA plays' ]Quatre règles décident de this, de la plus forte à la plus faible :
new:new Page('index')crée un objet et le passe commethis. Il l’emporte même surbind:new BoundPage('index')ignorejournal.- Explicite :
calletapplypassentthispour un appel ;bindrenvoie une nouvelle fonction dont lethisest fixé pour de bon, si bien que ni un point nicallne peuvent le changer ensuite. - Implicite :
lesson.whoAmI()passe l’objet avant le point. La fonction n’appartient pas àlesson: la même fonction appelée commedetached()l’a perdu. - Par défaut : un appel simple passe
undefineden code strict, et chaque module ES et chaque corps de classe est strict. En code non strict, il passe l’objet global à la place (voir le mode strict plus bas).
Une fonction fléchée ne participe pas : elle n’a pas de this propre et utilise celui du code qui l’entoure, comme n’importe quelle autre variable capturée. Cela rend les fonctions fléchées adaptées aux callbacks à l’intérieur d’une méthode, comme dans withArrow, où la function de withFunction reçoit la règle 4 et perd site. Cela les rend aussi inadaptées comme méthodes : arrowMethod voit le this du niveau supérieur du module, qui vaut undefined.
Le cas qui piège les développeurs C# et Java est le dernier. player.play lit la fonction, sans l’appeler ; map l’appelle plus tard sans point, et la règle 4 donne à this la valeur undefined. En C#, un groupe de méthodes comme Func<string> play = player.Play; garde son objet, tout comme une référence de méthode liée player::play en Java, ce qu’affichent les programmes de comparaison (method group: GA plays, method reference: GA plays). En JavaScript, enveloppe l’appel dans une fonction fléchée ou applique bind à la méthode.
flowchart TD start["Une fonction est appelée"] --> arrow{"Fonction fléchée ?"} arrow -- oui --> lexical["this du code englobant"] arrow -- non --> isnew{"Appelée avec new ?"} isnew -- oui --> fresh["Le nouvel objet"] isnew -- non --> isbound{"Créée par bind ?"} isbound -- oui --> boundto["La valeur donnée à bind"] isbound -- non --> iscall{"Appelée avec call ou apply ?"} iscall -- oui --> first["Leur premier argument"] iscall -- non --> dot{"Appelée comme objet.méthode() ?"} dot -- oui --> obj["Cet objet"] dot -- non --> strict["undefined en code strict,<br/>l'objet global sinon"]Mode strict
Section intitulée « Mode strict »Le mode strict a corrigé en 2009 quelques erreurs de conception des débuts, sans casser les anciennes pages : le code s’y engage avec la directive 'use strict'. Les modules ES et les corps de classe sont toujours stricts ; un fichier CommonJS ne l’est pas, sauf s’il le demande.
function sloppy() { return this === globalThis;}function strict() { 'use strict'; return this;}console.log('sloppy() gets globalThis:', sloppy());console.log('strict() gets:', strict());console.log('this at the top of a CommonJS file is module.exports:', this === module.exports);
// Sans mode strict, une faute de frappe crée une variable globalefunction typo() { totl = 42;}typo();console.log('globalThis.totl:', globalThis.totl);sloppy() gets globalThis: truestrict() gets: undefinedthis at the top of a CommonJS file is module.exports: trueglobalThis.totl: 42En code non strict, un appel simple passe l’objet global comme this, si bien qu’une méthode détachée lit et écrit en silence des propriétés globales au lieu de lever une exception ; affecter un nom non déclaré crée une variable globale ; et affecter une propriété en lecture seule, comme l’objet gelé de la leçon 2, ne fait rien au lieu de lever une exception. Écris des modules ES, et ces trois fautes deviennent des erreurs.
Dans GuitarAlchemist/ga : garder this dans les écouteurs d’événements
Section intitulée « Dans GuitarAlchemist/ga : garder this dans les écouteurs d’événements »Un écouteur d’événements est un callback, donc le piège de player.play s’applique à chaque addEventListener(…, this.method). Le front end de GA utilise trois façons correctes de garder this, et chacune conserve une référence à la fonction qu’elle enregistre, parce que removeEventListener a besoin de la même fonction :
LunarLanderEngine.ts, lignes 427-433 lie chaque gestionnaire une fois, dans le constructeur, et stocke le résultat ;InteractionHandler.ts, lignes 53 et 65 déclare les gestionnaires comme fonctions fléchées dans des champs de classe ;GodotBridge.ts, lignes 197-220 stocke une fonction fléchée dans un champ avant de l’enregistrer.
Node.js a la même classe EventTarget que les navigateurs, donc ces patterns s’exécutent sans navigateur :
const target = new EventTarget();const ping = () => target.dispatchEvent(new Event('ping'));
// Une méthode passée telle quelle : this est l'EventTarget, pas l'objetclass Naive { count = 0; onPing() { this.count++; } start() { target.addEventListener('ping', this.onPing); }}
// LunarLanderEngine.ts : lier une fois, garder la fonction liée pour la retirer plus tardclass BindOnce { count = 0; constructor() { this.boundPing = this.onPing.bind(this); } onPing() { this.count++; } start() { target.addEventListener('ping', this.boundPing); } stop() { target.removeEventListener('ping', this.boundPing); }}
// InteractionHandler.ts : une fonction fléchée dans un champ de classe, une par instanceclass ArrowField { count = 0; onPing = () => { this.count++; }; start() { target.addEventListener('ping', this.onPing); } stop() { target.removeEventListener('ping', this.onPing); }}
// L'erreur qu'évitent les trois : bind crée une nouvelle fonction, donc ce retrait ne retire rienclass BindTwice { count = 0; onPing() { this.count++; } start() { target.addEventListener('ping', this.onPing.bind(this)); } stop() { target.removeEventListener('ping', this.onPing.bind(this)); }}
const naive = new Naive();naive.start();const listeners = [new BindOnce(), new ArrowField(), new BindTwice()];for (const listener of listeners) listener.start();ping();for (const listener of listeners) listener.stop();ping();
console.log('Naive.count ', naive.count, '; target.count', target.count);for (const listener of listeners) { console.log(`${listener.constructor.name}.count`.padEnd(16), listener.count);}console.log('bind returns a new function each time:', naive.onPing.bind(naive) === naive.onPing.bind(naive));Naive.count 0 ; target.count NaNBindOnce.count 1ArrowField.count 1BindTwice.count 2bind returns a new function each time: falseNaive n’échoue même pas : un EventTarget appelle ses écouteurs avec la cible comme this, donc this.count++ crée une propriété count sur la cible, undefined + 1 la met à NaN, et le compteur propre de l’objet reste à zéro. BindOnce et ArrowField reçoivent le premier ping et pas le second. BindTwice reçoit les deux, parce que chaque appel à bind renvoie une fonction différente, et removeEventListener n’a rien trouvé à retirer : l’écouteur reste enregistré pendant toute la vie de la cible, et l’objet qu’il retient aussi.
À retenir
Section intitulée « À retenir »- Un appel ne vérifie pas ses arguments : les manquants valent
undefined, ceux en trop sont ignorés, et il n’y a pas de surcharge. - Passer une fonction à
mapou à un autre callback lui passe tous les arguments que ce callback reçoit, indice compris. letetconstont une portée de bloc et lèvent une exception quand on les lit avant leur ligne ;vara une portée de fonction et se lit commeundefined.- Une closure capture des variables, pas des valeurs ; un
letdans une boucleforcrée une variable par itération. thisdépend de l’appel :new, puisbind,callouapply, puis l’objet avant le point, puisundefined. Les fonctions fléchées prennent lethisqui les entoure.- Une méthode passée comme callback perd son objet ; enveloppe-la dans une fonction fléchée, ou lie-la une fois et garde le résultat.
- Les modules ES et les classes sont stricts, ce qui transforme des fautes silencieuses en erreurs.
Exercices
Section intitulée « Exercices »- Garde le
varde la bouclewithVar, et fais renvoyer0,1et2aux trois fonctions.
Solution
solutions/l03_ex1_var_loop.js montre deux façons :
const withParameter = [];for (var i = 0; i < 3; i++) { withParameter.push(((copy) => () => copy)(i)); // le paramètre est une nouvelle variable à chaque appel}console.log(withParameter.map((f) => f()));
const withBind = [];for (var j = 0; j < 3; j++) { withBind.push(((value) => value).bind(null, j)); // bind stocke la valeur qu'a l'argument maintenant}console.log(withBind.map((f) => f()));[ 0, 1, 2 ][ 0, 1, 2 ]La première appelle une fonction à chaque itération, et un paramètre est une nouvelle variable à chaque appel : c’est la correction de Java, une copie par itération. La seconde utilise l’autre fonctionnalité de bind, qui fixe les arguments en plus de this. Avant let, le vieux code enveloppait le corps dans une fonction immédiatement invoquée pour la même raison.
- Écris
once(fn), qui renvoie une fonction qui appellefnla première fois seulement et renvoie le premier résultat à chaque appel suivant. Elle doit transmettre sonthiset ses arguments, pour fonctionner comme méthode.
Solution
function once(fn) { let called = false; let result; return function (...args) { if (!called) { called = true; result = fn.apply(this, args); } return result; };}
const player = { starts: 0, start: once(function (label) { this.starts++; return `${label} started`; }),};console.log(player.start('first'));console.log(player.start('second'));console.log('starts:', player.starts);first startedfirst startedstarts: 1called et result vivent dans la closure, une paire par appel de once. La fonction renvoyée doit être une function, pas une fonction fléchée : player.start(…) lui donne player comme this par la règle 3, et apply transmet ce this à fn. Une fonction fléchée transmettrait le this du corps de once, qui vaut undefined.
- Prédis chaque ligne, puis exécute
solutions/l03_ex3_predict.js:
class Tuner { note = 'E'; read() { return this.note; } readArrow = () => this.note;}const tuner = new Tuner();const other = new Tuner();
const { read, readArrow } = tuner;attempt('read()', () => read());show('readArrow()', readArrow());show("read.call({ note: 'A' })", read.call({ note: 'A' }));show("readArrow.call({ note: 'A' })", readArrow.call({ note: 'A' }));show('tuner.read === other.read', tuner.read === other.read);show('readArrow === other.readArrow', readArrow === other.readArrow);Solution
read() TypeError: Cannot read properties of undefined (reading 'note')readArrow() 'E'read.call({ note: 'A' }) 'A'readArrow.call({ note: 'A' }) 'E'tuner.read === other.read truereadArrow === other.readArrow falseLa déstructuration lit les deux propriétés sans les appeler. read est une méthode : appelée seule, elle reçoit undefined comme this, puisqu’un corps de classe est strict. readArrow a été créée par l’initialiseur du champ, où this valait tuner, et le garde : même call ne peut pas changer le this d’une fonction fléchée. Les deux dernières lignes montrent le coût : read est une seule fonction sur le prototype de la classe, partagée par toutes les instances (leçon 4), alors que chaque instance reçoit son propre readArrow.
- ECMAScript — Définitions de fonctions, Définitions de fonctions fléchées, OrdinaryCallBindThis, Function.prototype.bind, Déclarations let et const, CreatePerIterationEnvironment, Code en mode strict
- MDN — Fonctions, Closures, this, Hoisting
- Eric Lippert — Closing over the loop variable considered harmful, JLS 15.27.2 — Corps d’une lambda