19 octobre 2023

WeakMap et WeakSet

Comme nous le savons du chapitre Ramasse-miettes (garbage collection), le moteur JavaScript stocke une valeur en mémoire pendant qu’elle est accessible et peut potentiellement être utilisée.

Par exemple :

let john = { name: "John" };

// l'objet est accessible, john en est la référence

// écraser la référence
john = null;

// l'objet sera supprimé de la mémoire

Habituellement, les propriétés d’un objet ou des éléments d’un tableau ou d’une autre structure de données sont considérées comme accessibles et conservées en mémoire pendant que cette structure de données est en mémoire.

Par exemple, si nous mettons un objet dans un tableau, alors que le tableau est vivant, l’objet sera également vivant, même s’il n’y a pas d’autres références.

Comme ceci :

let john = { name: "John" };

let array = [ john ];

john = null; // écraser la référence

// l'objet précédemment référencé par john est stocké dans le tableau
// donc il ne sera pas nettoyé
// nous pouvons l'obtenir sous forme de array[0]

Semblable à cela, si nous utilisons un objet comme clé dans un Map classique, alors que le Map existe, cet objet existe également. Il occupe de la mémoire et ne peut pas être nettoyé (garbage collected).

Par example :

let john = { name: "John" };

let map = new Map();
map.set(john, "...");

john = null; // écraser la référence

// John est stocké à l'intérieur du map
// nous pouvons l'obtenir en utilisant map.keys()

WeakMap est fondamentalement différent à cet égard. Cela n’empêche pas le garbage collection des objets clés.

Voyons ce que cela signifie sur des exemples.

WeakMap

La première différences entre Map et WeakMap est que les clés doivent être des objets, pas des valeurs primitives :

let weakMap = new WeakMap();

let obj = {};

weakMap.set(obj, "ok"); // fonctionne bien (object key)

// ne peut pas utiliser une chaîne de caractères comme clé
weakMap.set("test", "Whoops"); // Erreur, parce que "test" n'est pas un objet

Maintenant, si nous utilisons un objet comme clé, et qu’il n’y a pas d’autres références à cet objet – il sera automatiquement supprimé de la mémoire (et du map).

let john = { name: "John" };

let weakMap = new WeakMap();
weakMap.set(john, "...");

john = null; // on écrase la référence

// John est supprimé de la mémoire !

Comparez-le avec l’exemple du Map ci-dessus. Maintenant, si john n’existe que comme clé de WeakMap – il sera automatiquement supprimé du map (et de la mémoire).

WeakMap ne prend pas en charge l’itération et les méthodes keys(), values(), entries(), il n’y a donc aucun moyen d’en obtenir toutes les clés ou valeurs.

WeakMap n’a que les méthodes suivantes :

Pourquoi une telle limitation ? C’est pour des raisons techniques. Si un objet a perdu toutes les autres références (comme john dans le code ci-dessus), il doit être automatiquement nettoyé. Mais techniquement, ce n’est pas exactement spécifié quand le nettoyage a lieu.

Le moteur JavaScript décide de cela. Il peut choisir d’effectuer le nettoyage de la mémoire immédiatement ou d’attendre et de faire le nettoyage plus tard lorsque d’autres suppressions se produisent. Donc, techniquement, le nombre d’éléments actuel d’un WeakMap n’est pas connu. Le moteur peut l’avoir nettoyé ou non, ou l’a fait partiellement. Pour cette raison, les méthodes qui accèdent à toutes les clés/valeurs ne sont pas prises en charge.

Maintenant, où avons-nous besoin d’une telle structure de données ?

Cas d’utilisation : données supplémentaires

Le principal domaine d’application de WeakMap est un stockage de données supplémentaire.

Si nous travaillons avec un objet qui “appartient” à un autre code, peut-être même une bibliothèque tierce, et que nous souhaitons stocker certaines données qui lui sont associées, cela ne devrait exister que lorsque l’objet est vivant – alors WeakMap est exactement ce qu’il nous faut.

Nous plaçons les données dans un WeakMap, en utilisant l’objet comme clé, et lorsque l’objet est nettoyé, ces données disparaissent automatiquement également.

weakMap.set(john, "secret documents");
// si John meurt, les documents secrets seront détruits automatiquement

Regardons un exemple.

Par exemple, nous avons un code qui conserve un nombre de visites pour les utilisateurs. Les informations sont stockées dans un map : un objet utilisateur est la clé et le nombre de visites est la valeur. Lorsqu’un utilisateur quitte (son objet est nettoyé), nous ne voulons plus stocker son nombre de visites.

Voici un exemple d’une fonction de comptage avec Map :

// 📁 visitsCount.js
let visitsCountMap = new Map(); // map: user => visits count

// augmentons le nombre de visites
function countUser(user) {
  let count = visitsCountMap.get(user) || 0;
  visitsCountMap.set(user, count + 1);
}

Et voici une autre partie du code, peut-être un autre fichier qui l’utilise :

// 📁 main.js
let john = { name: "John" };

countUser(john); // compter ses visites

// plus tard, John nous quitte
john = null;

Maintenant, l’objet john doit être nettoyé, mais cependant, il reste en mémoire, parce que c’est une clé dans visitesCountMap.

Nous devons nettoyer visitesCountMap lorsque nous supprimons des utilisateurs, sinon il augmentera indéfiniment en mémoire. Un tel nettoyage peut devenir une tâche fastidieuse dans des architectures complexes.

Nous pouvons éviter cela en utilisant WeakMap :

// 📁 visitsCount.js
let visitsCountMap = new WeakMap(); // weakmap: user => visits count

// augmentons le nombre de visites
function countUser(user) {
  let count = visitsCountMap.get(user) || 0;
  visitsCountMap.set(user, count + 1);
}

Maintenant, nous n’avons plus à nettoyer visitesCountMap. Après que l’objet john devienne inaccessible autrement que en tant que clé de WeakMap, il est supprimé de la mémoire, en même temps que les informations de cette clé dans WeakMap.

Cas d’utilisation : mise en cache

Un autre exemple courant est la mise en cache. Nous pouvons stocker (“cache”) les résultats d’une fonction, afin que les futurs appels sur le même objet puissent le réutiliser.

Pour y parvenir, nous pouvons utiliser Map (scénario non optimal) :

// 📁 cache.js
let cache = new Map();

// calculons et mémorisons le résultat
function process(obj) {
  if (!cache.has(obj)) {
    let result = /* calculs du résultat pour */ obj;

    cache.set(obj, result);
    return result;
  }

  return cache.get(obj);
}

// Maintenant, utilisons process() dans un autre fichier :

// 📁 main.js
let obj = {/* disons que nous avons un objet */};

let result1 = process(obj); // calculé

// … plus tard, d'un autre endroit du code …
let result2 = process(obj); // résultat mémorisé provenant du cache

// … plus tard, lorsque l'objet n'est plus nécessaire :
obj = null;

alert(cache.size); // 1 (Ouch ! L'objet est toujours dans le cache, prenant de la mémoire !)

Pour plusieurs appels de process(obj) avec le même objet, il ne calcule le résultat que la première fois, puis le prend simplement dans cache. L’inconvénient est que nous devons nettoyer le cache lorsque l’objet n’est plus nécessaire.

Si nous remplaçons Map par WeakMap, alors ce problème disparaît : le résultat mis en cache sera automatiquement supprimé de la mémoire une fois que l’objet sera nettoyé.

// 📁 cache.js
let cache = new WeakMap();

// calculons et mémorisons le résultat
function process(obj) {
  if (!cache.has(obj)) {
    let result = /* calculer le résultat pour */ obj;

    cache.set(obj, result);
    return result;
  }

  return cache.get(obj);
}

// 📁 main.js
let obj = {/* un objet */};

let result1 = process(obj);
let result2 = process(obj);

// … plus tard, lorsque l'objet n'est plus nécessaire :
obj = null;

// Impossible d'obtenir cache.size, car c'est un WeakMap,
// mais c'est 0 ou bientôt 0
// Lorsque obj est nettoyé, les données mises en cache seront également supprimées

WeakSet

WeakSet se comporte de la même manière :

  • Il est analogue à Set, mais nous pouvons seulement ajouter des objets à WeakSet (pas de primitives).
  • Un objet existe dans le set tant qu’il est accessible ailleurs.
  • Comme Set, il prend en charge add, has et delete, mais pas size, keys() et aucune itération.

Étant “weak” (faible), il sert également de stockage supplémentaire. Mais pas pour des données arbitraires, mais plutôt pour des faits “oui/non”. Une appartenance à WeakSet peut signifier quelque chose à propos de l’objet.

Par exemple, nous pouvons ajouter des utilisateurs à WeakSet pour garder une trace de ceux qui ont visité notre site :

let visitedSet = new WeakSet();

let john = { name: "John" };
let pete = { name: "Pete" };
let mary = { name: "Mary" };

visitedSet.add(john); // John nous a rendu visite
visitedSet.add(pete); // Ensuite Pete
visitedSet.add(john); // John encore

// visitedSet a 2 utilisateurs maintenant

// vérifions si John est venu
alert(visitedSet.has(john)); // true

// vérifions si Mary est venue
alert(visitedSet.has(mary)); // false

john = null;

// visitedSet sera nettoyé automatiquement

La limitation la plus notable de WeakMap et WeakSet est l’absence d’itérations et l’impossibilité d’obtenir tout le contenu actuel. Cela peut sembler gênant, mais n’empêche pas WeakMap/WeakSet de faire leur travail principal – être un stockage “supplémentaire” de données pour les objets qui sont stockés/gérés à un autre endroit.

Résumé

WeakMap est une sorte de collection Map qui n’autorise que des objets comme clés et les supprime avec la valeur associée une fois qu’ils deviennent inaccessibles par d’autres moyens.

WeakSet est une sorte de collection Set qui ne stocke que des objets et les supprime une fois qu’ils deviennent inaccessibles par d’autres moyens.

Leurs principaux avantages sont qu’ils ont une faible référence aux objets, de sorte qu’ils peuvent facilement être supprimés par le garbage collector.

Cela se fait au prix de ne pas avoir de support pour clear, size, keys, values

WeakMap et WeakSet sont utilisées comme structures de données “secondaires” en plus du stockage d’objets “principal”. Une fois que l’objet est retiré du stockage principal, s’il n’est trouvé que comme clé de WeakMap ou dans un WeakSet, il sera nettoyé automatiquement.

Exercices

importance: 5

Il y a un tableau de messages :

let messages = [
  {text: "Hello", from: "John"},
  {text: "How goes?", from: "John"},
  {text: "See you soon", from: "Alice"}
];

Votre code peut y accéder, mais les messages sont gérés par le code d’une autre personne. De nouveaux messages sont ajoutés, les anciens sont régulièrement supprimés par ce code et vous ne connaissez pas le moment exact où cela se produit.

Maintenant, quelle structure de données pouvez-vous utiliser pour stocker des informations si le message “a été lu” ? La structure doit être bien adaptée pour donner la réponse “a-t-il été lu ?” Pour l’objet de message donné.

P.S. Lorsqu’un message est supprimé des messages, il doit également disparaître de votre structure.

P.P.S. Nous ne devrions pas modifier les objets de message, leur ajouter nos propriétés. Comme ils sont gérés par le code de quelqu’un d’autre, cela peut avoir de mauvaises conséquences.

Stockons les messages lus dans WeakSet:

let messages = [
  {text: "Hello", from: "John"},
  {text: "How goes?", from: "John"},
  {text: "See you soon", from: "Alice"}
];

let readMessages = new WeakSet();

// deux messages ont été lus
readMessages.add(messages[0]);
readMessages.add(messages[1]);
// readMessages a 2 éléments

// ...Relisons le premier message !
readMessages.add(messages[0]);
// readMessages a encore 2 éléments uniques

// réponse : le message[0] a-t-il été lu ?
alert("Read message 0: " + readMessages.has(messages[0])); // true

messages.shift();
// maintenant readMessages a 1 élément (techniquement, la mémoire peut être nettoyée plus tard)

Le WeakSet permet de stocker un ensemble de messages et de vérifier facilement l’existence d’un message dedans.

Il se nettoie automatiquement. Le compromis est que nous ne pouvons pas le parcourir, nous ne pouvons pas obtenir “tous les messages lus” directement. Mais nous pouvons le faire en parcourant tous les messages et en filtrant ceux qui sont dans le set.

Une autre solution pourrait consister à ajouter une propriété telle que message.isRead = true à un message après sa lecture. Comme les objets de messages sont gérés par un autre code, cela est généralement déconseillé, mais nous pouvons utiliser une propriété symbolique pour éviter les conflits.

Comme ceci :

// la propriété symbolique n'est connue que de notre code
let isRead = Symbol("isRead");
messages[0][isRead] = true;

Maintenant, le code tiers ne verra probablement pas notre propriété supplémentaire.

Bien que les symboles permettent de réduire la probabilité de problèmes, l’utilisation de WeakSet est préférable du point de vue de l’architecture.

importance: 5

Il existe un tableau de messages comme dans la previous task. La situation est similaire.

let messages = [
  {text: "Hello", from: "John"},
  {text: "How goes?", from: "John"},
  {text: "See you soon", from: "Alice"}
];

La question qui se pose maintenant est la suivante : quelle structure de données suggérez-vous pour stocker les informations : “quand le message a-t-il été lu ?”.

Dans la tâche précédente, nous n’avions besoin que de stocker le fait “oui/non”. Nous devons maintenant stocker la date et elle ne doit rester en mémoire que tant que le message n’a pas été nettoyé.

P.S. Les dates peuvent être stockées en tant qu’objets de la classe intégrée Date, que nous couvrirons plus tard.

Pour stocker une date, nous pouvons utiliser WeakMap:

let messages = [
  {text: "Hello", from: "John"},
  {text: "How goes?", from: "John"},
  {text: "See you soon", from: "Alice"}
];

let readMap = new WeakMap();

readMap.set(messages[0], new Date(2017, 1, 1));
// objet Date que nous étudierons plus tard
Carte du tutoriel