JavaScript, un peu d’héritage

Bien le bonjoir, parlons un peu de Javascript. Ce langage dynamique nous donne une grande liberté qui se paye parfois par une multitude de possibilités d’implémentations y compris pour les concepts de base.

Pour faire simple, si vous voulez de l’héritage, vous vous codez ce dont vous avez envie. C’est le côté chiant coût de la liberté. Nous allons faire un petit tour pour comprendre les mécanismes de base et aborder différentes façons d’implémenter l’héritage.

Le chaînage de prototype

 Javascript ne propose pas de la programmation objet classique, mais une programmation orienté prototype qui peut être assez perturbante. Le langage ne se base pas sur des classes mais sur l’instanciation d’objets, les prototypes, qui servent de modèles pour créer de nouveaux objets. 

Les fonctions possèdent une propriété appelée prototype contenant un objet. Lorsque nous utilisons le mot clé « new » sur une fonction, un nouvel objet est créé à partir de l’objet prototype, il contient un lien caché vers ce prototype (nommé parfois __proto__).  Il n’y a pas que les fonctions, tous les objets possèdent un lien caché qui pointe vers un prototype. Le chemin qui permet de remonter successivement les liens vers les prototypes est appelé chaîne de prototype. L’objet dont le prototype est null fini la chaîne de prototype.

Un petit bout de code très basique pour voir ce mécanisme, ouvrez une console et tapez :

var func = function(text) {};
// the function prototype
console.log('func prototype : ' + func.prototype);
// the prototype of the function prototype
console.log('func prototype.__proto__: ' + func.prototype.__proto__);
// the top parent prototype is null, it is the final link in the prototype chain
console.log('func prototype.__proto__.__proto__: ' + func.prototype.__proto__.__proto__);

Ce qui nous donne :

func prototype : [object Object]
func prototype.__proto__: [object Object]
func prototype.__proto__.__proto__: null

Ce chaînage de prototype prend tout son sens lorsqu’on regarde la résolution de propriété sur un objet. Nous commençons par chercher la propriété sur l’objet, puis nous allons voir du coté du prototype et ainsi de suite jusqu’à atteindre la fin de la chaîne.

L’héritage « classique »

Justement, cet héritage profite du chaînage de prototype. Prenons un exemple d’objets :

//Shape constructor
function Shape(){
    this.name = 'shape';
    this.toString = function() {
        return this.name;
    }
};
//TwoDShape constructor
function TwoDShape(){
    this.name = '2D shape';
};
//Triangle constructor
function Triangle(side, height){
    this.name = 'Triangle';
    this.side = side;
    this.height = height;
    this.getArea = function() {
        return this.side * this.height / 2;
    }
};

L’héritage va se faire via ces simples lignes de code :

TwoDShape.prototype = new Shape();
Triangle.prototype = new TwoDShape();

L’héritage passe ici par l’affectation de l’instance de l’objet parent au prototype de l’objet enfant. Ce mécanisme découple chaque objet de l’héritage. Concrètement, si nous modifions  Shape après avoir fait l’héritage, ça n’aura aucun impact sur TwoDShape.

C’est le constructor qui est appelé pour instancier de nouveaux objets à partir du prototype. Dans le cas de notre héritage, nous écrasons le prototype et le constructeur qui va avec. Pour éviter des effets de bord, spécifions le bon constructeur :

TwoDShape.prototype.constructor = TwoDShape;
Triangle.prototype.constructor = Triangle;

Nous pouvons instancier un Triangle

var myTriangle = new Triangle(2, 5);
myTriangle.getArea(); // 5

Il existe un mot clé : instanceof qui teste si un objet possède la propriété prototype d’un constructeur donné dans sa chaîne prototype.

Faisons quelques tests :

myTriangle instanceof Shape; // true
myTriangle instanceof TwoDShape; // true
myTriangle instanceof Triangle; // true
myTriangle instanceof Number; // false

Nous obtenons la même chose en partant du prototype avec la méthode isPrototypeOf :

Shape.prototype.isPrototypeOf(myTriangle); // true
TwoDShape.prototype.isPrototypeOf(myTriangle); // true
Triangle.prototype.isPrototypeOf(myTriangle); // true
Number.prototype.isPrototypeOf(myTriangle); // false

Maintenant, regardons d’où viennent les méthodes, pour cela nous allons utiliser hasOwnProperty :

var triangle = new Triangle(5,4);

triangle.hasOwnProperty('getArea'); //getArea is a Triangle property: true
triangle.hasOwnProperty('toString'); // toString is a Triangle property: false

var twoDShapePrototype = Object.getPrototypeOf(triangle);
twoDShapePrototype.hasOwnProperty('toString'); //toString is a TwoShape property: false

var shapePrototype = Object.getPrototypeOf(twoDShapePrototype);
shapePrototype.hasOwnProperty('toString'); //toString is a Shape property: true

Un prototype pour tous

Nous pouvons nous dire que finalement tout le code à partager se retrouve dans le prototype. Histoire de limiter le temps de résolution des propriétés, référençons directement un seul prototype.

function Shape() {
};
Shape.prototype.name = 'Shape'
Shape.prototype.toString = function () {
    return this.name;
}

function TwoDShape() {
};
TwoDShape.prototype = Shape.prototype;
TwoDShape.name = '2D shape';

function Triangle(side, height) {
    this.side = side;
    this.height = height;
};
Triangle.prototype = TwoDShape.prototype;
Triangle.prototype.name = 'Triangle';
Triangle.prototype.getArea = function () {
    return this.side * this.height / 2;
}

var myTriangle = new Triangle(2, 5);
myTriangle.getArea(); // 5
myTriangle.toString(); // Triangle

Cette fois-ci, la résolution de la méthode toString() se passe en deux étapes au lieu de quatre. Triangle ne possède pas la méthode, nous regardons dans le prototype, il référence le prototype de Shape et nous avons directement toString.

Vous vous doutez que ça pose quand même un gros problème 😉 . Toutes les modifications sur le prototype impactent toute la hiérarchie.

var myShape = new Shape();
myShape.toString(); // Triangle

Utilisation d’un constructeur temporaire

Un constructeur temporaire est utilisé pour hériter seulement du prototype du parent. Il utilise le chaînage de prototype.

//Shape constructor
function Shape() {
    this.justAShape = true;
};
Shape.prototype.name = 'Shape';
Shape.prototype.toString = function () {
    return this.name;
};
//TwoDShape constructor
function TwoDShape() {    
};
var F = function () {
};
F.prototype = Shape.prototype;
TwoDShape.prototype = new F();
TwoDShape.prototype.constructor = TwoDShape;
TwoDShape.prototype.name = '2D shape';
//Triangle constructor
function Triangle(side, height) {
    this.side = side;
    this.height = height;
}
var F = function () {
};
F.prototype = TwoDShape.prototype;
Triangle.prototype = new F();
Triangle.prototype.constructor = Triangle;
Triangle.prototype.name = 'Triangle';
Triangle.prototype.getArea = function () {
    return this.side * this.height / 2;
};

var myTriangle = new Triangle(5, 2);
myTriangle.getArea();// 5
myTriangle.toString();// triangle
myTriangle.hasOwnProperty('toString'); // false
Object.getOwnPropertyNames(myTriangle) //side,height

var myShape = new Shape();
myShape.toString(); // Shape
Object.getOwnPropertyNames(myShape); //justAShape

En utilisant ECMAscript 5

ECMAscript 5 fourni une méthode Object.create qui permet d’instancier un objet à partir d’un prototype. Cette méthode donne un réel contrôle sur la création d’objet et du coup, nous pouvons nous passer complètement du code de constructeur temporaire.

//Shape constructor
function Shape() {
    this.justAShape = true;
};
Shape.prototype.name = 'Shape';
Shape.prototype.toString = function () {
    return this.name;
};
//TwoDShape constructor
function TwoDShape() {    
};

TwoDShape.prototype = Object.create(Shape.prototype);
TwoDShape.prototype.constructor = TwoDShape;
TwoDShape.prototype.name = '2D shape';
//Triangle constructor
function Triangle(side, height) {    
    this.side = side;
    this.height = height;
}
Triangle.prototype = Object.create(TwoDShape.prototype);
Triangle.prototype.constructor = Triangle;
Triangle.prototype.name = 'Triangle';
Triangle.prototype.getArea = function () {
    return this.side * this.height / 2;
};
var myTriangle = new Triangle(5, 2);
myTriangle.getArea();// 5
myTriangle.toString();// triangle

myTriangle.hasOwnProperty('toString'); // false
Object.getOwnPropertyNames(myTriangle) //side,height

var myShape = new Shape();
myShape.toString(); // Shape
myShape.justAShape; // true
Object.getOwnPropertyNames(myShape); //justAShape

Alors pourquoi se servir d’un constructeur temporaire si Object.create fait le boulot ? Tout simplement pour le support des navigateurs. Si vous regardez la documentation MDN, vérifiez bien les versions supportées ! Pour Objet.create, nous avons : chrome 5+, Firefox 4.0+, IE9+, Opera 11.60+, Safari5+.

Si vous préférez hériter également des propriétés des objets en plus du prototype, il suffit d’appeler le constructeur du parent dans le constructeur enfant :

function Shape() {
    this.justAShape = true;
};
Shape.prototype.name = 'Shape';
Shape.prototype.toString = function () {
    return this.name;
};
//TwoDShape constructor
function TwoDShape() {    
    Shape.call(this);
};

TwoDShape.prototype = Object.create(Shape.prototype);
TwoDShape.prototype.constructor = TwoDShape;
TwoDShape.prototype.name = '2D shape';
//Triangle constructor
function Triangle(side, height) {
    TwoDShape.call(this);
    this.side = side;
    this.height = height;
}
Triangle.prototype = Object.create(TwoDShape.prototype);
Triangle.prototype.constructor = Triangle;
Triangle.prototype.name = 'Triangle';
Triangle.prototype.getArea = function () {
    return this.side * this.height / 2;
};
var myTriangle = new Triangle(5, 2);
myTriangle.getArea();// 5
myTriangle.toString();// triangle

myTriangle.hasOwnProperty('toString'); // false
Object.getOwnPropertyNames(myTriangle) //justAShape,side,height
myTriangle.justAShape; // true

var myShape = new Shape();
myShape.toString(); // Shape
myShape.justAShape; // true
Object.getOwnPropertyNames(myShape); //justAShape

 

Le futur avec ECMAScript 6

Cette version d’ECMAScript est appelée Harmony et reste à l’état de discussion, du coup, tout ce nous pouvons comprendre à l’heure actuelle pourrait évoluer. Vous pouvez jeter un coup d’œil au draft en cours. La dernière version au moment ou j’écris l’article est datée du 27 avril 2014, et il faut avouer que c’est relativement illisible :p

J’ai commencé par essayer de déchiffrer la partie 14.5.17 Runtime Semantics: ClassDefinitionEvaluation , comme j’étais perplexe devant ce document, j’ai cherché des exemples concrets sur le wiki ecmascript.org. L’exemple nous montre que cette version permet une écriture plus proche de ce que nous connaissons dans les autres langages. Pour faire simple, disons qu’il s’agit de sucre syntaxique pour nous faciliter la vie. J’ai voulu tester en cherchant comment utiliser class et je suis tombé sur une table des compatibilités ECMAScript 5/6. Je n’ai pas pu tester directement mais j’ai découvert Tracer Compiler, l’espoir fut de courte durée car ce n’est pas encore supporté.

Bref, contentons nous d’imaginer ce nous pourrions écrire :

class Shape {
    private name = "Shape";
    
    constructor(){
       public justAShape = true;
       private(this).name = "Shape";
    }        

    get name() {
        return private(this).name;
    }

    public toString() {
        return this.name;
    } 
}

class TwoDShape extends Shape {
    constructor(){
       super()
       private(this).name = "2D shape";
    }        
}

class Triangle extends TwoDShape {
    constructor(side, height) {
        super();
        public side = side;
        public height = height;
        private(this).name = "Triangle";
    }
    
    public getArea() {
       return this.side * this.height / 2;
    }
}

 

Conclusion

Javascript nous laisse beaucoup de liberté, un peu trop diront certains, et il existe bien d’autres façons d’implémenter l’héritage. Le futur nous amène une syntaxe plus consensuelle  dans la lignée des autres langages, mais il s’agit surtout de sucre syntaxique. Ce que je retiens, c’est qu’en prenant le temps de réfléchir à l’héritage, nous voyons qu’il est important de :

  • comprendre le fonctionnement des prototypes
  • vérifier que la version de javascript que nous utilisons est compatible avec les navigateurs ciblés
  • prendre le temps de lire les documentations officielles et de tester le comportement directement dans un (ou plusieurs) navigateur(s)

En rédigeant cet article, j’ai découvert Tracer Compiler et j’ai mieux compris l’intérêt des Polyfill même dans le cas d’implémentations basiques. Ça m’encourage d’autant plus à tester mon code sur différents navigateurs (du coup, article sur Karma à venir 😉 )!

Références

Laisser un commentaire

Votre adresse de messagerie ne sera pas publiée. Les champs obligatoires sont indiqués avec *