ES3, Chap 8. — Les constructeurs et les prototypes en JavaScript

Ce billet fait partie de la collection ES3 dans le détail et en constitue le Chapitre 8.

Chaque couche est reliée à la précédente si bien que chaque entité obtient les caractéristiques de la précédente.
Chaque couche est reliée à la précédente si bien que chaque entité obtient les caractéristiques de la précédente.

Cet article va traiter de deux points importants de l'implémentation de la programmation orientée objet du point de vue de JavaScript, les fonctions constructeurs et la chaîne des prototypes.

Introduction

Le JavaScript est un langage de programation orienté objet supportant l'héritage par délégation basé sur les prototypes. À ce titre donc, il existe des fonctions constructeurs en rapport avec l'utilisation du mot clé new pour la création d'objet d'une part, et il existe d'autre par un mécanisme appelé chaîne des prototypes s'occupant de gérer l'héritage. Nous allons étudier ces deux aspects dans cet article. En complément nous en profiterons pour étudier les méthodes d'accès à un objet qui font appel à la chaîne des prototypes et d'où la notion d'héritage découle.

Constructeur

Les objets en JavaScript sont créés à l'aide de ce que l'on appel : les constructeurs.

Un constructeur est une fonction qui crée et initialise l'objet nouvellement créé.

Pour la création (allocation de mémoire) de l'objet, une méthode interne [[Construct]] est utilisée. Le comportement de cette méthode interne est défini par l'implémentation. Tous les constructeurs de fonctions utilisent cette méthode pour allouer de la mémoire aux nouveaux objets.

Pour l'initialisation de l'objet, c'est cette fois la méthode interne [[Call]] qui s'en occupe en appelant une fonction dédiée dans le contexte de l'objet nouvellement créé.

Notez que d'un point de vu utilisateur, seule la phase d'initialisation est accessible et programmable. Cette objet nouvellement créé est accessible dans cette fonction d'initialisation via this. C'est cet objet this qui sera implicitement retourné. Nous pouvons, puisque nous avons la main sur cette phase d'initialisation, retourner autre chose que cet objet nouvellement créé si l'envie nous en prend avec return :

Code JavaScript

// Cette fonction est un counstructeur...
function Character() {
    // mettre à jour l'objet nouvellement créé
    this.level = 10;
    // mais retourner un objet différent
    return [1, 2, 3];
}

// ...si elle est appelée avec le mot-clé `new`
var tiz = new Character();
console.log(tiz.level, tiz); `undefined`, `[1, 2, 3]`

En faisant référence à l'algorithme de création de fonction dont nous avons discuté dans le chapitre 5, nous voyons que cette fonction est un objet natif se trouvant parmi d'autres propriétés internes comme [[Construct]] et [[Call]] ou propriétés explicites comme prototype, la référence au prototype des futurs objets.

Pseudo-code

F = new NativeObject() // objet natif innacessible

F.[[Class]] = "Function"

.... // autres propriétés

F.[[Call]] = <réference à la fonction> // la fonction elle-même

F.[[Construct]] = internalConstructor // constructeur interne général pour l'allocation mémoire

.... // autres propriétés

// prototype de l'object crée par le constructeur de F
__objectPrototype = new Object()
__objectPrototype.constructor = F // `{DontEnum}`
F.prototype = __objectPrototype

Ainsi, un objet qui peut être activé par l'appel des parenthèses ( et ) est appelé une fonction, et possède donc cette propriété [[Call]]. Il y a également la propriété [[Class]] qui est responsable de la distinction entre un objet simple et un objet activable puisque, dans le cas d'une fonction, celui-ci vaut "Function". L'opérateur typeof, sur ces objets retourne la valeur function. Cependant, ceci est vrai pour des objets natifs. Dans le cas d'objets hôtes activables, l'opérateur typeof peut retourner d'autres valeurs. Exemple avec window.console.log(...) dans IE :

Code JavaScript

// dans IE : "Object", "object", dans d'autres : "Function", "function"
console.log(Object.prototype.toString.call(window.console.log));
console.log(typeof window.console.log); // "Object"

La méthode interne [[Construct]] est activée avec l'opérateur new appliqué à la fonction dites constructeur. Comme nous l'avons vu, c'est cette méthode qui est responsable de l'allocation mémoire et de la création des objets. S'il n'y a aucun arguments, l'appel entre parenthèse peut être omis :

Code JavaScript

function Character(level) { // constructeur `Character`
    this.level = level || 10;
}

// sans arguments, l'appel
// avec les parenthèse peut être omis
var agnes = new Character; // ou `new Character()`;
console.log(agnes.level); // `10`

// passage explicite
// de la valeur de l'argument `level`
var edea = new Character(20);
console.log(edea.level); // `20`

Et comme nous le savons également la valeur de this à l'intérieur du constructeur (lors de la phase d'initialisation) est affectée d'un nouvel objet.

Algorithme de création d'objet

Le comportement de la méthode [[Construct]] peut être décrit ainsi :

Pseudo-code

O = new NativeObject()

// la propriété `[[Class]]` est mise à `"Object"`,
// c.-à-d. représente un simple objet
O.[[Class]] = "Object"

// Prend comme référence de prototype
// la valeur de `F.prototype` de la fonction
var __objectPrototype = F.prototype

// on associe le prototype `O.[[Prototype]]` de l'objet créé
if (isAnObject(__objectPrototype)) {
    O.[[Prototype]] = __objectPrototype
} else {
    O.[[Prototype]] = Object.prototype
}

// initialisation du nouvel objet créé
// utilisation de `F.[[Call]]`;
// affectiation à la valeur de `this` de l'objet `O`
// `initialParameters` sont les arguments passé au constructeur
R = F.[[Call]].apply(O, initialParameters)

if (isAnObject(R)) {
    // on retourne ce que l'utilisateur demande avec `return`
    return R
} else {
    // sinon on retourne l'objet nouvellement créé
    return O
}

Notez deux fonctionnalités majeures :

Premièrement, le [[Prototype]] de l'objet créé est défini à partir de la propriété prototype d'une fonction au moment courant (cela siginifie que le prototype de deux objets créés depuis un même constructeur peut varier si la propriété prototype de la fonction change ensuite).

Deuxièmement, comme nous l'avons mentionné plus haut, si lors de l'initialisation de l'objet, [[Call]] retourne un objet, c'est cet objet qui sera utilisé comme le résulat de l'expression avec mot clé new :

Code JavaScript

function Character() {}
Character.prototype.level = 10;

var ringabel = new Character();
console.log(a.level); // `10` par délégation, depuis le prototype

// affectons a la propriété `prototype` un nouvel objet
// qui va explicitement définir la propriété `constructeur`
Character.prototype = {
    constructor: Character,
    hp: 100
};

var yew = new Character();
// object `yew` a un nouveau prototype
console.log(yew.level); // `undefined`
console.log(yew.hp); // `100` par délégation, depuis le prototype

// cependant, le prototype de l'objet `ringabel`
// est toujours l'ancien (nous allons voir pourquoi plus bas)
console.log(ringabel.level); // `10` par délégation, depuis le prototype

function Asterisk() {
    this.power = 10;
    return new Array();
}

// si le constructeur de `Asterisk` n'a pas de `return`
// (ou retourne `this`), et bien l'objet `this`
// sera utilisé, sinon ça sera le tableau
var knight = new Asterisk();
console.log(knight.power); // `undefined`
console.log(Object.prototype.toString.call(knight)); // `[object Array]`

Résumons

Tout objet activable (appelable ou executable) est une fonction. Les fonctions activées avec le mot clé new sont dites des fonctions constructeurs (toute fonction est donc possiblement un constructeur dès lors qu'elle ne retourne rien, ou qu'elle retourne this). C'est ce mécanisme qui créé de nouveaux objets en mémoire basés sur un prototype.

Regardons maintenant ce prototype plus en détail.

Prototype

Tous les objets ont un prototype (exceptions faites de certains objets systèmes). La communication avec le prototype est organisée via la propriété interne, implicite et _inaccessible [[Prototype]]. Un prototype peut être aussi bien un objet ou la valeur null.

Propriété constructor

Dans l'exemple ci-dessus il y a deux points importants. Le premier concerne la propriété constructor de la propriété prototype.

Comme nous l'avons vu dans l'algorithme de la fonction de création d'objets, la propriété constructor est affectée à la propriété prototype lors de la phase de création de la fonction. La valeur de cette propriété est une référence circulaire à la fonction elle-même :

Code JavaScript

function Character() {}
var magnolia = new Character();
console.log(magnolia.constructor); // `function Character() {}` par délégation
console.log(magnolia.constructor === Character); // `true`

Souvant dans ce cas il y a un malentendu. La propriété constructor est incorrectement traitée comme une propriété appartenant à l'objet créé. Alors que, comme nous venons de le voir, cette propriété appartient au prototype de la fonction constructeur (ici Character) et est accessible par héritage.

Via la propriété constructor héritée, les objets créés peuvent indirectement obtenir une référence sur l'objet prototype du constructeur :

Code JavaScript

function Character() {}
Character.prototype.level = new Number(10);

var janne = new Character();
console.log(janne.constructor.prototype); // `[object Object]`

console.log(janne.level); // `10`, par délegation
// la même chose que `janne.[[Prototype]].level`
console.log(janne.constructor.prototype.level); // `10`

console.log(janne.constructor.prototype.level === janne.level); // `true`

Notez que les propriétés constuctor et prototype peuvent être redéfinie après que l'objet soit créé. Dans ce cas l'objet perd la référence mise en place par le mécanisme ci-dessus.

Cependant, si nous changeons la propriété prototype de la fonction complètement (en assignant un nouvel objet), la référence au constructeur original (ainsi que le prototype original) sont perdu.

Code JavaScript

function Character() {}
Character.prototype = {
    level: 10
};

var nikolai = new Character();
console.log(nikolai.level); // `10`
console.log(nikolai.constructor === Character); // `false` !

Et donc c'est pourquoi il est intéressant de restaurer la référence manuellement :

Code JavaScript

function Character() {}
Character.prototype = {
    constructor: Character,
    level: 10
};

var nikolai = new Character();
console.log(nikolai.x); // `10`
console.log(nikolai.constructor === Character); // `true`

Notons cependant que la propriété constructor manuellement restaurée, par contraste avec l'originale perdue, n'a pas d'attribut {DontEnum} et, par conséquent, est énumérable dans une boucle for..in sur le Character.prototype.

ES5 introduit la possibilité de contrôler l'état de l'énumération des propriétés avec l'attribut [[Enumerable]].

Code JavaScript

var airy = { level: 10 };

Object.defineProperty(airy, "advice", {
    value: 20,
    enumerable: false // aka `{DontEnum} = true`
});

console.log(airy.level, airy.advice); // `10`, `20`

for (var k in airy) {
    console.log(k); // only `level`
}

var levelDesc = Object.getOwnPropertyDescriptor(airy, "level");
var adviceDesc = Object.getOwnPropertyDescriptor(airy, "advice");

console.log(
    levelDesc.enumerable, // `true`
    adviceDesc.enumerable  // `false`
);

Propriétés explicites prototype vs. propriétés implicite [[Prototype]]

Souvent, le prototype [[Prototype]] d'un objet, qui est interne à l'objet et inaccessible est incorrectement confondu avec la référence explicite prototype de la fonction constructeur à ce prototype. Oui, effectivement, ils font référence au même objet, mais ce sont deux propriétés différentes :

Code JavaScript

a.[[Prototype]] ----> Prototype <---- A.prototype

De plus, le [[Prototype]] d'un objet créé par un constructeur donne la valeur que possédait la propriété prototype du constructeur lors de la phase de création de l'objet.

Cependant, remplacer la propriété prototype du constructeur n'affecte pas la référence [[Prototype]] des objets déjà créés. Ce sera uniquement la propriété prototype du constructeur qui changera ! Cela siginifie que des nouveaux objets auront ce nouveau prototype, mais les objets déjà créés (avant que la propriété prototype ne change), auront une référence vers le vieux prototype. Cette référence ne pourra plus être changée :

Pseudo-code

// Création de `anne`
anne = new Character

// État avant le changement de `A.prototype`
anne.[[Prototype]] // ----> Prototype
Character.prototype // ----> Prototype

// Changement de prototype
Character.prototype = newPrototype

// Création de `airy`
airy = new Character

// État après changement
Character.prototype ----> newPrototype
anne.[[Prototype]] ----> Prototype // les objets déjà créés ont une référence à l'ancien prototype
airy.prototype ----> newPrototype // les nouveaux objets auront une référence au nouveau prototype

Par exemple :

Code JavaScript

function Character() {}
Character.prototype.level = 10;

var tiz = new Character();
console.log(tiz.level); // `10`

Character.prototype = {
    constructor: Character,
    level: 20,
    hp: 30
};

// l'objet `tiz` utilise l'ancien
// prototype via sa référence
// implicite `[[Prototype]]`
console.log(tiz.level); // `10`
console.log(tiz.hp) // `undefined`

var yew = new Character();

// mais les nouveaux objets, à la création,
// ont bien une référence au nouveau prototype
console.log(yew.level); // `20`
console.log(yew.hp); // `30`

Parfois on peut lire des articles sur le JavaScript disant que « le changement dynamique de l'objet du prototype va affecter tous les objets qui auront ce nouveau prototype » ; cela est incorrect. Un nouveau prototype ré-affecté sera utilisé uniquement sur les nouveaux objets créés après le changement.

La règle principale ici c'est : le prototype d'un objet est assigné au moment de la création et ne peut pas être ré-assigné par celui que les nouveaux objets auront. En utilisant la référence explicite prototype depuis le constructeur, il est uniquement possible de muter l'objet, c.-à-d. d'ajouter, de modifier ou de supprimer des propriétés existantes dans le prototype de l'objet afin de répercuter les changements dans les objets déjà créés.

La propriété non-standard __proto__

Cependant, certaines implémentations, comme par exepmle, SpiderMonkey, fournissent une référence explicite vers l'objet du prototype via la propriété non standard __proto__ :

Code JavaScript

function Character() {}
Character.prototype.level = 10;

var agnes = new Character();
console.log(agnes.level); // `10`

var __newPrototype = {
    constructor: Character,
    level: 20,
    hp: 30
};

// référence au nouvel objet
Character.prototype = __newPrototype;

var ringabel = new Character();
console.log(ringabel.level); // `20`
console.log(ringabel.hp); // `30`

// `agnes` utilise toujours une référence
// sur l'ancien objet
console.log(agnes.level); // `10`
console.log(agnes.hp); // `undefined`

// changeons explicitement le prototype de `agnes`
agnes.__proto__ = __newPrototype;

// maintenant `agnes` fait également
// référence au nouvel objet
console.log(agnes.level); // `20`
console.log(agnes.hp); // `30`

Notez que ES5 a introduit la méthode Object.getPrototypeOf qui retourne directement la valeur de la propriété [[Prototype]] d'un objet, le prototype original de l'instance. Cependant, à la différence de __proto__, cela ne fournit qu'un accesseur, et ne perment en aucun cas de changer le prototype.

Code JavaScript

var luxendarc = {};
Object.getPrototypeOf(luxendarc) == Object.prototype; // `true`

L'objet est indépendant de son constructeur

Comme le prototype de l'objet créé est indépendant du constructeur et de la propriété prototype du constructeur, cela permet la chose suivante : l'objet du prototype de la phase de création peut être supprimé. Le prototype de l'objet créé va continuer d'exister, toujours référencé par la propriété [[Prototype]] :

Code JavaScript

function Asterisk() {}
Asterisk.prototype.power = 10;

var whiteMage = new Asterisk();
console.log(whiteMage.power); // `10`

// mise explicite de la référence
// du constructeur `Asterisk` à `null`
Asterisk = null;

// mais, il est toujours possible de créer
// des objets via la référence indirecte
// depuis les autres objets si
// la propriété `constructor` n'a pas été changée
var blackMage = new whiteMage.constructor();
console.log(blackMage.power); // `10`

// suppression de la référence implicite,
// après ça, `whiteMage.constructor` ainsi que `blackMage.constructor`
// feront référence à la fonction `Object`
// par défaut, mais plus à `Asterisk`
delete whiteMage.constructor.prototype.constructor;

// il ne sera plus possible de créer des objets
// du constructeur `Asterisk`
// mais ces deux objets auront toujours
// une référence à leur prototype dans `[[Prototype]]`
console.log(whiteMage.power); // `10`
console.log(blackMage.power); // `10`

L'opérateur instanceof

Il y a un lien entre la référence explicite au prototype, via la propriété prototype du constructeur et l'opérateur instanceof.

Cet opérateur fonctionne de pair avec la chaîne des prototypes d'un objet et pas uniquement avec son constructeur lui-même. Prennez ça en compte, car il y a souvent des incompréhensions à ce niveau. Quand on fait cette vérification :

Code JavaScript

if (tiz instanceof Character) {
    /* ... */
}

cela ne veut pas dire que l'objet tiz a été créé par le constructeur Character !

Tous ce que fait l'opérateur instanceof c'est de prendre la valeur de Character.prototype et vérifier sa présence dans la chaîne des prototypes de tiz, en commençant par tiz.[[Prototype]]. L'opérateur instanceof est activé par la méthode interne [[HasInstance]] du constructeur.

Regardons un exemple :

Code JavaScript

function Character() {}
Character.prototype.level = 10;

var magnolia = new Character();
console.log(magnolia.level); // `10`

console.log(level instanceof Character); // `true`

// Si maintenant on met `Character.prototype`
// à `null`...
Character.prototype = null;

// ...et bien l'objet `magnolia` a
// toujours accès à son
// prototype, via `magnolia.[[Prototype]]`
console.log(magnolia.level); // `10`

// cependant, l'opérateur `instanceof`
// ne pourra plus fonctionner, car
// il commence son examination depuis la
// propriété `prototype` du constructeur.
console.log(magnolia instanceof Character); // `Character.prototype` n'est pas défini`

Il est également possible de créer soit même le constructeur d'un objet, et instanceof retournera true en vérifiant l'instance d'un autre objet. Tout ce qu'il faut faire c'est définir soit même la propriété d'objet [[Prototype]] et la propriété prototype du constructeur avec le même objet :

Code JavaScript

function Asterisk() {}
var thief = new Asterisk();

console.log(thief instanceof Asterisk); // `true`

function Weapon() {}

var __proto = {
    constructor: Weapon
};

Weapon.prototype = __proto;
thief.__proto__ = __proto;

console.log(thief instanceof Weapon); // `true`
console.log(thief instanceof Asterisk); // `false`

Stockage via prototype pour partager des méthodes et propriétés

L'application la plus utile des prototypes en JavaScript est le stockage des méthodes, des états par défaut et des propriétés partagées des objets.

En effet, les objets peuvent avoir leur propre état, mais les méthodes sont habituellement les mêmes. C'est pourquoi, pour une optimisation de la mémoire, les méthodes sont habituellement définies dans le prototype. Cela signifie que tous les objets créés par un constructeur, partagent toujours les mêmes méthodes.

Code JavaScript

function Character(stat) {
    this.stat = stat || 100;
}

Character.prototype = (function () {

    // initialisation du contexte,
    // utilisation d'un objet additionnel

    var sharedStat = 500;

    function helper() {
        console.log('stat partagée : ' + sharedStat);
    }

    function attack() {
        console.log('attaque : ' + this.stat);
    }

    function defence() {
        console.log('défense : ' + this.stat);
        helper();
    }

    // le prototype lui-même.
    return {
        constructor: Character,
        attack: attack,
        defence: defence
    };

})();

var tiz = new Character(10);
var agnes = new Character(20);

tiz.attack(); // `attaque : 10`
tiz.defence(); // `défense : 10`, `stat partagée : 500`

agnes.attack(); // `attaque : 20`
agnes.defence(); // `défense : 20`, `stat partagée : 500`

// les deux objets utilisent
// la même méthode
// le même prototype
console.log(tiz.attack === agnes.attack); // `true`
console.log(tiz.defence === agnes.defence); // `true`

Lire et écrire des propriétés

Comme nous l'avons déjà mentionné, lire et écrire des propriétés se fait grâce à l'aide des méthodes internes [[Get]] et [[Put]]. La méthode est activée grâce à l'accesseur de propriété que ce soit via la notation par point ou par crochet droit :

Code JavaScript

// écrire
yew.level = 10; // `[[Put]]` est appelée

console.log(yew.level); // `10`, `[[Get]]` est appelée
console.log(yew['level']); // la même chose

La méthode [[Get]]

La méthode [[Get]] considère les propriétés venant de la chaîne des prototypes comme des objets aussi. Ainsi, les propriétés d'un prototype sont accessibles depuis l'objet lui-même. Ainsi pour O.[[Get]](P) avec O comme objet et P comme propriété réclamée, nous avons le mécanisme suivant :

Pseudo-code

// si c'est sa propre propriété,
// on la retourne
if (O.hasOwnProperty(P)) {
    return O.P
}

// sinon, on analyse le prototype
var __proto = O.[[Prototype]]

// s'il n'y a pas de prototype (cela est possible dans le dernier maillons de la chaîne pour `Object.prototype.[[Prototype]]`,
// qui est égale à `null`),
// on retourne `undefined`
if (__proto === null) {
    return undefined
}

// sinon, on appel la méthode `[[Get]]` récurssivement
// maintenant pour le prochain prototype; c.-à-d.
// que l'on traverse la chaîne des prototypes : et on essaye de trouver
// la propriété, puis ensuiste dans le prototype de prototype
// et ainsi de suite jusqu'à ce que le prototype soit égal à `null`
return __proto.[[Get]](P)

Notez que, puisque la méthode [[Get]] dans un des cas retourne undefined, il est possible de vérifier la présence d'une variable comme ceci :

Code JavaScript

if (window.someObject) {
    /* ... */
}

Ici, la propriété someObject n'est pas trouvée dans window, ni dans le prototype, ni dans le prototype du prototype, et l'algorithme retourne alors undefined.

Notez que c'est exactement le test de présence dont est responsable l'opérateur in. Il va également fouiller dans la chaîne des prototypes :

Code JavaScript

if ('someObject' in window) {
    /* ... */
}

Cela aide a éviter les cas où, par exemple, someObject serait égale à false et ou le première vérification aurait échouée malgré l'existance de la propriété.

La méthode [[Put]]

La méthode [[Put]] quand a elle met à jour sa propre propriété d'objet et masque les propriétés du même nom venant d'un prototype plus haut. Voyons cela avec l'algorithme de O.[[Put]](P, V) ou O est l'objet, P la propriété et V la valeur.

Pseudo-code

O.[[Put]](P, V):

// s'il n'est pas possible d'écrire
// dans cette propriété
// alors on ne fait rien.
if (!O.[[CanPut]](P)) {
    return
}

// si l'objet ne possède pas cette propriété,
// alors on la crée; tous les attributs
// de propriété étant placés à `false`.
if (!O.hasOwnProperty(P)) {
    createNewProperty(O, P, attributes: {
        ReadOnly: false,
        DontEnum: false,
        DontDelete: false,
        Internal: false
    })
}

// changer la valeur.
// si la propriété existait déjà, ses
// attributs restent inchangés, seule la valeur
// change
O.P = V

return

Par exemple :

Code JavaScript

Object.prototype.level = 100;

var edea = {};
console.log(edea.level); // `100`, hérité

edea.level = 10; // `[[Put]]`
console.log(edea.level); // `10`, possédé

delete edea.level;
console.log(edea.level); // again `100`, hérité

Notez qu'il n' est pas possible de masquer des propriétés héritées en lecture seule. Le résultat de l'affectation est simplement ignoré. Ceci est controllé par la méthode interne [[CanPut]].

Code JavaScript

// Par exemple, la propriété `length`
// de l'objet `String` est en lecture seule ; faisons de
// `String` le prototype de notre objet et essayons
// de masquer la propriété `length`

function SuperString() {
    /* rien */
}

SuperString.prototype = new String("abc");

var luxendarc = new SuperString();

console.log(luxendarc.length); // `3`, la longeur de `abc`

// essayons de la masquer
luxendarc.length = 5;
console.log(luxendarc.length); // toujours `3`

En mode strict de ES5, tenter de modifier une propriété en lecture seule lève l'erreur TypeError.

Accesseurs de propriété

Comme expliqué, les méthodes internes [[Get]] et [[Put]] sont activées par les accesseurs de propriété disponiblent dans JavaScript via la notation avec point ou la _notation avec crochet droit. La notation avec point est utilisée quand le nom de propriété est un identifieur valide ou connu à l'avance alors que la notation avec crochet droit permet l'utilisation de noms invalides ou dynamiques.

Code JavaScript

var rpg = { testProperty: 10 };

console.log(rpg.testProperty); // `10`, notation avec point
console.log(rpg['testProperty']); // `10`, notation avec crochet droit

var propertyName = 'Propriété';
console.log(rpg['test' + propertyName]); // `10`, notation dynamique avec crochet

Il y a encore une fonctionnalité importante : les accesseurs appellent toujours la conversion ToObject pour les objets placés sur la partie gauche de la propriété accession. Et du fait de cette conversion implicite, il est possible de dire « tout en JavaScript est un objet » (cependant comme nous le savons déjà, bien entendu, tout n'est pas objets, il y a également des valeurs primitives).

Si nous utilisons des accesseurs de propriété sur des valeurs primitives, nous créons juste un objet encadrant immédiat correspondant à cette valeur. Une fois le travail terminé, cet objet encadrant est supprimé.

Exemple :

Code JavaScript

var level = 10; // valeur primitive

// mais si on le demande, il y aura
// accès à la méthode, comme si c'était un objet.
console.log(level.toString()); // `"10"`

// nous pouvons également
// (tenter de) créer une nouvelle
// propriété dans la primitive `level` en appelant `[[Put]]`
level.test = 100; // et cela semble fonctionner.

// mais, `[[Get]]` ne retourne
// pas la valeur de cette propriété, et
// l'algorithme retourne `undefined`
console.log(level.test); // `undefined`

Alors, pourquoi dans cet exemple la valeur « primitive » de level à accès à la méthode toString mais, n'a pas accès à la propriété nouvellement crée test ?

La réponse est simple :

En premier lieu, comme déjà dit, après que l'accesseur de propriété ai été appliqué, on ne manipule pas une primitive, mais un objet intermédiaire. Dans ce cas, new Number(level) est utilisé, et par délégation la méthode toString de la chaîne du prototype :

Pseudo-code

// Algorithme d'évaluation de `level.toString()`

// 1.
wrapper = new Number(level)
// 2.
wrapper.toString() // `"10"`
// 3.
delete wrapper

Maintenant, la méthode [[Put]] crée également son propre objet intermédiaire englobant quand la propriété test est évaluée :

Pseudo-code

// Algorithme d'évaluation de `level.test = 100`

// 1.
wrapper = new Number(level)
// 2.
wrapper.test = 100
// 3.
delete wrapper

Nous voyons à l'étape 3 que l'objet encadrant est supprimé et que la propriété test nouvellement créée a été supprimée elle aussi à la suppression de l'objet lui-même.

Quand [[Get]] est utilisé de nouveau sur l'accesseur de propriété créé, il crée encore une fois un nouvel objet encadrant qui lui, ne sait rien à propos d'une quelconque propriété test :

Pseudo-code

// Algorithme d'évaluation de `level.test`

// 1.
wrapper = new Number(level)
// 2.
wrapper.test // `undefined`
// 3.
delete wrapper

Donc faire référence à une propriété ou méthode depuis une valeur primitive n'a de sens que pour la lecture de propriétés. Aussi, quand une valeur primitive accède souvent à des propriétés, pour économiser du temps de ressources, cela peut avoir du sens de directement la remplacer par sa représentation objet. Et, au contraire, si la valeur n'est utilisée que pour de petits calculs qui ne dépendent d'aucunes propriétés d'accès, il sera plus performant d'utiliser une valeur primitive à la place.

Héritage

Comme nous le savons, le JavaScript utilise l'héritage par délégation basé sur les prototypes.

Chaînage et prototype sont également souvent mentionnés en tant que chaîne de prototype.

En fait, l'intégralité de l'implémentation et l'analyse de la délégation se réduit au travail effectué par [[Get]] et déjà mentionné préceddement.

Si vous comprennez intégralement ce simple algorithme de la méthode [[Get]], la question de l'héritage en JavaScript disparait d'elle-même et la réponse devient clair.

Souvent sur les forums, quand les discussions se tournent vers l'héritage en JavaScript, je montre, en tant qu'exemple, seulement une ligne de code JavaScript qui représente exactement la définition d'une structure d'objet du langage et montre la délégation basée sur l'héritage. La ligne de code est vraiment simple :

Code JavaScript

console.log(1..toString()); // `"1"`

Maintenant, comme nous connaissons l'algorithme de la méthode [[Get]] et les accesseurs de propriétés, nous pouvons voir ce qu'il se passe ici :

  1. Depuis la valeur primitive 1, un objet encadrant équivalent à new Number(1) est créé.

  2. La méthode toString héritée est appelée depuis cet objet encadrant.

Pourquoi héritée ? Car les objets JavaScript peuvent avoir leurs propres propriétés, et que l'objet encadrant créé dans ce cas, n'a pas sa propre méthode toString. Cependant, il en hérite par délégation via son prototype, c.-à-d. utilise Number.prototype.

Notez la subtilité de la syntaxe. Deux points dans l'exemple précédent n'est pas une erreur. Le premier point est utilisé pour la partie fractionnée du nombre, et le second point est quand à lui l'accesseur de propriété :

Code JavaScript

1.toString(); // `SyntaxError` !

(1).toString(); // OK

1 .toString(); // OK (espace après 1)

1..toString(); // OK

1['toString'](); // OK

Chaîne de prototype

Montrons comment créer cette chaîne avec des objets définis par les utilisateurs. C'est très simple :

Code JavaScript

function Monster() {
    console.log('Monster.[[Call]] activé');
    this.attack = 10;
}
Monster.prototype.power = 20;

var monster = new Monster();
console.log([monster.attack, monster.power]); // `10` (possédé), `20` (délégué)

function Humanoid() {}

// la variante la plus simple du chaînage de prototype
// est de chaîner la valeur d'un enfant prototype
// a un nouvel objet créé,
// avec le constructeur du parent.
Humanoid.prototype = new Monster();

// fixons la propriété `constructor`, sinon elle vaudra `Monster`
Humanoid.prototype.constructor = Humanoid;

var goblin = new Humanoid();
console.log([goblin.attack, goblin.power]); // `10`, `20`, les deux sont délégués

// `[[Get]] goblin.attack` :
// `goblin.attack` (pas trouvé) -->
// `goblin.[[Prototype]].attack` (trouvé) - `10`

// `[[Get]] goblin.power` :
// `goblin.power` (pas trouvé) -->
// `goblin.[[Prototype]].power` (pas trouvé) -->
// `goblin.[[Prototype]].[[Prototype]].power` (trouvé) - `20`

// où `goblin.[[Prototype]] === Humanoid.prototype`,
// et `goblin.[[Prototype]].[[Prototype]] === Monster.prototype`

Cette approche a deux fonctionnalités.

La première, Humanoid.prototype va contenir la propriété attack. Mais cela n'est pas correcte puisque la propriété attack est définie dans Monster lui-même et que même s'il pourrait être attendu que le constructeur Humanoid le possède aussi, ce n'est pas le cas.

Dans le cas d'une traversée d'héritage prototypal normal, jusqu'à l'objet descendant, personne ne possède sa propre propriété déléguée d'un prototype. L'idée derrière ça c'est que , les objets créés par le constructeur Humanoid _n'_ont pas besoin de la propriété attack. Ce qui n'est pas le cas des modèles basés sur les classes, où toute propriété est copiée dans la classe descendante.

Cependant, s'il est nécessaire que la propriété attack soit propre aux objets créés par le constructeur Humanoid, il existe certaines techniques pour cela (émulation d'une approche basée sur la classe), dont nous allons parler ci-dessous.

La seconde n'est pas vraiment une fonctionnalité mais un désaventage. Le code du constructeur est aussi exécuté quand le descendant du prototype est créé. Nous pouvons voir ça grâce au message "Monster.[[Call]] activé" qui apparaît deux fois, quand l'objet est créé par le constructeur Monster qui est utilisé par Humanoid.prototype et lors de la création de l'objet monster lui-même !

Un exemple plus critique est une exception lancée dans le constructeur parent : peut être que pour un objet réellement créé par ce constructeur, une vérification est nécessaire mais le même cas est totalement inacceptable avec l'utilisation de cet objet comme prototype parent :

Code JavaScript

function Monster(param) {
    if (!param) {
        throw 'Paramètre requis';
    }
    this.param = param;
}
Monster.prototype.attack = 10;

var monster = new Monster(20);
console.log([monster.attack, monster.param]); // `10`, `20`

function Humanoid() {}
Humanoid.prototype = new Monster(); // `Erreur`

En outre, des calculs lourds dans le constructeur parent peuvent également être considérés comme un désavantage avec cette approche.

Pour résoudre le problème de cette « fonctionnalité », les programmeurs actuels utilisent un motif standard pour chaîner les prototypes, comme nous allons le voir plus bas. Le principal objectif de cette astruce consiste à créer un objet constructeur encadrant intermédiaire qui chaînes les prototypes souhaités.

Code JavaScript

function Monster() {
    console.log('Monster.[[Call]] activé');
    this.attack = 10;
}
Monster.prototype.defence = 20;

var monster = new Monster();
console.log([monster.attack, monster.defence]); // `10` (possédé), `20` (hérité)

function Humanoid() {
    // Ou simplement `Monster.apply(this, arguments)`
    Humanoid.superproto.constructor.apply(this, arguments);
}

// héritage : chaînage de prototypes
// en créant un constructeur intermédiaire vide.
var F = function () {};
F.prototype = Monster.prototype; // référence
Humanoid.prototype = new F();
Humanoid.superproto = Monster.prototype; // référence explicite au prototype ancètre, « sucre »

// fixons la propriété `constructor`, sinon elle vaudra `Monster`
Humanoid.prototype.constructor = Humanoid;

var goblin = new Humanoid();
console.log([goblin.attack, goblin.defence]); // `10` (propre), `20` (hérité)

Notez comment nous créons notre propre propriété attack sur l'instance de defence : nous appelons la référence au constructeur parent via Humanoid.superproto.constructor dans le contexte nouvellement créé.

Nous fixons également le probrème vis à vis de la non nécessité d'appeler le constructeur parent pour créer le prototype desendant. Mantenant le message "Monster.[[Call]] activé" n'est affiché que si nécéssaire.

Et pour ne pas avoir à répéter chaque fois les mêmes actions lors du chaînage de prototype (création d'un objet constructeur intermédière, créer un sucre superproto, restaurer la propriété constructor originale, etc), ce modèle peut être encapsulé dans une fonction utilitaire, dont le but est de chaîner les prototypes indépendemment du nom concret de leurs constructeurs :

Code JavaScript

function inherit(child, parent) {
    var F = function () {};
    F.prototype = parent.prototype
    child.prototype = new F();
    child.prototype.constructor = child;
    child.superproto = parent.prototype;
    return child;
}

Et l'héritage pourra se faire ainsi :

Code JavaScript

function Monster() {}
Monster.prototype.attack = 10;

function Humanoid() {}
inherit(Humanoid, Monster); // chaînage de prototype

var goblin = new Humanoid();
console.log(goblin.attack); // `10`, trouver dans le `Monster.prototype`

Il y a beaucoup de variation de cet objet encadrant (au regard de la syntaxe), cependant, elles se résument toutes à effectuer les actions ci-dessus.

Par exemple, nous pouvons optimiser l'objet encadrant précédent en mettant l'objet encadrant intermédiaire à l'extérieur du constructeur (comme cela, seulement une fonction sera créée), pour ensuite la ré-utiliser :

Code JavaScript

var inherit = (function(){
    function F() {}
    return function (child, parent) {
        F.prototype = parent.prototype;
        child.prototype = new F;
        child.prototype.constructor = child;
        child.superproto = parent.prototype;
        return child;
    };
})();

Et puisque le vrai prototype d'un objet est la propriété [[Prototype]], cela signifie que F.prototype peut facilement être changé et réutilisé, car child.prototype, qui a été créé via new F, va être dans [[Prototype]] comme la valeur courante de child.prototype :

Code JavaScript

function Monster() {}
Monster.prototype.attack = 10;

function Humanoid() {}
inherit(Humanoid, Monster);

Humanoid.prototype.y = 20;

Humanoid.prototype.name = function () {
    console.log("Humanoid#name");
};

var goblin = new Humanoid();
console.log(goblin.attack); // `10`, est trouvé dans le `Monster.prototype`

function Goblin() {}
inherit(Goblin, Humanoid);

// en utilisant notre sucre « superproto »
// nous pouvons appeler la méthode parente avec le même nom.

Goblin.prototype.name = function () {
    Goblin.superproto.name.call(this);
    console.log("Goblin#name");
};

var goblinSlasher = new Goblin();
console.log([goblinSlasher.attack, goblinSlasher.defence]); // `10`, `20`

goblinSlasher.foo(); // `"Humanoid#foo"`, `"Goblin#foo"`

Notez qu'en ES5 cette fonctionnalité a été standardisé pour des meilleurs chaînage de prototype. C'est la méthode Object.create.

Une version simplifié en tant que fonction de substitution ES3 s'implémenterait de cette manière :

Code JavaScript

Object.create ||
Object.create = function (parent, properties) {
    function F() {}
    F.prototype = parent;
    var child = new F;
    for (var k in properties) {
        child[k] = properties[k].value;
    }
    return child;
}

Pour être utilisé ainsi :

Code JavaScript

var monster = { attack: 10 };
var kobold = Object.create(monster, { defence: { value: 20 } });
console.log(kobold.attack, kobold.attack); // `10`, `20`

Pour plus de détail, voir ce chapitre.

De manière générale, toutes les limitations de « l'héritage classique en JavaScript » est basé sur ce principe. Maintenant, nous voyons qu'en fait même si ça ressemble à « une imitation des classes basés sur l'héritage », c'est surtout une manière simple de ré-utiliser du code pour le chaînage de prototypes.

Notez qu'en ES6, le concept de « class » a été standardisé, et son implémentation est exactement un « sucre syntaxique » par dessus les fonctions constructeurs décrites plus haut. De ce point de vu, le chaînage de prototype devient un détail d'implémentation de l'héritage basé sur les classes :

Code JavaScript

// ES6
class Monster {
    constructor(name) {
       this._name = name;
    }

    getName() {
        return this._name;
    }
}

class Humanoid extends Monster {
    getName() {
        return super.getName() + ' Archer';
    }
}

var goblin = new Humanoid('Goblin');
console.log(goblin.getName()); // `"Goblin Archer"`

Conclusion

Cet article n'a pas été havar de détails. Il pourra vous servir de référence global pour lister la majorité des mécanismes JavaScript et retrouver rapidement des détails de fonctionnement.

Références

Section correspondante de la spécification ECMA-262-3 :

Ce texte est une libre adaptation française d'une partie de l'excellent billet Тонкости ECMA-262-3. Часть 7.2. ООП: Реализация в ECMAScript. de Dmitry Soshnikov.