Toutes les problématiques concernant la notion d'héritage en Javascript repose sur une très bonne compréhension de la dimension orienté prototype de Javascript ainsi que des différents mécanisme d'héritage propre à ce langage, finalement le plus mal connu des langages de programmation. Si vous ne maîtrisez pas parfaitement la notion de prototype, il est impératif de prendre quelques minutes pour s'imprégner des notions décrites dans cet excellent article ;).
Nous verrons dans cet article les différentes manières de faire de l'héritage d'objets littéraux, de constructeurs (on ne peut jamais à proprement parler de classe en Javascript) et traiterons le cas particulier des modules NodeJS.
Sauf mention contraire, tous les exemples de code donnés par la suite sont interprétés par V8 JavaScript Engine via NodeJS.
Les différents types d'héritage
Héritage d'objets littéraux (par opposition aux objets constructeurs)
Nous aborderons dans cette section l'héritage d'objets simples ne prenant pas en compte à proprement parlé des propriétés pouvant être héritées d'un constructeur.
« Shallow Copy » copie
Il s'agit du type d'héritage le plus simple qui consiste à affecter chaque attributs d'un objet dans un nouvel objet. C'est ce type d'héritage qu'utilisaient les précédentes versions de JQuery et Prototype.js. Voici le code :
function shallowCopy(src) { var out = {}; for (var key in src) { out[key] = src[key]; } out.uber = src; return out; }
Ci-dessous, la fonction shallowCopy en action.
> var foo = { ... object: {a: "I'm foo.obj.a", b: "I'm foo.obj.b"}, ... 'method': function() {return "I'm foo.obj.method";}, ... array: 'abcd'.split('') ... }; > > var shallowCpOfFoo = shallowCopy(foo); > shallowCpOfFoo.object; { a: 'I\'m foo.obj.a', b: 'I\'m foo.obj.b' } > shallowCpOfFoo.method(); 'I\'m foo.obj.method' > shallowCpOfFoo.array; [ 'a', 'b', 'c', 'd' ]
Tout semble être pour le mieux dans le meilleur des mondes mais il apparaît que la modification de l'objet enfant retourné par shallowCopy peut avoir des répercussions sur l'objet parent, ce qui, en général, n'est pas souhaitable :
> shallowCpOfFoo.object.a = 'Modified from shallowCpOfFoo.obj.a'; > foo.object; { a: 'Modified from shallowCpOfFoo.obj.a', b: 'I\'m foo.obj.b' } > shallowCpOfFoo.array.push('Added from shallowCpOfFoo.obj.a'); > foo.array; [ 'a', 'b', 'c', 'd', 'Added from shallowCpOfFoo.obj.a' ] > shallowCpOfFoo.uber { object: { a: 'Modified from shallowCpOfFoo.obj.a', b: 'I\'m foo.obj.b' }, method: [Function], array: [ 'a', 'b', 'c', 'd', 'Added from shallowCpOfFoo.obj.a' ] }
« Deep Copy » copie
var deepCopy = function(o, c) { var out = c || {}; for (var key in o) { if (typeof o[key] === 'object') { out[key] = (o[key].constructor === Array ? [] : {}); deepCopy(o[key], out[key]); } else { out[key] = o[key]; } }; return out; };
> var foo = { ... object: {a: "I'm foo.obj.a", b: "I'm foo.obj.b"}, ... 'method': function() {return "I'm foo.obj.method";}, ... array: 'abcd'.split('') ... }; > var shallowCpOfFoo = deepCopy(foo); > shallowCpOfFoo.object; { a: 'I\'m foo.obj.a', b: 'I\'m foo.obj.b' } > shallowCpOfFoo.method(); 'I\'m foo.obj.method' > shallowCpOfFoo.array; [ 'a', 'b', 'c', 'd' ] > shallowCpOfFoo.object.a = 'Modified from shallowCpOfFoo.obj.a'; > foo.object; { a: 'I\'m foo.obj.a', b: 'I\'m foo.obj.b' } > shallowCpOfFoo.array.push('Added from shallowCpOfFoo.obj.a'); > foo.array; [ 'a', 'b', 'c', 'd' ]
L'héritage prototypal de Crockford
Qu'on ne s'y trompe pas, bien qu'il s'agisse d'un héritage prototypal il ne s'agit pas ici d'un héritage qui permette de faire de l'héritage de constructeurs. C'est une méthode utilisée et recommandée par Douglas Crockford dans un célèbre article que je ne peux que vous conseiller de lire. Il est basée sur l'idée que les objets hérites des objets d'après une chaîne prototypale. Ainsi, au lieu d'utiliser des classes comme dans les langages objets classiques, il met à profit le côte prototypal de Javascript afin de structurer ses programmes les plus complexes de façon rationnelle, efficace et optimale. Dans un tel style de programmation le constructeur est souvent remplacer par une méthode ini ou __construct directement dans l'objet littéral. Voici le code proposé par Crockford auquel je rajoute une propriété uber afin de pouvoir accéder à l'objet parent :
if (typeof Object._create !== 'function') { Object._create = function(o) { var out; function F() {}; F.prototype = o; out = new F(); out.uber = o; return out; }; }
Voici un exemple de mise en œuvre, inspiré d'un passage de l'excellent livre Object-Oriented JavaScript, qui permet de voir comment utiliser ce genre d'héritage.
var shape = { name: 'shape', ini: function(options) { for (var key in options) { this[key] = options[key]; } }, toString: function() { return this.name; } }; var twoD = Object._create(shape); twoD.ini({name: 'twoD'}); twoD.toString = function() { return this.uber.toString() + ', ' + this.name; }; var triangle = Object._create(twoD); triangle.ini = function(options) { this.uber.ini.call(this, options); var a = this.a, b = this.b, c = this.c; var s = 0.5 * (a + b + c); this.area = Math.sqrt(s * (s - a) * (s - b) * (s - c)); }; triangle.ini({name: 'triangle', side: 3, a: 3, b: 4, c: 5}); console.log('triangle.area :', triangle.area); console.log('triangle.toString() :', '' + triangle);
triangle.area : 6 triangle.toString() : shape, twoD, triangle
On notera en particulier :
- la redéfinition de la méthode toString de l'objet twoD qui permet de remonter toute la chaîne d'héritage ;
- le
this.uber.ini.call(this, options);
qui permet d'appeler le pseudo-constructeur ini de l'objet parent dans le scope de l'objet enfant.
Concrètement il serait intéressant de pouvoir à la fois hériter d'un objet et l'étendre, c'est la raison de la méthode suivante.
Extension prototypal
if (typeof Object.deepCopy !== 'function') { Object.deepCopy = function(o, c) { var out = c || {}; for (var key in o) { if (typeof o[key] === 'object') { out[key] = (o[key].constructor === Array ? [] : {}); deepCopy(o[key], out[key]); } else { out[key] = o[key]; } }; return out; }; } if (typeof Object.extend !== 'function') { Object.prototype.extend = function(stuff) { var out; function F() {}; F.prototype = this; out = new F(); out.uber = this; Object.deepCopy(stuff, out); return out; }; }
var shape = { ini: function() { // Do some stuff }, name: 'shape', toString: function() { return this.name; } }; var twoD = shape.extend({ name: 'twoD', toString: function() { return this.uber.toString() + ', ' + this.name; } }); var triangle = twoD.extend({ getArea: function() { if (!this.area) { var a = this.a, b = this.b, c = this.c; var s = 0.5 * (a + b + c); this.area = Math.sqrt(s * (s - a) * (s - b) * (s - c)); } return this.area; }, name: 'triangle', side: 3, a: 3, b: 4, c: 5 }); console.log('triangle.getArea() : ', triangle.getArea()); console.log('triangle.toString() : ', '' + triangle);
Quelques notes importantes sur cette méthode :
- dans la méthode Object.prototype.extend, il peut être intéressent de faire de
simples « shallow copy » sur le prototype car il n'y a aucune raison pour
que certaines propriétés soient propres et d'autres héritées du prototype.
Ça donnerait quelque chose comme :
if (typeof Object.extend !== 'function') { Object.prototype.extend = function(stuff) { var out; function F() {}; [this, stuff].forEach(function(o) { for (var key in o) { F.prototype[key] = o[key]; } }); out = new F(); out.uber = this; return out; }; }
-
depuis la version 5 de ECMAScript, Javascript implémente la méthode Object.create qui est plus complète que la méthode Object.prototype.extend que j'expose ici mais qui est beaucoup plus lourde à utiliser et encore supportée par très peu de navigateurs. Les heureux utilisateurs de Node.js peuvent utiliser la méthode spawn proposée par Tim Caswell dans l'article Prototypal Inheritance, elle est basée sur Object.create et Object.defineProperty.
Ceci étant, comme j'aime bien pouvoir accéder à l'objet parent depuis l'objet enfant je rajoute une petite touche personnelle à son dernier exemple :
if (typeof Object.extend !== 'function') { // Modified from http://howtonode.org/prototypical-inheritance Object.defineProperty(Object.prototype, "extend", {value: function (props) { var defs = {}, key; for (key in props) { if (props.hasOwnProperty(key)) { defs[key] = {value: props[key], enumerable: true, writable:true}; } } defs.uber = {value: this}; return Object.create(this, defs); }}); }
Multi-héritage
if (typeof Object.mextend !== 'function') { Object.mextend = function() { var out, l = arguments.length, i, key, ubers = []; function F() {}; for (i = 0; i < l ; ++i) { for (key in arguments[i]) { F.prototype[key] = arguments[i][key]; } if (i + 1 < l) { ubers.push(arguments[i]); } } out = new F(); out.ubers = ubers; return out; }; }
var obj = Object.mextend(parent1, parent2, etc, augment).
Héritage parasitaires
var shape = { ini: function() {/* Do some stuff */}, name: 'shape', toString: function() {return this.name;} }; var twoD = shape.extend({ name: 'twoD', toString: function() {return this.uber.toString() + ', ' + this.name;} }); function triangle(a, b, c) { var self = {}, s; self.name = 'triangle'; s = 0.5 * (a + b + c); self.area = Math.sqrt(s * (s - a) * (s - b) * (s - c)); return twoD.extend(self); } var t = triangle(3, 4, 5); console.log('triangle.area : ', t.area); console.log('triangle.toString() : ', '' + t);
Héritage de constructeurs
Le classique « prototype chaining »
Child.prototype = new Parent(arguments); Child.prototype.constructor = Child;
Voyons un exemple d'utilisation, toujours avec notre fameux triangle 3-4-5 mais sous forme de constructeur cette fois :
function Shape() {} // Move all shared material to the prototype Shape.prototype.ini = function() {/* Do some stuff */}; Shape.prototype.name = 'Shape'; Shape.prototype.toString = function() {return this.name;}; function TwoD() {} // Inheritance of Shape TwoD.prototype = new Shape(); TwoD.prototype.constructor = TwoD; // Move all shared material to the prototype after inheritance TwoD.prototype.name = 'TwoD'; function Triangle(a, b, c) { var s = 0.5 * (a + b + c); // This value is not shared by all instances of Triangle this.area = Math.sqrt(s * (s - a) * (s - b) * (s - c)); } Triangle.prototype = new TwoD(); Triangle.prototype.constructor = Triangle; Triangle.prototype.name = 'triangle'; var triangle = new Triangle(3, 4, 5); console.log('triangle.area : ', triangle.area); console.log('triangle.toString() : ', '' + triangle);
Afin d'optimiser les performances les attributs partagés par toutes les instances (on dirait statiques en POO) sont placés dans le prototype du constructeur et les valeurs propres à l'instance sont initialisées dans le constructeur.
En procédant ainsi non seulement l'architecture est plus rationnelle mais cela permet de mettre en œuvre le type d'héritage décrit à la section suivante qui ne permet d'hériter que du prototype du constructeur parent ; le code est alors encore plus optimisé et cohérent avec l'approche prototypale de Javascript. En effet, si un attribut est propre à l'instance, il semble logique, tout au moins cohérent, qu'il soit initialisé à l'instanciation et non hérité.
L'héritage prototypal strict
Comme expliqué précédemment il est utile de différencier dans un constructeur ce qui sera partagé entre toutes les instances de ce qui ne le sera pas et, de fait, de ne permettre que l'héritage du prototype du constructeur. Voici la solution naïve, donnée à titre d'apprentissage :
Child.prototype = Parent.prototype; Child.prototype.constructor = Child;
function Shape() {} // Move all shared material to the prototype Shape.prototype.ini = function() {/* Do some stuff */}; Shape.prototype.name = 'Shape'; Shape.prototype.toString = function() {return this.name;}; function TwoD() {} // Inheritance of Shape TwoD.prototype = Shape.prototype; TwoD.prototype.constructor = TwoD; // Move all shared material to the prototype after inheritance TwoD.prototype.name = 'TwoD'; function Triangle(a, b, c) { var s = 0.5 * (a + b + c); // This value is not shared by all instances of Triangle this.area = Math.sqrt(s * (s - a) * (s - b) * (s - c)); } Triangle.prototype = TwoD.prototype; Triangle.prototype.constructor = Triangle; Triangle.prototype.name = 'triangle'; var triangle = new Triangle(3, 4, 5); console.log('triangle.area :', triangle.area); console.log('triangle.toString() :', '' + triangle);
triangle.area : 6Mais si vous ajoutez à ce code les lignes suivantes
triangle.toString() : triangle
var shape = new Shape(); console.log('shape.toString() :', '' + shape);
Qu'à cela ne tienne, il suffit de passer par un constructeur intermédiaire afin de casser la référence. Ajoutons à cela la possibilité d'accéder au prototype parent via notre cher saint uber et nous sommes « les rois du pétrole ».
if (typeof Function.extend !== 'function') { Function.prototype.extend = function(Parent) { var F = function() {}; F.prototype = Parent.prototype; this.prototype = new F(); this.prototype.constructor = this; this.prototype.uber = Parent.prototype; }; }
Mise en œuvre, simple et efficace :
function Shape() {} // Move all shared material to the prototype Shape.prototype.ini = function() {/* Do some stuff */}; Shape.prototype.name = 'Shape'; Shape.prototype.toString = function() {return this.name;}; function TwoD() {} // Inheritance of Shape TwoD.extend(Shape); // Move all shared material to the prototype after inheritance TwoD.prototype.name = 'TwoD'; TwoD.prototype.toString = function() { return this.uber.toString() + ', ' + this.name; }; function Triangle(a, b, c) { var s = 0.5 * (a + b + c); // This value is not shared by all instances of Triangle this.area = Math.sqrt(s * (s - a) * (s - b) * (s - c)); } Triangle.extend(TwoD); Triangle.prototype.name = 'triangle'; var triangle = new Triangle(3, 4, 5); console.log('triangle.area :', triangle.area); console.log('triangle.toString() :', '' + triangle); console.log('towD.toString() :', '' + new TwoD()); console.log('shape.toString() :', '' + new Shape());
triangle.area : 6
triangle.toString() : Shape, TwoD, triangle
towD.toString() : Shape, TwoD
shape.toString() : Shape
J'aime cette approche car elle est assez optimisée, elle oblige à mieux structurer son application en différenciant les attributs partagés des attributs propres et peut-être aussi parce que c'est celle retenu par la bibliothèque YUI.
Ceci dit il peut aussi être utile de faire le contraire, c'est à dire d'hériter seulement des attributs propres du parent ; c'est l'objet de la section suivante.
Héritage par appel au constructeur parent
La commande qui permet cela est le classique
Parent.apply(this, arguments);.
Voyons en un exemple simple d'utilisation :
function Shape(options) { this.name = 'Shape'; for (var key in options) { this[key] = options[key]; } } Shape.prototype.toString = function() {return this.name;}; function Triangle() { Shape.apply(this, arguments); var a = this.a, b = this.b, c = this.c; var s = 0.5 * (a + b + c); this.area = Math.sqrt(s * (s - a) * (s - b) * (s - c)); } var triangle = new Triangle({name: "foo", a: 3, b: 4, c: 5}); console.log('shape.toString() :', '' + new Shape()); console.log('triangle.area :', triangle.area); console.log('triangle.toString() :', '' + triangle);
shape.toString() : Shape
triangle.area : 6
triangle.toString() : [object Object]
On peut alors vouloir mettre en œuvre un mélange entre cet héritage et le précédent comme expliqué dans la section suivante.
Héritage par appel au constructeur parent et héritage prototypal strict
Il s'agit ici de mettre en œuvre les deux techniques d'héritage précédentes afin d'hériter à la fois des attributs propres initialisés par le constructeur et du prototype parent.
if (typeof Function.extend !== 'function') { Function.prototype.extend = function(Parent) { var F = function() {}; F.prototype = Parent.prototype; this.prototype = new F(); this.prototype.constructor = this; this.prototype.uber = Parent.prototype; }; } function Shape(options) { this.name = 'Shape'; for (var key in options) { this[key] = options[key]; } } Shape.prototype.toString = function() {return this.name;}; function Triangle() { // Inheritance of own properties set by the constructor's parent Shape.apply(this, arguments); var a = this.a, b = this.b, c = this.c; var s = 0.5 * (a + b + c); this.area = Math.sqrt(s * (s - a) * (s - b) * (s - c)); } // Inheritance of the prototype constructor Triangle.extend(Shape); var triangle = new Triangle({name: "foo", a: 3, b: 4, c: 5}); console.log('shape.toString() :', '' + new Shape()); console.log('triangle.area :', triangle.area); console.log('triangle.toString() :', '' + triangle);
shape.toString() : Shape
triangle.area : 6
triangle.toString() : foo
Le cas particulier de l'héritage des modules Node.js
Travail en cours…Sources et compléments
- l'excellent livre Object-Oriented JavaScript: Create scalable, reusable high-quality JavaScript applications and libraries ;
- ECMA-262-3 in detail. Chapter 7.2. OOP: ECMAScript implementation par Dmitry Soshnikov
- Classical Inheritance in JavaScript par Douglas Crockford
- Prototypal Inheritance in JavaScript par Douglas Crockford
- Simple JavaScript Inheritance par John Resig
- Harmony classes proposal (ongoing specification work of Ecma TC39)