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é).

Deux semaines auparavant, nous avons vu le nouveau système de classes ajouté avec ES6 et qui permet de gérer les cas simples pour créer des constructeurs d’objets. Nous avons vu comment écrire du code qui ressemble à :

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;
    };
}

Cependant, comme certains l’ont relevé, nous n’avons pas eu le temps de parler de l’étendue des pouvoirs des classes ES6. Comme pour les langages basés sur les classes (C++ ou Java par exemple), ES6 gère également l'héritage : une classe peut en utiliser une autre comme « base » et ajouter des fonctionnalités supplémentaires qui lui sont propres. Allons voir ce que nous réserve cette nouvelle fonctionnalité.

Mais avant de parler des sous-classes et de l’héritage entre classes, ça peut faire du bien de réviser l’héritage des propriétés et la chaîne, dynamique, des prototypes.

L’héritage en JavaScript

Lorsqu’on crée un objet, on peut lui adjoindre des propriétés mais il hérite également des propriétés de ses prototypes. Certains développeurs JavaScript connaissent bien la méthode Object.create qui permet de faire tout ça facilement :

var proto = {
    valeur: 4,
    méthode() { return 14; }
}
var obj = Object.create(proto);

obj.valeur; // 4
obj.méthode(); // 14

De plus, si on ajoute des propriétés à obj qui ont les mêmes noms que celles de proto, les propriétés de obj masqueront celles de proto (NdT : en anglais, on parle de « shadowing »).

obj.valeur = 5;
obj.valeur // 5
proto.valeur; // 4

Un héritage de classes simple

Après ces légères révisions, voyons comment raccrocher la chaîne de prototypes à un objet créé via une classe. Encore un rappel : quand on crée une classe, on construit une nouvelle fonction qui correspond à la méthode du constructeur dans la définition de la classe et qui contient toutes les méthodes statiques. On crée aussi un objet qui sera le prototype de cette fonction créée et qui contiendra toutes les méthodes des instances. Pour créer une nouvelle classe qui hérite de l’ensemble des propriétés statiques, il faudra que l’objet fonction correspondant à cette nouvelle classe hérite de l’objet fonction de la classe parente. De la même façon, il faudra que l’objet prototype de la nouvelle fonction hérite de l’objet prototype de la classe parente afin que la nouvelle classe bénéficie des méthodes d’instances.

Cette description est un peu difficile à digérer, voyons ce que ça donne dans un exemple utilisant des éléments de syntaxe connus. Ensuite, nous ajouterons un élément syntaxique simple mais qui permettra de rendre le code plus agréable à lire.

Si on poursuit avec notre exemple précédent et qu’on considère une classe Forme qu’on veut décliner :

class Forme {
    get couleur() {
        return this._couleur;
    }
    set couleur(c) {
        this._couleur = parseColorAsRGB(c);
        this.modifApportée();  // on repeindra le canvas après
    }
}

Lorsqu’on essaie d’écrire le code pour faire ça, on se confronte au même problème qu’on a eu dans le billet précédent sur les classes avec les propriétés statiques : il n’y a aucun élément syntaxique qui permette de changer le prototype d’une fonction lors de sa définition. Bien qu’on puisse résoudre ça à coup de Object.setPrototypeOf, cette approche est limitée en termes d’optimisation et de performances et on préfèrerait avoir un moyen de créer une fonction avec le prototype voulu.

class Cercle {
    // Comme vu avant
}
// Rattacher les propriétés des instances 
Object.setPrototypeOf(Cercle.prototype, Forme.prototype);
// Rattacher les propriétés statiques
Object.setPrototypeOf(Cercle, Forme);

C’est assez moche. La syntaxe des classes a été ajoutée afin qu’on puisse encapsuler toute la logique d’un objet à un seul endroit plutôt que d’avoir à « rattacher » des choses a posteriori. Java, Ruby et d’autres langages ont une façon de déclarer qu’une déclaration de classe est une sous-classe d’une autre, ça devrait également être le cas pour JavaScript. Pour cela, on peut utiliser le mot-clé extends et écrire :

class Cercle extends Forme {
    // Comme vu avant
}

On peut mettre n’importe quelle expression après extends tant que celle-ci correspond à un constructeur valide avec une propriété prototype. On pourra donc mettre :

  • une autre classe
  • des fonctions semblables à des classes provenant de frameworks gérant l’héritage
  • une fonction normale
  • une variable qui contient une fonction ou une classe
  • un accès à une propriété d’un objet
  • un appel de fonction

On peut même utiliser null si on souhaite que les instances n’héritent pas d’Object.prototype.

Des oiseaux ? Des avions ? Non… Ce sont les super propriétés !

On peut donc faire des sous-classes, on peut hériter des propriétés et dans certains cas, nos méthodes masqueront (ou surchargeront) les méthodes qui auraient du être héritées. Comment faire quand on voudra contourner ce masque ?

Imaginons qu’on veuille écrire un sous-classe de la classe Cercle qui gère un homothétie avec un facteur donné. Pour cela, on pourrait écrire :

class CercleHomothétique extends Cercle {
    get rayon() {
        return this.facteur * super.rayon;
    }
    set rayon() {
        throw new Error("Le rayon d'un CercleHomothétique est constant." +
                        "Modifier plutôt le facteur d'échelle.");
    }

    // Code pour gérer le facteur d'échelle
}

On remarque ici que l’accesseur pour le rayon utilise super.rayon. Ce nouveau mot-clé, super, permet d’utiliser les propriétés du prototype, y compris quand celles-ci sont surchargées. super[expr] fonctionne également. L’accès aux propriétés avec super peut être utilisé dans n’importe quelle fonction définie avec la syntaxe de définition d’une méthode. Même si ces méthodes sont utilisées en dehors de l’objet original, l’accès aux propriétés est toujours lié à l’objet sur lequel la méthode a été définie initialement. Autrement dit, si on stocke la méthode dans une nouvelle variable, cela ne changera pas le comportement de super.

var obj = {
    toString() {
        return "MonObjet : " + super.toString();
    }
}

obj.toString(); // MonObjet : [object Object]
var a = obj.toString;
a(); // MonObjet : [object Object]

Créer des sous-classes pour les objets natifs

Avec tout ça, il est possible d’étendre les objets natifs offerts par le langage. Les structures de données natives ajoutent une richesse énorme à JavaScript, ce sera très utile de pouvoir créer de nouveaux types qui tirent parti de cette richesse. Cet aspect était une notion clé lors de la conception du système de sous-classes. Imaginons qu’on ait l’idée folle de créer un tableau versionné… On veut pouvoir effectuer des changements, les sauvegarder (« commit »), revenir à une version précédente (« rollback »). On peut utiliser l’objet Array et en créer une sous-classe :

class VersionedArray extends Array {
    constructor() {
        super();
        this.history = [[]];
    }
    commit() {
        // On sauvegarde les changements en historique
        this.history.push(this.slice());
    }
    revert() {
        this.splice(0, this.length, this.history[this.history.length - 1]);
    }
}

Les instances de VersionedArray contiennent quelques propriétés importantes. Ce sont des instances d’Array avec l’ensemble des attributs utiles que sont map, filter et sort. Array.isArray() les considèrera comme des tableaux, même la propriété length sera automatiquement mise à jour pour ces instances. Encore mieux : les fonctions qui renvoient un nouveau tableau (par exemple Array.prototype.slice()) renverront bien un VersionedArray !

Les constructeurs de sous-classes

Dans la méthode constructor de l’exemple précédent, vous avez pu voir le super() utilisé. Quel est son rôle ?

Dans les modèles de classes traditionnels, les constructeurs sont utilisés pour initialiser l’état des instances de la classe. Chaque sous-classe qui suit est ensuite responsable de l’initialisation de l’état associé aux spécificités de la sous-classe. On veut pouvoir enchaîner ces appels afin que les sous-classes puissent partager ce code d’initialisation pour la classe qu’elles étendent.

Pour appeler un constructeur d’une classe parente, on utilise là encore le mot-clé super mais cette fois comme s’il s’agissait d’une fonction. Cette syntaxe n’est valide qu’au sein de méthodes constructor pour des déclarations de classes qui utilisent extends. Avec super, on peut donc réécrire notre classe Forme :

class Forme {
    constructor(couleur) {
        this._couleur = couleur;
    }
}
class Cercle extends Forme {
    constructor(couleur, rayon) {
        super(couleur);

        this.rayon = rayon;
    }

    // Comme précédemment
}

En JavaScript, on a l’habitude d’écrire des constructeurs qui agissent sur l’objet this pour installer des propriétés et initialiser l’état interne. Normalement, l’objet this est créé lorsqu’on appelle le constructeur avec new, comme si on appelait Object.create() sur la propriété prototype du constructeur. Cependant, certains objets natifs sont organisés différemment en interne. Les tableaux (Array) par exemple, sont organisés différemment en mémoire. Étant donné qu’on souhaite vouloir créer des sous-classes pour les types natifs, on laisse le constructeur le plus « haut » allouer l’objet this. Ainsi, si c’est un objet natif, on aura l’organisation voulue et si c’est un constructeur normal, on aura l’objet this par défaut qu’on attend.

Cela entraîne une bizarrerie dans la façon dont this est lié aux constructeurs des sous-classes. Avant d’avoir exécuté le constructeur de base et donc d’avoir pu allouer l’objet this : on n’a pas de valeur pour this. Pour cette raison, tous les accès aux constructeurs de sous-classes lèveront une exception ReferenceError s’ils ont lieu avant l’appel au constructeur parent avec super().

Comme on l’a vu dans le billet précédent sur les classes, il est possible de ne pas écrire de méthode constructeur pour une classe. C’est également valable pour les sous-classes et leurs constructeurs. Si on ne déclare pas de méthode constructor, cela sera équivalent à :

constructor(...args) {
    super(...args);
}

Parfois, les constructeurs n’interagissent pas avec l’objet this. À la place, ils créent un objet d’une autre façon, l’initialisent et le renvoient directement. Si c’est le cas, il n’est pas nécessaire d’utiliser super. N’importe quel constructeur peut renvoyer un objet directement, qu’un constructeur parent ait été appelé ou non.

new.target

L’allocation effectuée par la classe la plus « haute » a une autre conséquence un peu étrange : parfois la classe la plus haute ne sait pas quel type d’objet allouer. Imaginons qu’on ait écrit un framework de gestion d’objets et qu’on ait une classe de base Collection dont certaines des sous-classes sont des tableaux (Array) et d’autres des tableaux associatifs (Maps). Au moment où on utilise le constructeur Collection, on est incapable de dire quelle sorte de sous-classe nous intéresse.

Puisqu’on peut avoir des sous-classes pour les types natifs, lorsqu’on exécute le constructeur natif, en interne, on doit connaître le prototype de la classe originale. Sans ça, on serait incapable de créer un objet avec les bonnes méthodes d’instances. Pour résoudre ce cas avec les « Collections », un nouvel élément de syntaxe a été ajouté à JavaScript pour exposer ces informations : la méta-propriété new.target. Elle correspond au constructeur qui a été appelé avec new. Lorsqu’on appelle une fonction avec new, new.target prendra la valeur de la fonction appelée. La valeur de new.target est transmise lorsqu’on utilise super au sein du constructeur.

Un exemple vaut mieux qu’un long discours pour expliquer :

class Toto {
    constructor() {
        return new.target;
    }
}
class Truc extends Toto {
    // This is included explicitly for clarity. It is not necessary
    // to get these results.
    constructor() {
        super();
    }
}
// Toto directement appelé, new.target correspond à Toto
new Toto(); // Toto

// 1) Truc directement appelé, new.target correspond à  Truc
// 2) Truc appelle Toto via super(), new.target est toujours Truc
new Truc(); // Truc

Nous avons résolu le problème avec la Collection décrite ci-dessus, parce que le constructeur de la collection peut juste vérifier new.target et l’utiliser pour les sous-classes, puis déterminer quel type natif utiliser.

new.target est valide dans n’importe quelle fonction. Si la fonction n’est pas appelée avec new, il vaudra undefined.

Un mélange détonant

En espérant que vous ayez survécu à ces nouvelles fonctionnalités. Merci de vous être accroché-e. Prenons un peu de temps pour se demander s’ils permettent de résoudre les problèmes correctement. De nombreux développeurs se sont demandé si c’était une bonne chose d’utiliser l’héritage pour coder une fonctionnalité. Vous pourriez croire que l’héritage n’est pas aussi efficace que la composition pour construire des objets, ou que la propreté de cette nouvelle syntaxe ne vaut pas l’absence de flexibilité du design qui en résulte, en comparaison avec l’ancien modèle basé sur les prototypes. Il est indéniable que les mixins sont devenus le mode d’expression dominants pour créer des objets qui partagent du code de façon extensible. C’est le cas pour une bonne raison : ils fournissent une méthode facile permettant de partager du code sans relation avec le même objet, sans avoir à comprendre comment ces deux éléments sans relation peuvent s’intégrer dans la même structure d’héritage.

Il existe de nombreuses croyances sur ce sujet, mais je crois qu’il y a quelques aspects notables. Premièrement, l’ajout des classes comme une fonctionnalité du langage ne rend pas leur utilisation obligatoire. Deuxièmement, tout en étant aussi important, l’ajout des classes comme une fonctionnalité du langage ne signifie pas qu’elles sont toujours le meilleur moyen de résoudre les problèmes d’héritage ! En réalité, certains problèmes seront plus facilement résolus en utilisant l’héritage et les prototypes. En fin de compte, les classes sont juste un nouvel outil à votre disposition, ce n’est pas le seul outil que vous avez et ce n’est pes nécessairement le meilleur.

Si vous souhaitez continuer à utiliser les mixins, vous aurez besoin de classes qui puissent hériter de plusieurs sources. Malheureusement, ce serait assez bouleversant de modifier le modèle d’héritage maintenant. Pour cette raison, JavaScript n’implémente pas l’héritage multiple de classes. Ceci étant dit, il existe une solution hybride qui permet d’utiliser les mixins dans un framework basé sur les classes.

Les fonctions suivantes, par exemple, sont basées sur l’idiome mixin extend :

function mix(...mixins) {
    class Mix {}

    // On ajoute toutes les méthodes et accesseurs
    // des mixins à la classe Mix.
    for (let mixin of mixins) {
        copyProperties(Mix, mixin);
        copyProperties(Mix.prototype, mixin.prototype);
    }
    
    return Mix;
}
function copyProperties(target, source) {
    for (let key of Reflect.ownKeys(source)) {
        if (key !== "constructor" && key !== "prototype" && key !== "name") {
            let desc = Object.getOwnPropertyDescriptor(source, key);
            Object.defineProperty(target, key, desc);
        }
    }
}

Cette fonction mix peut désormais être utilisée pour créer une classe parente composée. Il n’y a pas besoin de créer une relation d’héritage explicite entre les différents mixins. Si par exemple, on travaille sur un outil d’édition collaboratif et que les actions d’édition sont enregistrées et que le contenu de ces actions doit être sérialisé, on pourra utiliser la fonction mix pour définir la classe DistributedEdit :

class DistributedEdit extends mix(Loggable, Serializable) {
    // Méthodes d'événements
}

C’est ainsi qu’on mélange les deux approches efficacement. C’est aussi facile de voir comment étendre ce modèle pour gérer des classes mixin qui possèdent des classes parentes : il suffit de passer la classe parente dans le mélange et que la classe renvoyée étende cette classe parente.

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

OK, on a beaucoup parlé des sous-classes et de décliner les objets natifs, mais peut-on les utiliser dès maintenant ?

À peu près. Parmi les principaux navigateurs, Chrome possède la plupart des fonctionnalités évoquées aujourd’hui. En mode strict, vous devriez pouvoir faire tout ce que nous avons vu à part décliner Array. Les autres types natifs sont fonctionnels mais Array est un peu particulier et ce n’est donc pas une surprise que ça prenne du temps. En ce qui concerne Firefox, je suis en train d’implémenter ces fonctionnalités et espère atteindre le même objectif (tout sauf les déclinaisons d’Array) très bientôt. Le bug 1141863 contient plus d’informations à ce sujet, il devrait arriver dans la version Nightly de Firefox d’ici quelques semaines.

Edge supporte super mais pas les sous-classes pour les types natifs. Safari ne supporte aucune de ces fonctionnalités.

Sur ce sujet, les transpileurs ne sont pas d’un grand secours. Ils permettent de créer des classes et d’utiliser super mais ils n’ont aucun moyen pour assister à la création de sous-classes des types natifs. En effet, il faut que le moteur JavaScript permette d’obtenir les instances de la classe de base pour les méthodes natives (par exemple quand on utilise Array.prototype.splice sur une classe).

Bon, ce fut un long billet ! La semaine prochaine, Jason Orendorff revient pour discuter du système des modules ES6 en détails.