View this page in english

Aug 05 2011

Tout ce que vous avez toujours voulu savoir sur l’héritage javascript sans jamais l’avoir cherché

Category: JavaScript,Node.js

Contrairement aux langages orientés objet comme C++, Java ou PHP , Javascript ne possède pas explicitement de notion de classe, laissant le soin délicat au programmeur de l'implémenter à sa convenance.

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' ] }
    
On comprends mieux pourquoi ce type d'héritage n'est plus utilisé par JQuery et Prototype.js… Il est en général moins dangereux d'utiliser une copie récursive (deep copy).

« Deep Copy » copie

Le principe est le même que pour shallowCopy mais la copie est récursive, comme son nom l'indique. Voici le code :
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;
};
    
C'est sur cette idée qu'est basé l'héritage dans JQuery actuellement et le test précédent fonctionne maintenant sans surprise :
> 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);
    
Ce qui donne :
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

Cette méthode est un mélange des deux approches précédentes, elle permet d'utiliser à la foi l'héritage prototypal pour dupliquer l'objet et d'ajouter des propriétés par copie (j'ai choisi la deep copy mais on peut vouloir utiliser autre chose) :
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;
  };
}
    
Le code ci-dessus définissant le triangle 3-4-5 s'écrit alors plus lisiblement car la structure est plus stricte :
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);
      }});
    }
            
    L'exemple du triangle 3-4-5 fonctionne aussi parfaitement avec cette version de Object.prototype.extend car l'ajout de la propriété uber permet à nouveau de remonter la chaîne d'héritage.

Multi-héritage

Au-delà de l'utilité de cette fonctionnalité, on peut rapidement coder en Javascript une routine de multi-héritage ; en voici un exemple simpliste :
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;
  };
}
    
On l'utiliserait ainsi
var obj = Object.mextend(parent1, parent2, etc, augment)
.

Héritage parasitaires

Ce type d'héritage, encore dû à Douglas Crockford, est davantage un concept architectural de codage qu'un type d'héritage. L'héritage parasitaires consiste à créer la fonction adéquat pour chaque objet héritant d'un ou plusieurs autres objets ; concrètement la fonction s'occupe de cloner l'objet parent, de le surcharger, de l'augmenter et le revoie, le plus simplement du monde. Un exemple s'impose tout de même :
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);
  
Dans ce cas, c'est la fonction triangle qui s'occupe d'étendre twoD après avoir effectué un travail préliminaire. Cette méthode est très souple d'utilisation puisque que le travail préliminaire peut être aussi complexe que l'on veut avant de renvoyer l'objet étendu et, finalement, le twoD.extend(self) aurait put être remplacé par n'importe quelle autre routine d'héritage.

Héritage de constructeurs

Le classique « prototype chaining »

Il s'agit de l'héritage par défaut décrit par le standard ECMAScript, le code tient en deux lignes :
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;
    
Dont la mise en œuvre pourrait être :
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);
    
Dont le résultat est celui attendu :
triangle.area : 6
triangle.toString() : triangle
Mais si vous ajoutez à ce code les lignes suivantes
var shape = new Shape();
console.log('shape.toString() :', '' + shape);
    
vous serez surpris de voir que shape.toString() renvoie 'Triangle' au lieu de 'Shape'. Cela est dû au fait que l'affectation du prototype est effectuée par référence et non par valeur, donc toute modification du prototype enfant se répercute sur le prototype parent ; ce qui est, somme toute, très gênant.

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());
    
dont la sortie correspond bien à ce qui est attendu :
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

Pour hériter seulement des propriétés propres initialisés par le constructeur il suffit d'appeler dans le constructeur enfant le constructeur parent dans le scope courant.
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);
    
La sortie, dans laquelle on voit bien que la méthode toString définie dans le prototype de Shape n'est pas héritée par 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);
    
Résultat :
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