Nous avons vu dans le précédent article, la manière d’implémenter les notions de classes abstraites et d’interfaces en JavaScript. J’ai également brièvement rappelé que l’héritage multiple n’existait pas en JavaScript, mais comme vous vous en doutez, il existe une façon de remédier à ce problème, c’est ce que nous allons voir dans cet article.
Rafraîchissons-nous la mémoire
Afin de s’attaquer au problème de l’héritage multiple, il est important de faire quelques rappels sur la notion de classe et d’héritage en JavaScript.
Les classes
Pour ceux qui ont découvert JavaScript avec l’arrivée ECMAScript 6, déclarer une classe se fait simplement, comme n’importe quel langage orienté objet, à l’aide du mot-clé class
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | class Shape { constructor(w, h) { this.width = w; this.height = h; } setWidth(w) { this.width = w; } setHeight(h) { this.height = h; } } |
Cette notation, bien que pratique, cache la vraie nature de JavaScript : JavaScript n’est pas un langage orienté objet, mais un langage orienté prototype. Voyons la définition de la programmation orientée prototype par notre cher ami Wikipédia :
La programmation orientée prototype est une forme de programmation orientée objet sans classe, basée sur la notion de prototype. Un prototype est un objet à partir duquel on crée de nouveaux objets.
Concrètement, on a un objet qui sert de modèle structurel permettant de définir des propriétés et des méthodes aux objets se basant sur celui-ci. Cet objet peut évoluer dynamiquement à l’exécution, contrairement à une classe qui est figée à la compilation.
Avant l’arrivée d’ECMAScript 6, la déclaration d’une « classe » se faisait comme suit :
1 2 3 4 5 6 7 8 9 10 11 12 | var Shape = function(w, h) { this.width = w; this.height = h; }; Shape.prototype.setWidth = function(w) { this.width = w; }; Shape.prototype.setHeight = function(h) { this.height = h; }; |
Cette notation est plus explicite et permet de comprendre un peu mieux ce qui se cache derrière le mot-clé class
d’ECMAScript 6.
On a donc une fonction Shape
qui est utilisée comme constructeur et un objet prototype
attaché à celle-ci. Afin de comprendre à quoi sert l’objet prototype
et comment est utilisé le constructeur en interne, nous allons voir grosso modo ce qui se passe lorsque l’on crée un objet à l’aide de l’opérateur new
:
- Un nouvel objet est créé à partir de l’objet
prototype
du constructeur; - Le constructeur est appelé en lui fournissant comme valeur
this
l’objet nouvellement créé; - L’objet est retourné.
L’implémentation naïve de l’opérateur new
pourrait ressembler à ça :
1 2 3 4 5 | function newOperator(constr) { var o = Object.create(constr.prototype); constr.apply(o, Array.prototype.slice.call(arguments, 1)); return o; } |
Lorsqu’un objet est créé, celui possède automatiquement une propriété __proto__
qui est un objet pouvant contenir des propriétés et des méthodes. Dans le cas de l’utilisation de l’opérateur new
, cette propriété __proto__
contient l’objet prototype
du constructeur. Voyons ceci avec notre classe Shape
:
1 2 3 4 5 6 7 8 | var shape = new Shape(10, 15); shape.__proto__ /* setHeight : ƒ (h) setWidth : ƒ (w) constructor : ƒ (w, h) __proto__ : Object */ |
La propriété __proto__
de notre instance possède bien les méthodes setHeight
et setWidth
de l’objet prototype du constructeur Shape
.
Lors de l’appel d’une propriété ou d’une méthode d’un objet, JavaScript va chercher celle-ci sur l’objet en question, puis dans son prototype, puis dans le prototype du prototype et ainsi de suite, jusqu’à ce qu’il trouve la propriété ou la méthode en question ou qu’il aboutisse à un prototype null
. C’est ce qu’on appelle la chaîne des prototypes.
C’est cette chaîne des prototypes qui permet l’héritage en JavaScript.
L’héritage
Pour voir, en détail comment l’héritage fonctionne nous allons reprendre l’exemple de notre classe Shape
et créer une nouvelle classe Rectangle
qui hérite de celle-ci :
1 2 3 4 5 6 7 8 9 10 11 | // 1 var Rectangle = function(w, h) { Shape.call(this, w, h); // 2 }; Rectangle.prototype = Object.create(Shape.prototype); // 3 Rectangle.prototype.constructor = Rectangle; // 4 Rectangle.prototype.getArea = function() { return this.width * this.height; }; |
- Nous créons un constructeur
Rectangle
; - Nous appelons dans celui-ci, le constructeur
Shape
via la méthodecall
en lui passant comme premier paramètrethis
suivit des paramètresw
eth
; - Nous créons un nouvel objet qui aura comme propriété
__proto__
le prototype du constructeurShape
via la méthodeObject.create
puis nous l’affectons au prototype du constructeurRectangle
; - Comme nous venons d’affecter le prototype de
Shape
avec le nouvel objet créé, la propriétéconstructor
n’existe plus dans l’objetprototype
il faut donc recréer cette propriété en y affectant le bon constructeur à savoirRectangle
. (Je n’entrerais pas dans les détails de l’utilisation de cette propriétéconstructor
, ce n’est pas utile pour la suite).
Nous créons ainsi la chaîne des prototypes, pour ceux qui auraient du mal à comprendre voici un schéma qui explique le fonctionnement :
Lorsque l’on crée une instance de Rectangle
via l’opérateur new
et que nous faisons appel à la fonction setWidth
par exemple, JavaScript recherche cette fonction dans l’objet que nous venons de créer, puis dans le prototype de Rectangle
, puis dans le prototype de Shape
. Notre instance hérite donc bien des méthodes de Shape
.
Notre exemple donnerait ceci en ECMAScript 6 :
1 2 3 4 5 6 7 8 9 | class Rectangle extends Shape { constructor(w, h) { super(w, h); } getArea() { return this.width * this.height; } } |
Cette syntaxe est beaucoup plus simple, mais elle masque le véritable fonctionnement de l’héritage.
Après cette longue introduction, attaquons-nous au problème de l’héritage multiple.
L’héritage multiple
En JavaScript, l’héritage multiple n’existe pas, quand on voit le schéma précédent on comprend rapidement pourquoi : l’objet prototype
, d’un constructeur, possède une seule propriété __proto__
qui pointe vers un unique prototype.
Il n’est donc pas possible d’avoir un « lien » vers plusieurs classes. Bien entendu, une solution existe pour ce souci : les mixins.
Mix… quoi?
Demandons à notre cher Wikipédia la définition d’un mixin :
En programmation orientée objet, un mixin ou une classe mixin est une classe destinée à être composée par héritage multiple avec une autre classe pour lui apporter des fonctionnalités. C’est un cas de réutilisation d’implémentation. Chaque mixin représente un service qu’il est possible de greffer aux classes héritières.
Concrètement, un mixin est un objet ou une classe, possédant des propriétés et/ou des méthodes, permettant d’enrichir une autre classe par le principe de composition.
Voyons un exemple, imaginons que l’on ait une classe Shape
(comme vu précédemment) et une autre classe Drawable
. Nous souhaitons maintenant avoir une nouvelle classe DrawableShape
, pour cela nous allons utiliser un mixin :
1 2 3 4 5 | let Drawable = { draw() { console.log('Draw a shape with width = ' + this.width + ' and height = ' + this.height); } }; |
Oui c’est tout simple ! On a simplement un objet avec une méthode draw
. Il ne reste plus qu’à créer notre classe DrawableShape
qui hérite de Shape
et qui sera enrichi via notre mixin :
1 2 3 4 5 | class DrawableShape extends Shape { // ... } Object.assign(DrawableShape.prototype, Drawable); |
Nous utilisons la fonction Object.assign
qui permet simplement de copier notre mixin dans l’objet prototype
de notre nouvelle classe. Comme nous pouvons le voir dans l’exemple ci-dessous cela fonctionne parfaitement :
1 2 3 | const drawableShape = new DrawableShape(5, 10); drawableShape.draw(); // console.log : Draw a shape with width = 5 and height = 10 |
Contrairement à une classe, un mixin n’est pas destiné à être utilisé seul, mais avec une classe pour laquelle il étend ses méthodes.
Malheureusement, avec cette façon de faire, la chaîne des prototypes n’est pas respectée. On copie directement les propriétés et méthodes du mixin dans l’objet prototype
qui est donc directement modifié.
Utilisation des classes ECMAScript 6
Voyons dès à présent une autre façon d’écrire un mixin tout en préservant la chaîne des prototypes à l’aide des classes d’ECMAScript 6. L’idée derrière est d’écrire nos mixins non plus comme de simples objets, mais comme une fonction permettant de créer une classe (une factory). Voyons cela en reprenant notre mixin Drawable
:
1 2 3 4 5 6 7 | let Drawable = function(superclass) { return class extends superclass { draw() { console.log(this.width, this.height); } }; }; |
Notre fonction prend en paramètre une classe superclass
et renvoie une nouvelle classe qui hérite de celle-ci.
On peut donc réécrire notre classe DrawableShape
comme ceci :
1 2 3 | class DrawableShape extends Drawable(Shape) { // ... } |
C’est tout de même plus joli :). Il est également possible d’ajouter un nouveau mixin comme ceci :
1 2 3 4 5 6 7 8 9 10 11 12 13 | let Moveable = function(superclass) { return class extends superclass { move() { console.log('Move'); } }; }; class MoveableDrawableShape extends Moveable(Drawable(Shape)) { // ... } const shape = new MoveableDrawableShape(5, 10); // Les fonctions de Moveable, Drawable et Shape sont disponibles |
L’idée est d’imbriquer les mixins entre eux, un peu comme des poupées russes et ainsi créer la chaîne des prototypes :
Par contre si l’on souhaite utiliser plusieurs mixins, cela peut rapidement devenir illisible. Nous allons donc améliorer tout ça en utilisant une fonction qui permettra d’appliquer une liste de mixins sur une classe (non obligatoire) fournit en paramètre :
1 2 3 4 5 6 7 | let mix = function(superclass) { return { with(...mixins) { return mixins.reduce((c, mixin) => mixin(c), superclass || class {}); } }; }; |
Cette fonction renvoi donc un objet avec une fonction with permettant de construire la chaîne des prototypes comme précédemment.
Réécrivons donc notre classe MoveableDrawableShape
:
1 2 3 | class MoveableDrawableShape extends mix(Shape).with(Moveable, Drawable) { // ... } |
Comme on peut le voir, c’est beaucoup plus lisible.
Pour aller plus loin
On a vu dans cet article comment fonctionnait l’héritage en JavaScript, ainsi qu’une solution permettant de répondre au souci de l’héritage multiple à l’aide des mixins. Les mixins peuvent également être utilisés pour simuler des interfaces en JavaScript (voir mon article à ce sujet) :
1 2 3 4 5 6 7 | let DrawableInterface = function(superclass) { return class extends superclass { draw() { throw new Error('You must implement this function'); } }; }; |
Je pense qu’après la lecture de cet article vous aurez une bonne vision du fonctionnement de l’héritage en JavaScript.
Pour les personnes souhaitant approfondir le sujet sur les mixins, je vous invite à lire l’excellent article de Justin Fagnani à ce sujet : http://justinfagnani.com/2015/12/21/real-mixins-with-javascript-classes/
Voici également deux librairies qui permettent l’utilisation des mixins :
- Mics : https://github.com/justinfagnani/mixwith.js
- mixwith.js: https://github.com/justinfagnani/mixwith.js
Bonus :
L’héritage est malheureusement trop souvent utilisé à tort et à travers. Bien souvent la composition est une meilleure alternative à son utilisation, je vous invite donc à lire ces articles (en anglais) :
- Composition over Inheritance,
- How to Use Classes and Sleep at Night
- Master the JavaScript Interview: What’s the Difference Between Class & Prototypal Inheritance?
- Object Composition Patterns in JavaScript
Je pense que les prochains articles porteront sur les designs pattern en JavaScript. Ce sera une série de petits articles sur un design pattern en particulier. En attendant je vous souhaite de bonnes fêtes de fin d’année !
Je suis lead developer dans une boîte spécialisée dans l’univers du streaming/gaming, et en parallèle, je m’éclate en tant que freelance. Passionné par l’écosystème JavaScript, je suis un inconditionnel de Node.js depuis 2011. J’adore échanger sur les nouvelles tendances et partager mon expérience avec les autres développeurs.
Si vous avez envie de papoter, n’hésitez pas à me retrouver sur Twitter, m’envoyer un petit email ou même laisser un commentaire.
WOW! merci beaucoup, super article, clair et instructif! 🙂
J’ai un petit souci, quand je fait:
const Drawable = function(superclass) {
return class extends superclass {
draw() {
console.log(this.width, this.height);
}
};
};
class Shape {
constructor(w, h) {
this.width = w;
this.height = h;
}
setWidth(w) {
this.width = w;
}
setHeight(h) {
this.height = h;
}
}
class Rectangle extends Drawable(Shape) {
constructor(w, h) {
super(w, h);
}
getArea() {
return this.width * this.height;
}
}
const r = new Rectangle(5, 10);
console.log(r.__proto__);
console.log(r.__proto__.__proto__);
console.log(r.__proto__.__proto__.__proto__);
console.log(r.__proto__.__proto__.__proto__.__proto__);
J’obtiens en console:
Rectangle {}
Shape {}
Shape {}
{}
Du coup ca respecte pas trop le schema 🙁
J’aurai voulu obtenir:
Rectangle { constructor: [Function: Rectangle], getArea: [Function] }
Drawable { constructor: [Function: Drawable], draw: [Function] }
Shape { setWidth: [Function], setHeight: [Function] }
{}
Que j’ai reussi avec la vielle method:
const Shape = function(w, h) {
this.width = w;
this.height = h;
};
Shape.prototype.setWidth = function(w) {
this.width = w;
};
Shape.prototype.setHeight = function(h) {
this.height = h;
};
const drawable = function(superclass) {
const Drawable = function(w, h) {
superclass.call(this, w, h);
};
Drawable.prototype = Object.create(Shape.prototype);
Drawable.prototype.constructor = Drawable;
Drawable.prototype.draw = function() {
return console.log(this.width, this.height);
};
return Drawable;
}
const Rectangle = function(w, h) {
drawable(Shape).call(this, w, h);
};
Rectangle.prototype = Object.create(drawable(Shape).prototype);
Rectangle.prototype.constructor = Rectangle;
Rectangle.prototype.getArea = function() {
return this.width * this.height;
};
const r = new Rectangle(5, 10);
console.log(r.__proto__);
console.log(r.__proto__.__proto__);
console.log(r.__proto__.__proto__.__proto__);
console.log(r.__proto__.__proto__.__proto__.__proto__);
Comment je peux obtenir le meme resultat via es6?
Pourquoi en utilisant le mot clef
class
il me sort justeRectangle {}
par exemple sans les methodes deRectangle
?Merci beaucoup beaucoup 🙂
Tu as essayé sur quel navigateur ? Car le code ES6 est correct, j’obtiens bien :
{ constructor: ƒ, getArea: ƒ }
{constructor: ƒ, draw: ƒ }
{ constructor: ƒ, setWidth: ƒ, setHeight: ƒ }
{ constructor: ƒ, __defineGetter__: ƒ, __defineSetter__: ƒ, hasOwnProperty: ƒ, __lookupGetter__: ƒ, … }
J’ai créé un fichier blabla.js que je lance: node blabla.js
node version: 8.6.0
Je sais pas sur quel navigateur tu testes mais il se mouille pas
{ constructor: ƒ, getArea: ƒ }
, il prenne pas le risque d ecrire en sorti{constructor: Rectangle, getArea: f}
😂OK 🙂 j’ai trouver mon probleme. En es6 je n’avais pas donner de nom de classe a
Drawable
. Du coup:const drawable = function(superclass) {
return class Drawable extends superclass {
draw() {
console.log(this.width, this.height);
}
};
};
Ainsi j’obtiens bien
Rectangle { }
Drawable { }
Shape { }
{}
Le probleme qu’il reste c’est qu’en mode « non es6 » j’obtiens:
Rectangle { constructor: [Function: Rectangle], getArea: [Function] }
Drawable { constructor: [Function: Drawable], draw: [Function] }
Shape { setWidth: [Function], setHeight: [Function] }
{}
La raison est plus simple que prevu, lorsqu’on cree une class es6, le descripteurs
enumerable
est false par default, si je veux le meme resultat il me faudra (pour Shape mais pour Rectangle et Drawable aussi):const Shape = function(w, h) {
this.width = w;
this.height = h;
};
Object.defineProperties(Shape.prototype, {
setWidth: {
value: function(w) {
this.width = w;
},
enumerable: true
},
setHeight: {
value: function(h) {
this.height = h;
},
enumerable: true
}
})
Pour obtenir:
Rectangle { }
Drawable { }
Shape { }
{}
🙂 🙂
Effectivement les méthodes d’une classe ES6 ne sont pas énumérables 🙂 . Voici un article intéressant à ce sujet d’ailleurs : http://2ality.com/2015/10/enumerability-es6.html
👍