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é).
Aujourd’hui, retour aux choses simples après quelques billets précédents assez complexes. Pas de façon complètement nouvelle d’écrire du code avec des générateurs, pas d’objets Proxy
superpuissants qui viendraient s’accrocher à l’algorithmique interne du langage JavaScript, pas de nouvelles structures de données qui vous éviteraient de réaliser vos propres solutions. À la place, nous allons parler d’une solution syntaxique et idiomatique pour résoudre un vieux problème : la création de constructeurs d’objets en JavaScript.
Le problème
Supposons que l’on veuille créer l’exemple le plus emblématique de la conception orientée objet : la classe Cercle. Imaginons que nous sommes en train de créer un Cercle pour une petite bibliothèque Canvas. Entre autres choses, on pourrait avoir envie de savoir comment réaliser les opérations suivantes :
- Dessiner un Cercle donné dans un Canvas donné.
- Se souvenir du nombre de Cercles que l’on a créés jusque-là.
- Se souvenir du rayon d’un Cercle en particulier et garantir qu’il ne puisse être modifié.
- Calculer la surface d’un Cercle donné.
JavaScript tel qu’on le connait actuellement nous indique que l’on doit tout d’abord créer le constructeur de cet objet sous forme d’une fonction. Ensuite, il est nécessaire d’ajouter toutes les propriétés que l’on pourrait vouloir appliquer à notre objet directement sur cette fonction. Enfin, il va falloir remplacer la propriété prototype
de ce constructeur par un objet ad hoc. Cet objet prototype contiendra toutes les méthodes nécessaires aux instances de notre classe. Même pour un exemple très simple, une fois que vous en aurez fini, vous allez vous retrouver avec ce genre de code un peu écœurant.
function Cercle(rayon) { this.rayon = rayon; Cercle.nbrDeCercles++; } Cercle.dessiner = function dessiner(cercles, canvas) { /* Code pour dessiner dans le Canvas */ } Object.defineProperty(Cercle, "nbrDeCercles", { get: function() { return !this._count ? 0 : this._count; }, set: function(val) { this._count = val; } }); Cercle.prototype = { surface: function surface() { return Math.pow(this.rayon, 2) * Math.PI; } }; Object.defineProperty(Cercle.prototype, "rayon", { get: function() { return this._radius; }, set: function(rayon) { if (!Number.isInteger(rayon)) throw new Error("Le rayon du cercle doit être un entier."); this._radius = rayon; } });
Non seulement le code est lourdingue mais il est égalment assez peu intuitif. Il nécessite d’avoir une excellente compréhension du fonctionnement des fonctions et de la façon dont les propriétés et méthodes sont utilisables par les instances d’objets. Cela vous semble compliqué ? Pas d’inquiétude. Tout l’enjeu de cet article est de vous montrer une methode bien plus simple pour écrire un code équivalent.
Syntaxe de définition des méthodes
Pour commencer à nettoyer tout ça, ES6 propose une nouvelle syntaxe afin d’ajouter des propriétés exotiques à un objet. S’il a été assez facile d’ajouter la méthode surface
à l’objet Cercle.prototype
ci-avant, ça n’a pas été une mince affaire de gérer les getters/setters de la propriété rayon
. Dans la mesure où JavaScript est de plus en plus utilisé avec une approche orientée objet, de nombreuses personnes ont eu envie de définir des méthodes plus simples pour ajouter de tels accesseurs et mutateurs. Ce dont nous avions besoin, c’était une façon d’ajouter des « méthodes » à un objet aussi simple que obj.prop = methode
, sans la lourdeur de Object.defineProperty
. On veut pouvoir simplement:
- Ajouter une fonction ordinaire comme propriété d’un objet
- Ajouter une fonction générateur comme propriété d’un objet
- Ajouter une fonction accesseur ou mutateur comme propriété d’un objet
- Ajouter n’importe laquelle des fonctions ci-avant comme si l’on avait utilisé la syntaxe à crochets [] sur l’objet fini, ce que l’on appelle les « noms de propriétés générés ».
Certaines de ces actions étaient impossibles jusqu’à présent. Par exemple, il n’y avait aucun moyen de définir un accesseur ou un mutateur via une affectation directe à obj.prop
. Il a donc fallu rajouter une nouvelle syntaxe. Désormais, vous pouvez écrire le code suivant :
var obj = { // Les méthodes sont désormais ajoutées sans le mot clé "function", // le nom de la propriété devenant le nom de la fonction. methode(args) { ... }, // Pour créer une méthode qui soit un générateur, ajoutez juste un '*', comme d'habitude. *genMethode(args) { ... }, // Les accesseurs et mutateurs peuvent désormais être créés directement sur place // avec l'aide de |get| et |set|. Cependant ils ne peuvent pas être des générateurs. // Notez qu'un accesseur défini de cette façon ne doit avoir aucun argument. get propName() { ... }, // Notez qu'un mutateur défini de cette façon doit avoir exactement un argument. set propName(arg) { ... }, // Pour pouvoir gérer le quatrième cas ci-avant, la syntaxe à crochets [] est autorisée // partout où un nom de fonction est attendu. Cela permet d'utiliser des symboles, // des appels de fonction, des concaténations de chaînes et toute autre méthode pouvant // être évaluée comme un identifiant de propriété valide. L'exemple ci-après crée une // méthode mais cela fonctionne également pour les accesseurs, mutateurs et générateurs. [functionQuiRenvoieUnNomDePropriété()] (args) { ... } };
En utilisant cette nouvelle syntaxe, on peut reécrire notre exemple de la manière suivante :
function Cercle(rayon) { this.rayon = rayon; Cercle.nbrDeCercles++; } Cercle.dessiner = function dessiner(cercles, canvas) { /* Code pour dessiner dans le Canvas */ } Object.defineProperty(Cercle, "nbrDeCercles", { get: function() { return !this._count ? 0 : this._count; }, set: function(val) { this._count = val; } }); Cercle.prototype = { area() { return Math.pow(this.rayon, 2) * Math.PI; }, get rayon() { return this._radius; }, set rayon(radius) { if (!Number.isInteger(radius)) throw new Error("Le rayon du cercle doit être un entier."); this._radius = radius; } };
Si vous voulez pinailler, ce code n’est pas tout à fait identique au précédent. Les méthodes définies via la notation littérale sont configurables et énumérables, alors que les accesseurs et mutateurs définis précédemment ne sont ni configurables ni énumérables. Dans les faits, c’est quelque chose que l’on remarque rarement et j’ai décidé d’éluder cette question pour rester simple.
Quoi qu’il en soit, c’est déjà beaucoup mieux, n’est-ce pas ? Malheureusement, même armé de cette nouvelle syntaxe, on ne peut pas faire grand-chose pour la définition de Cercle
puisque l’on doit toujours définir une fonction. Or, il n’est pas possible de définir les propriétés de cette fonction pendant qu’on définit la fonction elle-même.
Syntaxe de définition des classes
Bien que se soit mieux, ça ne satisfait toujours pas les personnes qui veulent un solution plus simple pour la conception orientée objet en JavaScript. Leur argument est le suivant : les autres langages possèdent une structure faite pour gérer la conception orientée objet : les classes
Ok. Ajoutons-donc les classes.
Ce que l’on veut, c’est un mécanisme qui nous permettra d’ajouter des méthodes à un constructeur identifié et d’ajouter des méthodes à son prototype, méthodes qui seront donc accessibles aux instances de la classe. Vu qu’on a déjà notre nouvelle syntaxe de définition des méthodes, autant l’utiliser. On a juste besoin d’un moyen de différencier les méthodes génériques, utilisables pour toutes les instances de classe, et les méthodes spécifiques à chaque instance. En C++ ou en Java, le mot-clé pour faire cette différence c’est static
. Il en vaut bien un autre, utilisons celui-ci.
À présent, il serait bien utile d’avoir un moyen pour identifier la méthode qui, parmi toutes les autres, sera le constructeur de la classe. En C++ ou en Java, cette fonction a le même nom que la classe sans type de retour. Puisque JavaScript n’a, de toute façon, pas de type de retour et que l’on a besoin d’une propriété constructor
pour des questions de rétro-compatibilité, on va appeler cette méthode constructor
.
En faisant tout ça, on peut réécrire notre classe Cercle comme elle aurait toujours dû l’être :
class Cercle { constructor(rayon) { this.rayon = rayon; Cercle.nbrDeCercles++; }; static dessiner(cercle, canvas) { // Code pour dessiner dans le Canvas }; static get nbrDeCercles() { return !this._count ? 0 : this._count; }; static set nbrDeCercles(val) { this._count = val; }; surface() { return Math.pow(this.rayon, 2) * Math.PI; }; get rayon() { return this._radius; }; set rayon(radius) { if (!Number.isInteger(radius)) throw new Error("Le rayon du cercle doit être un entier."); this._radius = radius; }; }
Wow ! Non seulement, nous avons pu regrouper tout ce qui est propre à notre Cercle mais en plus c’est si… simple. C’est clairement mieux que ce que nous avions au départ. Malgré tout, vous allez sans doute avoir des questions ou bien vous allez trouver certains cas particuliers. J’ai fait de mon mieux pour anticiper et répondre à certains d’entre eux :
- Pourquoi des point-virgules ? — Dans une tentative de faire en sorte que ça ressemble à des classes « traditionnelles », nous avons choisi d’utiliser un séparateur classique. Vous n’aimez pas ça ? C’est optionnel, les delimiteurs ne sont pas obligatoires.
- Comment faire si je ne veux pas de constructeur mais que je veux quand même ajouter des méthodes à une classe ? — Pas de problème. La méthode
constructor
est complètement facultative. Si vous n’en définissez aucune, ça va se comporter comme si vous aviez écritconstructor() {}
. - Un constructeur peut-il être un générateur ? — Nope ! Ajouter un constructeur qui n’est pas une fonction normale engendrera une erreur
TypeError
. Ça vaut pour les générateurs mais également pour les accesseurs et mutateurs. - Puis-je définir un constructeur via un nom de propriété généré ? — Hélas non. C’est vraiment difficile a gérer, on n’a donc même pas essayé. Si vous définissez une méthode avec un nom de propriété généré qui se trouve être
constructor
, vous obtiendrez bien une methode appeléconstructor
mais ce ne sera pas le constructeur de la classe. - Que se passe-t-il si je change la valeur de Cercle ? Cela posera-t-il des problèmes si j’utilise
new Cercle
? Non ! Comme pour les expressions de fonctions, les classes reçoivent une structure interne pour un nom donné. Cette structure ne peut pas être modifiée depuis l’extérieur, quelle que soit la valeur utilisée pour modifier la variableCercle
dans la portée courante.Cercle.nbrDeCercles++
du constructeur continuera de fonctionner normalement. - Certes, mais je pourrais passer un littéral objet directement comme argument d’une fonction. Ces nouvelles « classes » ne fonctionneraient plus, non ? – Heureusement, ES6 apporte également les expressions de classe. Celles-ci peuvent être nommées ou anonymes et elles se comporteront exactement comme ce qu’on a vu avant sauf qu’elles ne créeront pas de variable dans la portée de la déclaration.
- Et au fait, qu’en est-il de l’énumérabilité et du reste ? – Les gens souhaitaient pouvoir installer des méthodes sur des objets mais n’obtenir que les propriétés de données lors d’une énumération, ce qui est logique. Pour cette raison, les méthodes ajoutées aux classes sont configurables mais pas énumérables.
- Euh, attendez ? Où sont mes variables d’instances et mes constantes statiques ? – Bien vu. À l’heure actuelle, elles n’existent pas avec les classes ES6. Mais c’est bien parti pour les avoir par la suite : le sujet a déjà été abordé lors des réunions de spécifications par moi et plusieurs personnes favorables à l’idée d’avoir à la fois des valeurs statiques et des constantes utilisables avec cette syntaxe de classe. D’autres discussions sont à venir sur ce sujet.
- OK, ça a l’air super ! Puis-je utiliser cette fonctionnalité ? – Pas exactement. Certaines prothèses existent (notamment grâce à Babel) et vous pouvez actuellement vous amuser avec les classes. Malheureusement, cela va prendre encore un peu de temps avant qu’elles ne soient implémentées au sein des principaux navigateurs. Tout ce dont nous avons parlé aujourd’hui a été implémenté par votre serviteur et est disponible dans la version Nightly de Firefox. Les classes sont implémentées dans Edge et Chrome mais ne sont pas activées par défaut. Il semblerait qu’à l’heure actuelle, il n’y ait pas d’implémentation pour Safari.
- Java et C++ permettent de créer des sous-classes et utilisent le mot-clé
super
. Cet article n’en parle pas, est-ce que JavaScript permet de faire pareil ? Oui, toutefois c’est un sujet suffisamment vaste pour un autre billet. Nous reviendrons prochainement pour parler des sous-classes et explorer le pouvoir des classes JavaScript.
Je n’aurais pas été capable d’implémenter les classes sans l’aide apportée par Jason Orendorff et Jeff Walden et la relecture de code qu’ils ont effectuée.
La semaine prochaine, Jason Orendorff reviendra pour expliquer en détails les nouvelles instructions ES6 let
et const
.