23 octobre 2023

Le "bind" de fonction

Lorsque l’on transmet des méthodes objets en tant que callbacks, par exemple à setTimeout, il y a un problème connu : “la perte du this”.

Dans ce chapitre nous verrons les façons de régler ça.

La perte du “this”

Nous avons déjà vu des exemples de la perte du this. Une fois qu’une méthode est passée quelque part séparement de l’objet – this est perdu.

Voici comment cela pourrait arriver avec setTimeout :

let user = {
  firstName: "John",
  sayHi() {
    alert(`Hello, ${this.firstName}!`);
  }
};

setTimeout(user.sayHi, 1000); // Hello, undefined!

Comme nous pouvons le voir, la sortie n’affiche pas “John” pour this.firstName, mais undefined !

C’est car setTimeout a eu la fonction user.sayHi, séparement de l’objet. La dernière ligne pourrait être réécrite comme :

let f = user.sayHi;
setTimeout(f, 1000); // Perte du contexte d'user

La méthode setTimeout dans le navigateur est un peu spéciale : elle définit this=window pour l’appel à la fonction (pour Node.js, this devient un objet “timer”, mais ça n’a pas d’importance ici). Donc pour this.firstName il essaye de récuperer window.firstName, qui n’existe pas. Dans d’autres cas similaires, this devient généralement undefined.

Cette tâche est plutôt commune – on veut transmettre une méthode objet quelque part ailleurs (ici – au scheduler) où elle sera appelée. Comment s’assurer qu’elle sera appelée dans le bon contexte ?

Solution 1 : Un wrapper

La solution la plus simple est d’utiliser une fonction enveloppée :

let user = {
  firstName: "John",
  sayHi() {
    alert(`Hello, ${this.firstName}!`);
  }
};

setTimeout(function() {
  user.sayHi(); // Hello, John!
}, 1000);

Maintenant ça fonctionne, car elle reçoit user depuis un environnement lexical extérieur, et donc les appels à la fonction se font normalement.

La même chose mais en plus court :

setTimeout(() => user.sayHi(), 1000); // Hello, John!

Ça à l’air bon, mais une légère vulnérabilité apparaît dans la structure de notre code.

Que se passe t-il si avant le déclenchement de setTimeout (il y une seconde de délai) user changeait de valeur ? Alors, soudainement, ça appelera le mauvais objet !

let user = {
  firstName: "John",
  sayHi() {
    alert(`Hello, ${this.firstName}!`);
  }
};

setTimeout(() => user.sayHi(), 1000);

// ...La valeur d'user dans 1 seconde
user = {
  sayHi() { alert("Another user in setTimeout!"); }
};

// Un autre user est dans le setTimeout !

La prochaine solution garantit que ce genre de chose n’arrivera pas

Solution 2 : “bind”

Les fonctions fournissent une méthode intégrée, bind qui permet de corriger this.

La syntaxe basique est :

// Une syntaxe plus complexe arrivera bientot
let boundFunc = func.bind(context);

Le résultat de func.bind(context) est une “objet exotique” dans le style d’une fonction, qui est appellable comme une fonction et qui transmet l’appel à func en définissant this=context de façon transparente.

En d’autres termes, appeller boundFunc équivaut à func avec un this corrigé.

Par exemple, ici funcUser passe l’appel à this tel que this=user :

let user = {
  firstName: "John"
};

function func() {
  alert(this.firstName);
}

let funcUser = func.bind(user);
funcUser(); // John

Ici func.bind(user) en tant “variante liée” de func, avec this=user.

Tous les arguments sont passés à l’originale func “tels quels”, par exemple :

let user = {
  firstName: "John"
};

function func(phrase) {
  alert(phrase + ', ' + this.firstName);
}

// Lie this à user
let funcUser = func.bind(user);

funcUser("Hello"); // Hello, John (l'argument "Hello" est passé, et this=user)

Maintenant essayons avec une méthode objet :

let user = {
  firstName: "John",
  sayHi() {
    alert(`Hello, ${this.firstName}!`);
  }
};

let sayHi = user.sayHi.bind(user); // (*)

// Peut s'exécuter sans objet
sayHi(); // Hello, John!

setTimeout(sayHi, 1000); // Hello, John!

// Mème si la valeur de user change dans 1 seconde
// sayHi utilise la valeur pré-liée, laquelle fait référence à l'ancien objet user
user = {
  sayHi() { alert("Another user in setTimeout!"); }
};

Sur la ligne (*) nous prenons la méthode user.sayHi en nous la lions à user. La méthode sayHi est une fonction “liée”, qui peut être appelée seule ou être transmise à setTimeout – ça n’a pas d’importance, le contexte sera le bon.

Ici, nous pouvons voir que les arguments passés “tels quels”, seulement this est corrigé par bind :

let user = {
  firstName: "John",
  say(phrase) {
    alert(`${phrase}, ${this.firstName}!`);
  }
};

let say = user.say.bind(user);

say("Hello"); // Hello, John! (l'argument "Hello" est passé à say)
say("Bye"); // Bye, John! (l'argument "Bye" est passé à say)
La méthode pratique : bindAll

Si un objet a plusieurs méthodes et que nous prévoyons de le transmettre plusieurs fois, alors on pourrait toutes les lier dans une boucle :

for (let key in user) {
  if (typeof user[key] == 'function') {
    user[key] = user[key].bind(user);
  }
}

Les librairies JavaScript fournissent aussi des fonctions pratiques pour les liaisons de masse, e.g. _.bindAll(object, methodNames) avec lodash.

Les fonctions partielles

Jusqu’à maintenant nous avons parlé uniquement de lier this. Allons plus loin.

Nous pouvons lier this, mais aussi des arguments. C’est rarement utilisé, mais ça peut être pratique.

La syntaxe complète de bind :

let bound = func.bind(context, [arg1], [arg2], ...);

Elle permet de lier le contexte en tant que this et de démarrer les arguments de la fonction.

Par exemple, nous avons une fonction de multiplication mul(a, b) :

function mul(a, b) {
  return a * b;
}

Utilisons bind pour créer une fonction double sur cette base :

function mul(a, b) {
  return a * b;
}

let double = mul.bind(null, 2);

alert( double(3) ); // = mul(2, 3) = 6
alert( double(4) ); // = mul(2, 4) = 8
alert( double(5) ); // = mul(2, 5) = 10

L’appel à mul.bind(null, 2) créer une nouvelle fonction double qui transmet les appels à mul, corrigeant null dans le contexte et 2 comme premier argument. Les arguments sont passés “tels quels” plus loin.

Ça s’appelle l’application de fonction partielle – nous créeons une nouvelle fonction en corrigeant certains paramètres d’une fonction existante.

Veuillez noter que nous n’utilisons actuellement pas this ici. Mais bind en a besoin, donc nous devrions mettre quelque chose dedans comme null.

La fonction triple dans le code ci-dessous triple la valeur :

function mul(a, b) {
  return a * b;
}

let triple = mul.bind(null, 3);

alert( triple(3) ); // = mul(3, 3) = 9
alert( triple(4) ); // = mul(3, 4) = 12
alert( triple(5) ); // = mul(3, 5) = 15

Pourquoi faisons nous généralement une fonction partielle ?

L’avantage de faire ça est que nous pouvons créer une fonction indépendante avec un nom lisible (double, triple). Nous pouvons les utiliser et ne pas fournir de premier argument à chaque fois puisque c’est corrigé par bind.

Dans d’autres cas, les fonctions partielles sont utiles quand nous avons des fonctions vraiment génériques et que nous voulons une variante moins universelle pour des raisons pratiques.

Par exemple, nous avons une fonction send(from, to, text). Alors, dans un objet user nous pourrions vouloir en utiliser une variante partielle : sendTo(to, text) qui envoie depuis l’utilisateur actuel.

Aller dans les partielles sans contexte

Que se passerait t-il si nous voulions corriger certains arguments, mais pas le contexte this ? Par exemple, pour une méthode objet.

La fonction bind native ne permet pas ça. Nous ne pouvons pas juste omettre le contexte et aller directement aux arguments.

Heureusement, une fonction partial pour lier seulement les arguments peut être facilement implémentée.

Comme ça :

function partial(func, ...argsBound) {
  return function(...args) { // (*)
    return func.call(this, ...argsBound, ...args);
  }
}

// Utilisation :
let user = {
  firstName: "John",
  say(time, phrase) {
    alert(`[${time}] ${this.firstName}: ${phrase}!`);
  }
};

// Ajoute une méthode partielle avec time corrigé
user.sayNow = partial(user.say, new Date().getHours() + ':' + new Date().getMinutes());

user.sayNow("Hello");
// Quelque chose du genre :
// [10:00] John: Hello!

Le résultat de l’appel partial(func[, arg1, arg2...]) est une enveloppe (*) qui appelle func avec :

  • Le même this qu’il récupère (pour user.sayNow l’appel est user)
  • Alors il donne ...argsBound – les arguments provenant de l’appel de partial ("10:00")
  • Alors il donne ...args – les arguments donnés à l’enveloppe ("Hello")

Alors, c’est simple à faire avec la spread syntaxe, pas vrai ?

Aussi il y a une implémentation de _.partial prête à l’emploi dans les librairies lodash.

Résumé

La méthode func.bind(context, ...args) retourne une “variante liée” de la fonction func qui corrige le contexte de this et des premiers arguments s’ils sont donnés.

Nous appliquons généralement bind pour corriger this pour une méthode objet, comme ça nous pouvons la passer ailleurs. Par exemple, à setTimeout.

Quand nous corrigeons certains arguments d’une fonction existante, la fonction (moins universelle) en résultant est dite partiellement appliquée ou partielle.

Les fonctions partielles sont pratiques quand nous ne voulons pas répéter le même argument encore et encore. Comme si nous avions une fonction send(from, to), et que from devait être toujours le même pour notre tâche, nous pourrions récupérer une partielle et continuer.

Exercices

importance: 5

What will be the output?

function f() {
  alert( this ); // ?
}

let user = {
  g: f.bind(null)
};

user.g();

The answer: null.

function f() {
  alert( this ); // null
}

let user = {
  g: f.bind(null)
};

user.g();

The context of a bound function is hard-fixed. There’s just no way to further change it.

So even while we run user.g(), the original function is called with this=null.

importance: 5

Can we change this by additional binding?

What will be the output?

function f() {
  alert(this.name);
}

f = f.bind( {name: "John"} ).bind( {name: "Ann" } );

f();

The answer: John.

function f() {
  alert(this.name);
}

f = f.bind( {name: "John"} ).bind( {name: "Pete"} );

f(); // John

The exotic bound function object returned by f.bind(...) remembers the context (and arguments if provided) only at creation time.

A function cannot be re-bound.

importance: 5

There’s a value in the property of a function. Will it change after bind? Why, or why not?

function sayHi() {
  alert( this.name );
}
sayHi.test = 5;

let bound = sayHi.bind({
  name: "John"
});

alert( bound.test ); // what will be the output? why?

The answer: undefined.

The result of bind is another object. It does not have the test property.

importance: 5

The call to askPassword() in the code below should check the password and then call user.loginOk/loginFail depending on the answer.

But it leads to an error. Why?

Fix the highlighted line for everything to start working right (other lines are not to be changed).

function askPassword(ok, fail) {
  let password = prompt("Password?", '');
  if (password == "rockstar") ok();
  else fail();
}

let user = {
  name: 'John',

  loginOk() {
    alert(`${this.name} logged in`);
  },

  loginFail() {
    alert(`${this.name} failed to log in`);
  },

};

askPassword(user.loginOk, user.loginFail);

The error occurs because ask gets functions loginOk/loginFail without the object.

When it calls them, they naturally assume this=undefined.

Let’s bind the context:

function askPassword(ok, fail) {
  let password = prompt("Password?", '');
  if (password == "rockstar") ok();
  else fail();
}

let user = {
  name: 'John',

  loginOk() {
    alert(`${this.name} logged in`);
  },

  loginFail() {
    alert(`${this.name} failed to log in`);
  },

};

askPassword(user.loginOk.bind(user), user.loginFail.bind(user));

Now it works.

An alternative solution could be:

//...
askPassword(() => user.loginOk(), () => user.loginFail());

Usually that also works and looks good.

It’s a bit less reliable though in more complex situations where user variable might change after askPassword is called, but before the visitor answers and calls () => user.loginOk().

importance: 5

La tâche est une variante un peu plus complexe de Fix a function that loses "this".

L’objet user a été modifié. Maintenant, au lieu de deux fonctions loginOk/loginFail, il a une seule fonction user.login(true/false).

Que faire passer à askPassword dans le code ci-dessous, de sorte qu’il appelle user.login(true) comme ok et user.login(false) comme fail ?

function askPassword(ok, fail) {
  let password = prompt("Password?", '');
  if (password == "rockstar") ok();
  else fail();
}

let user = {
  name: 'John',

  login(result) {
    alert( this.name + (result ? ' logged in' : ' failed to log in') );
  }
};

askPassword(?, ?); // ?

Vos modifications doivent uniquement modifier le fragment surligné.

  1. Soit utiliser une fonction wrapper, une fléchée pour être concis:

    askPassword(() => user.login(true), () => user.login(false));

    Maintenant, elle obtient user des variables externes et l’exécute normalement.

  2. Ou créez une fonction partielle à partir de user.login qui utilise user comme contexte et a le bon premier argument:

    askPassword(user.login.bind(user, true), user.login.bind(user, false));
Carte du tutoriel