Héritage et chaîne de prototypes

JavaScript peut parfois se révéler déroutant, notamment pour les développeurs habitué-es à des langages fonctionnant avec des classes (Java ou C++ par exemple). JavaScript possède un typage dynamique et ne fournit pas d'implémentation de classe (le mot-clé class a été introduit avec ECMAScript 6 mais ne fournit qu'un sucre syntaxique, JavaScript continue d'avoir un modèle d'héritage basé sur les prototypes).

Dès lors qu'on aborde l'héritage, JavaScript n'utilise qu'un seul concept : les objets. Chaque objet possède un lien, interne, vers un autre objet, appelé prototype. Cet objet prototype possède lui aussi un prototype et ainsi de suite, jusqu'à ce que l'on aboutisse à un prototype null. null, n'a, par définition, aucun prototype et forme donc le dernier maillon de la chaîne des prototypes.

Souvent considéré comme l'un des points faibles de JavaScript, le modèle d'héritage des prototypes se révèle plus puissant que le modèle habituel basé sur les classes. On peut par exemple construire assez facilement un modèle classique à partir d'un modèle basé sur des prototypes, l'inverse en revanche, est beaucoup plus complexe.

L'héritage et la chaîne des prototypes

L'héritage de propriétés

Les objets JavaScript sont des conteneurs de propriétés (appelées propriétés propres de l'objet) et chaque objet possède un lien vers un objet prototype. Lorsqu'on souhaite accéder à une propriété d'un objet, on recherche d'abord parmi les propriétés propres de l'objet, puis parmi celles de son prototype, puis parmi celle du prototype du prototype et ainsi de suite jusqu'à ce qu'une propriété correspondante soit trouvée ou qu'on ait atteint la fin de la chaîne de prototypes.

Afin de suivre la notation standard ECMAScript, on utilisera unObjet.[[Prototype]] pour faire référence au prototype de unObjet. Cela est équivalent à la propriété __proto__ (auparavant dépréciée et introduite formellement avec ECMAScript 6, pour le support des anciennes versions, il est conseillé d'utiliser Object.getPrototypeOf() et Object.setPrototypeOf() pour accéder au [[Prototype]]

Voici un exemple qui décrit ce qui se passe lorsqu'on tente d'accéder à une propriété :

// On a l'objet o, qui a des propriétés propres a et b:
// {a: 1, b: 2}
// o.[[Prototype]] a les propriétés b et c:
// {b: 3, c: 4}
// Enfin, o.[[Prototype]].[[Prototype]] vaut null. C'est 
// donc la fin de la chaîne de prototype. Par définition,
// null n'a pas de [[Prototype]].
// La chaîne de prototypes ressemble donc à :
// {a:1, b:2} ---> {b:3, c:4} ---> null

console.log(o.a); // 1
// Est-ce que 'a' est une propriété propre de o ? Oui, elle vaut 1.

console.log(o.b); // 2
// Est-ce que 'b' est une propriété propre de o ? Oui, elle vaut 2.
// Le prototype possède aussi une propriété 'b' mais celle-ci n'est pas
// utilisée.
// On parle alors de « masque de propriété » (property shadowing)

console.log(o.c); // 4
// Est-ce que 'c' est une propriété propre de o ? Non, on vérifie le prototype
// Est-ce que 'c' est une propriété propre de o.[[Prototype]] ?
// Oui, elle vaut 4.

console.log(o.d); // undefined
// Est-ce que 'd' est une propriété propre de o ? Non, on vérifie le prototype
// Est-ce que 'd' est une propriété propre de o.[[Prototype]] ?
// Non on vérifie son prototype.
// o.[[Prototype]].[[Prototype]] vaut null, on arrête la recherche.
// La propriété n'a pas été trouvée, on renvoie undefined

Renseigner une propriété d'un objet créera une propriété pour l'objet. La seule exception à ce comportement se produit lorsqu'il y a une propriété héritée d'un getter ou setter (respectivement aussi appelés accesseur ou mutateur).

L'héritage de « méthodes »

JavaScript n'utilise pas les méthodes au sens des langages de classes. En JavaScript, toute fonction peut être rattachée à un objet en tant que propriété. Une fonction héritée agit comme n'importe quelle autre propriété, y compris pour le masquage décrit ci-avant (c'est ici une façon de surcharger une méthode).

Lorsqu'une fonction héritée est exécutée, la valeur de this pointe vers l'objet qui hérite et non pas vers le prototype qui dispose de la fonction parmi ses propriétés.

var o = {
  a: 2,
  m: function(b){
    return this.a + 1;
  }
};

console.log(o.m()); // 3
// Quand on appelle o.m, 'this' fait référence à o

var p = Object.create(o);
// p est un objet héritant de o

p.a = 12; // crée une propriété 'a' pour p
console.log(p.m()); // 13
// lorsque p.m est appelé, 'this' fait référence à p.
// Lorsque p hérite de m grâce à o, 'this.a' signifie p.a, avec 'a' la propriété de p

Différentes façons de créer des objets et leurs chaînes de prototypes

Créer des objets grâce à des éléments syntaxiques

var o = {a: 1};

// L'objet o qui vient d'être créé hérite de Object.prototype.
// o ne possède pas de propriété 'hasOwnProperty'
// hasOwnProperty est une propriété de Object.prototype. Donc o hérite de hasOwnProperty grâce à Object.prototype
// Object.prototype a null comme prototype.
// Voici la chaîne de prototypes : 
// o ---> Object.prototype ---> null

var a = ["yo", "bien", "?"];

// Les tableaux (Array) héritent du prototype Array.prototype (possédant les méthodes indexOf, forEach, etc.).
// La chaîne de prototypes est la suivante :
// a ---> Array.prototype ---> Object.prototype ---> null

function f(){
  return 2;
}

// Les fonctions héritent du prototype Function.prototype (avec les méthodes call, bind, etc.):
// f ---> Function.prototype ---> Object.prototype ---> null

Avec un constructeur

Un constructeur JavaScript est « juste » une fonction appelée avec l'opérateur new.

function Graph() {
  this.sommets = [];
  this.aretes = [];
}

Graph.prototype = {
  ajouteSommet: function(v){
    this.sommets.push(v);
  }
};

var g = new Graph();
// g est un objet avec les propriétés 'sommets' et 'aretes'.
// g.[[Prototype]] est la valeur de Graph.prototype quand new Graph() est exécuté.

Avec Object.create()

ECMAScript 5 a permis d'introduire une nouvelle méthode : Object.create(). Appeler cette méthode permet de créer un nouvel objet. Le prototype de cet objet est le premier argument de cette fonction :

var a = {a: 1}; 
// a ---> Object.prototype ---> null

var b = Object.create(a);
// b ---> a ---> Object.prototype ---> null
console.log(b.a); // 1 (hérité)

var c = Object.create(b);
// c ---> b ---> a ---> Object.prototype ---> null

var d = Object.create(null);
// d ---> null
console.log(d.hasOwnProperty); // undefined, en effet d n'hérite pas de Object.prototype

Avec le mot-clé class

ECMAScript 6 a introduit un nouvel ensemble de mots-clés pour implémenter des classes. Bien que cela puisse paraître familier aux développeurs habitués à des langages de classes, cela ne transforme pas le modèle de JavaScript qui reste basé sur les prototypes. Les nouveaux mots-clé incluent class, constructor, static, extends, et super.

"use strict";

class Polygone {
  constructor(hauteur, largeur) {
    this.hauteur = hauteur;
    this.largeur = largeur;
  }
}

class Carré extends Polygone {
  constructor(longueurCôté) {
    super(longueurCôté, longueurCôté);
  }
  get aire() {
    return this.hauteur * this.largeur;
  }
  set longueurCôté(nouvelleLongueur) {
    this.hauteur = nouvelleLongueur;
    this.largeur = nouvelleLongueur;
  }
}

var carré = new Carré(2);

Performances

La recherche ascendante dans la chaîne des prototypes peut avoir un certain impact sur les chaînes de prototypes particulièrement longues. De plus, tenter d'accéder à des propriétés qui n'existent pas se traduira toujours par une analyse de toute la chaîne de prototypes.

Ainsi, lorsqu'on itère sur les propriétés d'un objet, chaque propriété énumérable de la chaîne de prototypes sera listée.

Afin de vérifier si un objet dispose d'une propriété qui lui est propre et qui n'est pas plus haut dans la chaîne, il est nécessaire d'utiliser la méthode hasOwnProperty, dont tous les objets héritent grâce à Object.prototype.

hasOwnProperty est le seul élément JavaScript qui est liée aux propriétés et qui ne parcourt pas la chaîne de prototypes.

Note : Il ne suffit pas de vérifier qu'une propriété vaut undefined. En effet, la propriété peut très bien exister et valoir, à cet instant, undefined.

Une méthode à proscrire : l'extension de prototypes natifs

On voit souvent, à mauvais escient, l'extension du prototype Object.prototype ou d'un autre prototype natif.

Cette technique casse l'encapsulation nécessaire au fonctionnement du langage objet. Bien qu'elle soit utilisée dans certains frameworks connus, comme Prototype.js, il n'y a pas de raisons valables pour augmenter les types natifs avec des fonctionnalités non-standard.

La seule bonne raison de faire ceci est d'améliorer le portage de nouvelles fonctionnalités dans un ancien environnement JavaScript comme par exemple reconstruire Array.forEach etc.

prototype et Object.getPrototypeOf()

JavaScript peut parfois être source de confusion pour les développeurs habitués à Java ou à C++ : en effet, tout est dynamique, se déroule lors de l'exécution et il n'y a aucun concept natif de classe (en dehors du sucre syntaxique vu ci-avant). Il n'y a aucune classe, que des instances.

La fonction A possède une propriété spéciale appelée prototype. Cette propriété spéciale fonctionne avec l'opérateur JavaScript new. Cela permettra de lier l'objet prototype avec la propriété interne [[Prototype]] de la nouvelle instance. Ainsi, quand on fait var a1 = new A(), après avoir créé l'objet en mémoire et avant de lancer la fonction A() avec this utilisant la valeur de l'objet, JavaScript définit a1.[[Prototype]] = A.prototype. Lorsqu'on accède aux propriétés d'un objet, JavaScript vérifie d'abord si elles existent directement sur l'objet, si ce n'est pas le cas, il parcourt son [[Prototype]]. Ainsi, toutes les propriétés définies dans prototype sont partagées par toutes les instances et il est possible de modifier prototype par la suite, ce qui mettra à jour les propriétés liées dynamiquement.

Avec cet exemple :

function A(a){
  this.varA = a;
}

A.prototype = {
  faireQuelqueChose : function(){
    // ...
  }
}

function B(a, b){
  A.call(this, a);
  this.varB = b;
}
B.prototype = Object.create(A.prototype, {
  varB : {
    value: null, 
    enumerable: true, 
    configurable: true, 
    writable: true 
  },
  faireQuelqueChose : { 
    value: function(){ // override
      A.prototype.faireQuelqueChose.apply(this, arguments);
      // ...
    },
    enumerable: true,
    configurable: true, 
    writable: true
  }
});
B.prototype.constructor = B;
var b = new B();

Si on fait var a1 = new A(); var a2 = new A(); puis a1.faireQuelqueChose, cela ferait référence à Object.getPrototypeOf(a1).faireQuelqueChose, ce qui correspond au A.prototype.faireQuelqueChose qu'on a défini (autrement dit Object.getPrototypeOf(a1).faireQuelqueChose == Object.getPrototypeOf(a2).faireQuelqueChose == A.prototype.faireQuelqueChose).

Cela signifique que prototype est utilisé pour les types et que Object.getPrototypeOf() est utilisé pour les instances.

[[Prototype]] est parcouru de façon récursive, c'est-à-dire que le moteur JavaScript cherchera d'abord a1.faireQuelqueChose, puis Object.getPrototypeOf(a1).faireQuelqueChose, Object.getPrototypeOf(Object.getPrototypeOf(a1)).faireQuelqueChose etc. jusqu'à ce que la méthode soit trouvée ou que Object.getPrototypeOf() renvoie null.

Ainsi, lorsqu'on appelle :

var o = new Toto();

JavaScript exécutera des instructions équivalentes à :

var o = new Object();
o.[[Prototype]] = Toto.prototype;
Toto.call(o);

Lorsqu'on utilisera l'instruction suivante :

o.unePropriété;

Le moteur JavaScript vérifiera que o possède la propriété unePropriété. Si ce n'est pas le cas, il vérifiera Object.getPrototypeOf(o).unePropriété et ainsi de suite avec Object.getPrototypeOf(Object.getPrototypeOf(o)).unePropriété et ainsi de suite.

Conclusion

Il est essentiel de comprendre le modèle d'héritage basé sur les prototypes avant d'écrire du code complexe basé sur ces notions. Pour des raisons de performances, il faut être conscient de la longueur de la chaîne des prototypes pour éventuellement la fragmenter si possible. Enfin, les prototypes natifs ne devraients jamais être étendus, à moins que cela soit strictement nécessaire pour la compatibilité avec de nouvelles fonctionnalités de JavaScript.

Étiquettes et contributeurs liés au document

Contributeurs à cette page : SphinxKnight, jacomyal, darul75, teoli
Dernière mise à jour par : SphinxKnight,
Masquer la barre latérale