ES6 en détails est une série d’articles décrivant les nouvelles fonctionnalités ajoutées au langage de programmation JavaScript avec la sixième édition du standard ECMAScript (ES6 en abrégé).

En début de semaine (NdT : le 17 juin 2015), les spécifications ES6, officiellement intitulées ECMA-262, 6th Edition, ECMAScript 2015 Language Specification, étaient sur la dernière ligne droite et ont été approuvées en tant que standard Ecma. Félicitations au TC39 et à tous ceux qui ont contribué. ES6 est publié !

Une autre nouvelle encore plus intéressante : la prochaine mise à jour ne mettra pas six ans à arriver. Le comité de standardisation a annoncé son objectif de lancer une nouvelle version environ tous les 12 mois. Les propositions pour la septième édition sont déjà dans les cartons.

Dès lors, il est de circonstance que de fêter tout ça en vous parlant de quelque chose que j’attendais depuis longtemps dans JavaScript – et qui pourra encore être amélioré par la suite !

De la difficulté d’évoluer

JavaScript n’est pas un langage de programmation comme les autres et parfois cela influence son évolution de façon surprenante.

Les modules ES6 en sont un exemple flagrant. D’autres langages utilisent des systèmes de modules. Racket possède un système de modules remarquable et Python également. Lorsque que le comité de standardisation a décidé d’ajouter des modules à ES6, pourquoi n’a-t-il pas copié un système existant ?

JavaScript est différent car il fonctionne dans les navigateurs web. Les entrées/sorties peuvent prendre un certain temps. Pour cette raison, JavaScript a besoin d’un système de module qui peut charger du code de façon asynchrone. Il ne peut pas non plus se permettre de chercher les modules dans différents répertoires les uns à la suite des autres. Copier un système existant était voué à l’échec : le système de modules ES6 avait besoin de certaines nouveautés.

La façon dont cela a influencé la conception du système est une histoire intéressante mais nous n’allons pas parler des modules aujourd’hui.

Ce billet portera sur ce que le standard ES6 appelle les « collections à clés » (keyed collections en anglais) : Set, Map, WeakSet et WeakMap. Sous de nombreux angles, ces fonctionnalités ressemblent aux différentes tables de hachage des autres langages. Toutefois, en raison de la nature de JavaScript, le comité de standardisation a dû faire certains compromis intéressants.

Les collections, pour quoi faire ?

Celles et ceux qui sont déjà familiers avec JavaScript savent qu’il y a déjà quelque chose de natif qui ressemble aux tables de hachage : les objets.

Un objet (type Object) n’est finalement rien d’autre qu’une collection ouverte de paires de clés-valeurs. Il est possible d’obtenir, de définir et de supprimer des propriétés. On peut itérer sur les propriétés d’un objet. Toutes ces caractéristiques font que les objets permettent de faire ce qu’on attend d’une table de hachage. Alors pourquoi ajouter une nouvelle fonctionnalité ?

Eh bien, de nombreux programmes utilisent les objets pour stocker des paires de clés-valeurs, et pour les programmes où cela fonctionne, il n’est pas nécessaire d’utiliser des Map ou des Set. Cependant, il existe des problèmes bien connus dans ces cas-là :

  • les objets utilisés comme tables de recherche ne peuvent pas avoir de méthodes sans risque de collisions ;
  • par conséquent les programmes doivent utiliser Object.create(null) au lieu de {} ou faire attention à ne pas interpréter des méthodes natives (telles que Object.prototype.toString) comme des données ;
  • les clés utilisés pour les propriétés d’un objet sont nécessairement des chaînes de caractères (ou en ES6, des symboles), des objets ne peuvent pas être des clés ;
  • il n’existe pas de moyen efficace pour connaître le nombre de propriétés d’un objet.

On a un nouveau problème avec ES6 : les objets ne sont pas itérables, donc ils ne pourront coopérer avec la boucle for-of, l’opérateur ..., etc.

J’insiste, il existe de nombreux programmes pour lesquels tout ceci n’est pas important et pour lesquels les objets seront des choix adaptés. Les Map et les Set sont utiles pour d’autres situations.

Ayant été conçues pour éviter les collisions entre les données des utilisateurs et les méthodes natives, les collections ES6 n’exposent pas leurs données comme des propriétés. Cela signifie que des expressions comme obj.clé ou obj[clé] ne peuvent pas être utilisées pour accéder aux données des tables de hachage. Il faudra écrire map.get(clé). De même, les éléments d’une table de hachage ne sont pas héritées via la chaîne de prototypes.

L’avantage est qu’on peut ajouter des méthodes aux méthodes existantes de Map et Set, que ce soit sur les classes standards ou sur ses propres sous-classes, sans que cela crée de conflit.

Set

Un Set est un ensemble de valeurs. Un ensemble est modifiable et on peut y ajouter ou y supprimer des valeurs. Ils ressemblent aux tableaux mais il y a quelques différences entre les ensembles (Set) et les tableaux (Array).

Tout d’abord, contrairement à un tableau, un ensemble ne contient jamais la même valeur deux fois. Si vous essayez d’ajouter une valeur qui existe déjà, cela n’aura aucun effet.

> var desserts = new Set(["cookie","glace","sundae","donut"]);
> desserts.size
    4
> desserts.add("cookie");
    Set [ "cookie", "glace", "sundae", "donut" ]
> desserts.size
    4

Cet exemple utilise des chaînes de caractères, mais un Set peut contenir n’importe quel type de valeurs JS. Comme avec les chaînes, ajouter le même objet ou le même nombre plus d’une fois ne modifiera pas l’ensemble.

Ensuite, un Set gardera ses données organisées afin de faciliter les tests d’appartenance :

> // vérifier que "zythum" est un mot.
> tableauMots.indexOf("zythum") !== -1  // lent
    true
> ensembleMots.has("zythum")            // rapide
    true

Les ensembles ne supportent pas l’indexation :

> tableauMots[15000]
    "anapanapa"
> ensembleMots[15000]   // l'indexation ne fonctionne pas
    undefined

Voici toutes les operations qu’on peut effectuer sur les ensembles (Set) :

  • new Set crée un nouveau Set vide ;
  • new Set(iterable) crée une nouveau Set et le remplit avec les données d’un itérable ;
  • set.has(valeur) renvoie true si le Set contient la valeur donnée ;
  • set.add(valeur) ajoute une valeur au Set. Si la valeur existait déjà dans le Set, rien ne se passe ;
  • set.delete(valeur) supprime une valeur du Set. Si la valeur n’existait pas dans ce Set, rien ne se passe. .add() et .delete renvoient tous les deux l’objet Set, il est donc possible de les enchaîner ;
  • set[Symbol.iterator]() renvoie un nouvel itérateur sur les valeurs du Set. C’est cette méthode qui rend les ensembles itérables. Cela signifie que vous pouvez écrire for (v of set) {...} et ainsi de suite ;
  • set.forEach(f) sera plus facilement compris avec un peu de code, c’est un raccourci pour :
    for (let value of set)
      f(value, value, set);
    
    Cette méthode est analogue à la méthode .forEach() utilisée pour les tableaux ;
  • set.clear() retire toutes les valeurs de l’ensemble ;
  • set.keys(), set.values() et set.entries() renvoient différents itérateurs. Ceux-ci sont fournis pour obtenir une compatibilité avec Map. Nous les décrirons dans la suite de cet article.

Parmi toutes ces fonctionnalités, le constructeur new Set(iterable) est un vrai couteau suisse car il permet de manipuler des structures de données entières. Vous pouvez l’utiliser pour convertir un tableau (Array) en un ensemble (Set) et éliminer les doublons avec une seule ligne de code. Vous pouvez également lui passer un générateur, le constructeur utilisera le générateur jusqu’à épuisement et collectera les valeurs dans l’ensemble construit. Le constructeur permet également d’obtenir une copie d’un objet Set existant.

Dans le billet précédent, j’avais prévenu que j’allais me plaindre sur les collections ES6. Pour commencer, voici quelques méthodes qui pourraient tout à fait améliorer les objet Set et qui pourraient être ajoutées par la suite dans le standard :

  • les méthodes utilitaires qu’on a déjà sur les tableaux : .map(), .filter(), .some() et .every() ;
  • des méthodes set1.union(set2) et set1.intersection(set2) qui ne modifient pas les objets sur lesquels elles sont appelées ;
  • des méthodes qui peuvent agir sur plusieurs valeurs à la fois : set.addAll(iterable), set.removeAll(iterable), et set.hasAll(iterable).

Bonne nouvelle ! Toutes ces méthodes peuvent être implémentées efficacement en utilisant les outils fournis par ES6.

Map

Une Map est une collection de paires de clés-valeurs. Voici ce qu’on peut faire avec une Map :

  • new Map renvoie un nouveau tableau associatif vide ;
  • new Map(paires) crée un nouveau tableau associatif et le remplit avec les paires contenues dans l’objet paires. Cet objet peut être un objet Map, un tableau qui contient des tableaux de deux éléments, un générateur qui génère des tableaux à deux éléments, etc.
  • map.size fournit le nombre de paires contenues dans le tableau associatif ;
  • map.has(clé) permet de tester si une clé est présente (semblable à l’expression clé in obj) ;
  • map.get(clé) fournit la valeur associée à une clé donnée. S’il n’y a pas de valeur pour cette clé, la valeur renvoyée sera undefined ;
  • map.set(clé, valeur) ajoute un nouvelle paire dans le tableau associatif, si un élément existe déjà pour cette clé, cela modifiera la valeur associée (semblable à obj[clé] = valeur) ;
  • map.delete(clé) supprime une paire donnée (semblable à delete obj[clé]) ;
  • map.clear() supprime toutes les paires contenues dans le tableau associatif ;
  • map[Symbol.iterator]() renvoie un itérateur qui parcourt les paires du tableau associatif. L’itérateur représente chaque élément comme un tableau [clé, valeur] ;
  • map.forEach(f) fonctionne comme ça :
    for (let [clé, valeur] of map)
      f(valeur, clé, map);
      
    L’ordre des arguments est pris de cette façon pour conserver l’analogie avec Array.prototype.forEach() ;
  • map.keys() renvoie un itérateur qui parcourt les clés du tableau associatif ;
  • map.values() renvoie un itérateur qui parcourt les valeurs du tableau associatif ;
  • map.entries() renvoie un itérateur qui parcourt les paires du tableau associatif de la même façon que map[Symbol.iterator](). Les deux sont équivalentes et sont en fait synonymes.

De quoi pourrait-on se plaindre ici ? Voici quelques fonctionnalités, qui ne font pas partie d’ES6 et qui pourraient, à mon avis, être utiles :

  • Un outil pour gérer les valeurs par défaut (par exemple Python a collections.defaultdict) ;
  • Une fonction utilitaire Map.fromObject(obj) qui permettrait de créer des tableaux associatifs en utilisant des littéraux objets.

De même, il est facile d’ajouter ces fonctionnalités.

OK. Revenons maintenant au début de l’article, j’y expliquais que, pour certains aspects, JavaScript était unique et que cela avait un impact sur la conception des fonctionnalités de ce langage. J’ai trois exemples pour expliquer cela, voici les deux premiers.

Les différences de JavaScript, première partie : Des tables de hachage sans code de hachage ?

Voici une fonctionnalité que les collections ES6 ne supportent pas. Imaginons qu’on ait un ensemble (Set) qui contient des objets représentant des URL :

var urls = new Set;
urls.add(new URL(location.href));  // deux objets URL
urls.add(new URL(location.href));  // est-ce que ce sont les mêmes ?
alert(urls.size);  // 2

Ces deux URL pourraient parfaitement être considérées comme égales. Elles possèdent toutes les deux les mêmes champs. Cependant, en JavaScript, ces deux objets sont distincts et il n’y a pas de méthode pour surcharger cette notion d’égalité.

D’autres langages permettent de le faire : Java, Python, Ruby dans lesquels les classes peuvent surcharger l’égalité. De nombreuses implémentations Scheme permettent de créer tables de hachage dont chacune peut utiliser une relation d’égalité sur mesure. C++ permet de faire les deux.

Cependant, tous ces mécanismes exposent la fonction de hachage utilisée par défaut par le système et il faut implémenter des fonctions de hachage personnalisées. Le comité de standardisation a choisi de ne pas exposer les codes de hachage pour JavaScript (du moins, pas encore) en raison de questions ouvertes sur l’interopérabilité et la sécurité. Ces deux préoccupations sont moindres pour les autres langages.

Les différences de JavaScript, deuxième partie : Prévisibilité et surprises !

On pourrait penser que pour un ordinateur, c’est la moindre des choses que d’avoir un comportement déterministe. Malgré ça, quand j’explique que les boucles qui parcourent les objets Map et Set le font dans l’ordre d’insertion des éléments, j’obtiens souvent des regards surpris. Pourtant, c’est tout à fait déterministe.

Nous sommes habitués à un certain arbitraire pour les tables de hachage. Nous nous y sommes habitués. Cependant, il y a quelques bonnes raisons d’éviter ce côté arbitraire. Comme je l’écrivais en 2012 :

  • Il est démontré que certains programmeurs sont surpris voire confus par cet ordre de parcours arbitraire [1][2][3][4][5][6] ;
  • L’ordre d’énumération des propriétés n’est pas spécifié par ECMAScript. Malgré tout, les principales implémentations semblent avoir convergé vers l’ordre d’insertion, afin d’avoir une certaine compatibilité sur le Web. Cela devient donc préoccupant que le TC39 ne spécifie pas un ordre d’itération déterministe : « le Web ira de l’avant et construira sa règle » [7] ;
  • L’ordre d’itération des tables de hachage peut exposer certains fragments des codes de hachage. Cela implique que l’implémentation de la fonction de hachage prenne en compte ces éléments de sécurité. Il ne faut pas, par exemple, que l’adresse d’un objet puisse être obtenue depuis les fragments du code de hachage (révéler les adresses des objets à du code ECMAScript tierce ne serait a priori pas exploitable mais constituerait tout de même un bogue de sécurité majeur).

Lorsque cela a été discuté en février 2012, j’étais favorable à un ordre d’itération arbitraire. Ensuite, j’ai mis en place une expérience afin de démontrer que suivre l’ordre d’insertion des éléments ralentirait trop les tables de hachage. J’ai écrit quelques microbenchmarks C++.

Les résultats que j’ai obtenus m’ont surpris et c’est pourquoi nous avons aujourd’hui des tables de hachage JavaScript qui fonctionnent en utilisant l’ordre d’insertion des éléments !

De bonnes raisons d’utiliser des collections à liens faibles

Dans l’article précédent, nous avons discuté d’un exemple où il était question d’une bibliothèque d’animation JS. Nous voulions stocker un booléen pour chaque objet du DOM, de cette façon :

if (element.isMoving) {
  smoothAnimations(element);
}
element.isMoving = true;

Rajouter une propriété de cette façon est une mauvaise idée. Nous l’avons expliqué dans le billet précédent.

Nous avons vu que nous pouvions résoudre ce problème à l’aide de symboles. Est-ce qu’il serait possible d’utiliser des ensembles (Set) ? Cela pourrait ressembler à :

if (movingSet.has(element)) {
  smoothAnimations(element);
}
movingSet.add(element);

Il y a un inconvénient : les objets Map et Set gardent une référence forte pour chaque clé-valeur qu’ils contiennent. Cela signifie que si l’élément du DOM est retiré, le ramasse-miettes (NdT : garbage collection ou GC en anglais) ne pourra récupérer la mémoire occupée tant que l’élément n’est pas explicitement retiré de movingSet. Généralement, les bibliothèques qui imposent aux utilisateurs de « nettoyer » après leur passage remportent au mieux un succès mitigé. Si les références ne sont pas retirées, on pourrait avoir des fuites mémoire.

ES6 apporte une solution surprenante à ce problème : il suffit que movingSet soit un WeakSet plutôt qu’un Set et le problème de fuite mémoire est résolu !

Cela signifie qu’il est possible de résoudre ce problème en utilisant des symboles ou des collections à références faibles. Quelle est la meilleure solution ? Discuter ici des avantages et inconvénients de chacune de ces méthodes rendrait ce billet trop long. En résumé, si vous pouvez utiliser un seul symbole pour la durée de vie de la page web, faites-le. En revanche, si vous arrivez à un état où vous accumulez des symboles plus ou moins éphémères, pensez à utiliser les objets WeakMap pour éviter les fuites mémoire.

WeakMap et WeakSet

WeakMap et WeakSet sont spécifiés pour se comporter exactement comme Map et Set mais comportent quelques restrictions :

  • WeakMap supporte uniquement new, .has(), .get(), .set(), et .delete() ;
  • WeakSet supporte uniquement new, .has(), .add(), et .delete() ;
  • Les valeurs enregistrées dans un WeakSet et les clés enregistrées dans un WeakMap doivent être des objets.

Note : aucune des collections à références faibles n’est itérable. Il est impossible d’obtenir des éléments d’une telle collection autrement qu’en utilisant la clé de l’élément qu’on souhaite récupérer.

Ces restrictions finement mises en œuvre permettent au ramasse-miettes de collecter les objets morts, même s’ils étaient utilisés dans ces collections. L’effet obtenu est semblable à celui qu’on pourrait avoir avec des références faibles ou des dictionnaires à clés faibles mais ES6 permet de bénéficier de ces avantages sans exposer le fait que le ramasse-miettes agisse sur les scripts.

Les différences de JavaScript, troisième partie : Masquer le non-déterminisme du ramasse-miettes

Sous le capot, les collections à références faibles sont implémentées comme des tableaux d’ephemeron (voir aussi cet article).

En résumé, un WeakSet ne conserve pas de référence forte vers l’objet qu’il contient. Lorsqu’un objet d’un WeakSet est récupéré par le ramasse-miettes, il est simplement retiré du WeakSet. WeakMap fonctionne de façon similaire. Ces objets ne gardent pas de références fortes vers les clés. Si une clé est « vivante », la valeur associée l’est aussi.

Pourquoi avoir ces restrictions ? Ne suffirait-il pas d’ajouter le mécanisme de référence faible à JavaScript ?

Là encore, le comité de standardisation a été très réticent en ce qui concerne cette fonctionnalité. En effet, il faut éviter au maximum d’exposer un comportement non-déterministe pour les scripts. Des problèmes de compatibilité entre les navigateurs seraient un drame pour le développement web. Utiliser des références faibles expose certains détails d’implémentation du ramasse-miette, lequel dépend nécessairement de la plate-forme spécifique utilisée. Bien entendu, les applications ne devraient pas dépendre de détails propre à la plate-forme mais lorsqu’on utilise les références faibles, il devient difficile de savoir à quel point le script repose sur le fonctionnement du ramasse-miettes.

En comparaison, les collections à références faibles ES6 possèdent beaucoup moins de fonctionnalités que les références faibles mais ces fonctionnalités sont solides comme le roc. Le fait qu’une clé ou une valeur ait été collectée n’est pas observable en tant que tel. Les applications ne peuvent donc pas dépendre du comportement du ramasse-miettes, volontairement ou accidentellement.

Ce scénario, propre au Web, a mené vers ce choix de conception qu’on peut trouver surprenant mais qui a, in fine, amélioré JS.

Quand puis-je commencer à utiliser ces fonctionnalités ?

Ces quatre classes de collection sont disponibles dans Firefox, Chrome, Microsoft Edge et Safari. Pour que cela fonctionne dans d’anciens navigateurs, il faut utiliser une prothèse comme es6-collections.

WeakMap fut d’abord implémenté dans Firefox par Andreas Gal avant qu’il devienne CTO à Mozilla. Tom Schuster a implémenté WeakSet. Quant à moi j’ai implémenté Map et Set. Merci à Tooru Fujisawa d’avoir contribué avec plusieurs patches.

C’est le temps d’une courte pause pour ES6 en détails. Nous avons déjà parcouru beaucoup de chemin mais les fonctionnalités les plus puissantes d’ES6 restent à venir. Rendez-vous le 9 juillet pour un prochain article ! (NdT : le 09 juillet sera la date de la publication de l’article sur https://hacks.mozilla.org, la traduction habituelle suivra quelques jours après sur https://tech.mozfr.org :))