Programmation orientée objet en JavaScript

Le JavaScript est un des langages les plus utilisés et aussi un des plus populaires du moment. Côté client, côté serveur, il est omniprésent sur le web. Malgré cela, le JS demeure mal compris par un grand nombre de développeurs. Pourtant, à mesure que son usage s’intensifie et qu’il est le cœur d’applications de plus en plus complexes, il convient de bien appréhender son modèle objet. En route pour le royaume des objets !

Le vilain petit canard

Le JavaScript apparaît pour la première fois en 1996 dans le navigateur Netscape. Face à son succès, Microsoft, sort une implémentation similaire dans son IE3. On a déjà deux navigateurs et deux versions différentes du langage… vous connaissez la suite.

Si JavaScript est un des langages les plus utilisés, c’est donc bien grâce au web. En effet, tous les terminaux qui possèdent un navigateur sont capables d’interpréter du JS. Cette spécificité du langage le rend incontournable pour qui veut programmer pour le web.

Bon nombre de développeurs se sont ainsi mis au JS par absence d’alternative, sans prendre la peine ni le temps de vraiment comprendre le langage. Tout ceci nous a mené à des codes parfois un peu crados et des pratiques pas toujours reluisantes. À cela s’ajoute des interpréteurs aussi variés qu’il existe de navigateurs et d’OS dans la nature, et on abouti à un langage à la réputation sulfureuse.

Depuis quelques années, il y a eu de gros efforts de standardisation, notamment de la part de l’ECMA, l’organisme chargé de rédiger les spécifications du JS, mais aussi de la part des éditeurs de navigateurs. Les cinq principaux navigateurs que sont Firefox, Chrome, IE/Edge, Safari et Opera respectent plutôt bien la spécification dans leurs dernières versions.

Ainsi, les améliorations apportées au langage, le meilleur support des standards dans les navigateurs et l’apparition de Node.js, qui permet d’exécuter du JS sur le serveur, permettent au JavaScript d’acquérir ses lettres de noblesse en tant que langage de programmation.

Tout est objet

En JS tout est objet. Tous les types héritent du type Object. Ainsi, que ce soient les string, les array, les bool, presque tout est objet.

Bon, je ne vais pas vous mentir, la réalité un tout petit peu plus complexe que cela. En JS, il y a les objets et les primitives. Une primitive est simplement une donnée qui n’est pas un objet et n’a pas de méthode.

Il en existe cinq et vous les connaissez très certainement pour la plupart : string, number, boolean, null, undefined et symbol. Vous ne le savez peut-être pas, mais il y a deux moyens de créer chacun de ces types : via son type primitif, comme vous le faites 99% tu temps, et via son constructeur. Exemple :

var primitif = 'je suis un string';
var stringObj = new String('je suis un string, un object string !');

Là où vous vous dites peut-être que je déconne, c’est que plus haut, j’ai défini les primitives comme n’ayant pas de méthodes. Pourtant, quand on fait cela :

var primitif = 'je suis un string';
console.log(primitif.toUpperCase()); // "JE SUIS UN STRING"

On invoque bien la méthode toUpperCase() et cela fonctionne, pourtant notre type est primitif… Eh oui, c’est parce que JavaScript effectue automatiquement la conversion entre la primitive et l’objet String. La chaine est temporairement transformée en un objet String le temps du traitement, puis il est détruit. Cela s’applique bien évidemment aux autres types.

Pour bien illustrer cette différence, faisons une petite expérience :

var primitif = 'un';
var objectString = new String('un');

console.log(typeof primitif); // "string"
console.log(typeof objectString);  // "object"

if (primitif === objectString) {
  console.log('==='); // n'affiche rien
}

if (primitif == objectString) {
  console.log('=='); // "=="
}

// les primitives sont évaluées comme du code source
var prim = '2 * 6';
console.log(eval(prim)); // renvoie le nombre 12

// les objets comme des string
var obj = new String('2 * 6');
console.log(eval(obj)); // renvoie la chaîne "6 * 6"

À l’usage il y a assez peu de différences et on a tendance à utiliser les types primitifs car ils sont plus concis. Cependant, les différences méritent d’être connues car elles peuvent créer des comportements inattendus.

Objets, propriétés et attributs

En JS, un objet contient des propriétés, jusque là, tout va bien. Cependant, on le sait moins, chaque propriété possède des attributs. Sa valeur bien entendu, mais également d’autres propriétés qui lui confèrent un comportement particulier. Observons cela.

Attribut Type Description
[[Value]] N’importe quelle valeur JavaScript La valeur obtenue lorsqu’on accède à la propriété.
[[Writable]] Booléen S’il vaut false, la valeur de la propriété (l’attribut [[Value]]) ne peut être changé.
[[Enumerable]] Booléen S’il vaut true, la propriété pourra être listée par une boucle for...in. Voir également l’article sur le caractère énumérable des propriétés.
[[Configurable]] Booléen S’il vaut false, la propriété ne pourra pas être supprimée et les attributs autres que [[Value]] et [[Writable]] ne pourront pas être modifiés.

J’ai honteusement copié ce tableau depuis le MDN, merci les licences Creative Commons.

On a assez peu souvent à modifier ces propriétés – c’est pour cela qu’elles sont méconnues – mais lorsque l’on a à le faire, on utilisera Object.defineProperties.

var mini = {
  model: 'mini',
  make: 'bmw',
  hp: 120,
  color: 'blue',
  tires: '17"'
};

Object.defineProperties(mini, {
  model: {
    enumerable: false
  },
  hp: {
    writable: false
  }
});

// liste toutes les propriétés sauf "model"
for (let prop in mini) {
  console.log(prop);
}

mini.hp = 200; // on tente de modifier "hp"
console.log(mini.hp); // 120 … hp n'est pas modifiable

Getters et setters

Les getters et setters sont des attribus un peu spéciaux. Il permettent d’accéder à la valeur d’une propriété ou de la définir. Bien souvent, on utilise des fonctions classiques comme getters ou setters, mais il y a bien des propriétés dédiées à cela dans les objets en JS.

var mini = {
  model: 'mini',
  make: 'bmw',
  hp: 120,
  color: 'blue',
  tires: '17"',
  get getColor() {
    // on utilise ici la syntaxe ES6 des template literals
    return `${this.model}'s color is ${this.color}`;
  },
  set paint(newColor) {
    this.color = newColor;
  }
};

console.log(mini.getColor); // "blue"

// vous remarquez qu'on ne l'appelle pas comme une fonction !
mini.paint = 'red';
console.log(mini.getColor); // "red"

Les setters et getters possèdent eux-mêmes des attributs, deux pour être exacts, il s’agit de [[Enumerable]] et de [[Configurable]], on les configure exactement de la même manière que que pour les autres attributs, avec Object.defineProperties.

Proto-quoi ?

Comme nombre de développeurs ne prennent pas le temps de comprendre le JS, certaines notions leur échappent. Pour quelqu’un sachant déjà programmer, il est facile d’avoir un usage basique du langage et d’arriver à ses fins sans en comprendre la vraie nature.

Le JavaScript est un langage orienté objet à prototype. Bon, qu’est-ce que c’est que cela me demanderez-vous ? Inutile que j’essaie de pondre ma propre définition, celle de Wikipedia me semble très claire.

La programmation orientée prototype est une forme de programmation orientée objet sans classe, basée sur la notion de prototype. Un prototype est un objet à partir duquel on crée de nouveaux objets.

Wikipedia

Je pompe toujours sur Wikipedia, mais l’article présente deux listes qui mettent bien en exergue les différences entre les deux types d’héritage.

Objets à classes :

  • Une classe définie par son code source est statique ;
  • Elle représente une définition abstraite de l’objet ;
  • Tout objet est instance d’une classe ;
  • L’héritage se situe au niveau des classes.

Objets à prototypes :

  • Un prototype défini par son code source est mutable ;
  • Il est lui-même un objet au même titre que les autres ;
  • Il a donc une existence physique en mémoire ;
  • Il peut être modifié, appelé ;
  • Il est obligatoirement nommé ;
  • Un prototype peut être vu comme un exemplaire modèle d’une famille d’objet ;
  • Un objet hérite des propriétés (valeurs et méthodes) de son prototype ;

Je sens que cette notion de prototype reste floue, tentons d’éclaircir vos idées. En JS, chaque objet possède un lien vers un autre objet : son prototype, lui-même possédant aussi un lien vers son prototype et ainsi de suite jusqu’à ce que le prototype ne soit plus un objet mais null.

Ainsi, lorsque l’on souhaite accéder à une propriété d’un objet, JavaScript cherche d’abord dans l’objet lui-même, puis s’il ne trouve rien, regarde dans son prototype et ainsi de suite jusqu’au début de la chaine. Illustrons cela par l’exemple.

// on créé un objet littéral
var o = {a: "b", b: "c"};

// on ajoute une propriété à son prototype
o.__proto__ = {d: "e"};

// on appelle cette propriété à partir de l'objet
console.log(o.d); // "e"

// vérification des propriétés propres
console.log(o.hasOwnProperty('a')); // true
console.log(o.hasOwnProperty('d')); // false

// on affiche l'objet dans les devtools (screen ci-dessous)
console.log(o);
propriétés propres et héritées du proto

On réalise ici que l’objet possède bien deux propriétés et qu’il va chercher la propriété « d » dans son prototype lorsqu’on lui demande d’y accéder.

La chaine de prototype ressemble ici à ça :

{a: "b", b: "c"} --> {d: "e"} --> Object.prototype --> null

C’est bien parce qu’on a le prototype de Object dans la chaîne de prototype que l’on est en mesure d’appeler des méthodes comme hasOwnProperty que l’on n’a pas explicitement défini.

Par ailleurs, notez bien que nous utilisons dans l’exemple __proto__ comme un setter, ce qui peut notablement impacter les performances. Dans la mesure du possible, on préférera recourir à d’autres méthodes, nous verrons plus loin comment définir le prototype.

Un peu de vocabulaire

Avant de rentrer dans les détails de la création d’objets et d’aborder différents patterns, clarifions un peu le vocabulaire. L’appartenance de certains mots à des langages aux paradigmes objets différents, allié à l’usage de certains termes à tort, est responsable pour une grande part de la mauvaise compréhension du modèle objet JavaScript.

POO

Commençons par le commencement. Le terme programmation orientée objet est partagé par le JS, basé sur des objets et dont l’héritage est prototypal et par les langages OO « classiques », basés sur les classes.

Pour bien marquer la différence, entre le modèle objet du JS et celui d’autres langages comme le Java, nous pourrions tenter de redéfinir les termes comme le fait Kyle Simpson dans sa série sur l’OOJS [en].

Ainsi, le JavaScript mériterait le terme d’orienté objet car le langage est basé sur les objets dans sa forme la plus pure. Tandis que les langages objets dit « classiques », sont plutôt basés sur les classes – dans la mesure où il n’est pas possible de créer d’objet sans passer par une classe – c’est donc de la programmation orientée classe.

Héritage

L’autre notion centrale est celle de l’héritage. Alors qu’en POO classique, lorsqu’on instancie une classe, l’objet ainsi créé contient toutes les propriétés et méthodes de cette classe et éventuellement de ses classes parentes, il en va tout autrement en JS.

En POOP le concept est plus limpide si l’on parle de délégation plutôt que d’héritage (ce concept est expliqué en profondeur dans un autre article [en] de la série de K. Simpson). L’objet n’hérite pas des propriétés d’autres objets – nous n’avons pas vraiment de classes en JS, nous y reviendrons – dans le sens où il n’en contient pas une copie mais un lien vers un autre objet : son prototype.

L’idée de délégation prend tout son sens lorsque l’on comprend que notre objet délègue au prototype la responsabilité de trouver une propriété ou une méthode, s’il ne la possède pas lui-même.

Méthode

On mentionne souvent le terme méthode, mais JS ne possède pas de méthode au sens classique de l’OO. Une méthode en OOJS est simplement une fonction rattachée à un objet en tant que propriété. Elle obéit aux mêmes règles que toute autre propriété, elle « hérite » de la même manière, la seule différence est qu’elle peut être appelée.

Il est bon de noter que lorsqu’une fonction contenue dans le prototype est exécutée, this fait référence à l’objet depuis lequel on appelle la fonction et non au prototype.

Classe

Le JS n’a pas de classes à proprement parler, il n’y a pas d’implémentations de classes dans le langages. Tout est objet et l’héritage est intégralement basé sur les prototypes. ES6 introduit quelques mots clefs supplémentaires pour faciliter le travail en OO, notamment le mot clef class, mais celui-ci n’est qu’un sucre syntaxique et le fonctionnement interne reste inchangé.

Instance

Si le JS n’a pas de classes, on est en droit de se demander s’il a des instances. Un POO classique, une instance est un objet issue d’une classe. Comme souvent en JS, on utilisera les mots habituellement utilisé en POO, on parlera donc d’instance. Cependant, une instance n’est rien d’autre qu’un objet qui hérite du prototype de son constructeur.

Les objets globaux

JavaScript possède un certain nombre d’objets natifs. C’est le cas de l’objet String dont nous avons parlé plus haut, mais aussi de Object, Math etc. Vous trouverez la liste exhaustive des objets globaux sur cette page du MDN.

C’est bien grâce à ces objets prédéfinis que nous pouvons invoquer des méthodes sans avoir à les définir préalablement. Elles sont définies dans le prototype des objets que nous créons.

Observons la chaine de prototype des objets les plus communs.

var texte = 'string ta mère !';
// texte --> String.prototype --> Object.prototype --> null

var num = 42;
// num --> Number.prototype --> Object.prototype --> null

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

var array = ['test'];
// array --> Array.prototype --> Object.prototype --> null

function fn() {
  return 'osef';
}
// fn --> Function.prototype --> Object.prototype --> null

On comprend aisément que les objets natifs que nous créons héritent de propriétés propres de leurs objets parents, puis de Objet. C’est donc pour cela qu’il est possible d’appeler toUpperCase() sur un string mais pas sur une fonction ou sur un nombre. Cette méthode fait partie du prototype de String et non d’Object.

Créer des objets

Passons aux choses sérieuses ! Nous savons déjà créer des objets avec la syntaxe littérale, nous avons vu également qu’il est facile d’instancier des objets natifs avec le mot clef new. Voyons donc comment créer nos propres objets.

Il existe trois manière de créer des objets en JS. Comme nous l’avons vu, le langage ne possède pas de classes, de ce fait, lorsque nous parlons de constructeur, il s’agit de fonctions (elles-même des objets) qui agissent comme des constructeurs.

Les deux premières méthodes vous sont déjà familières, il s’agit de la création d’objet littéraux avec {} et de l’instanciation d’objets avec le mot clef new, nous verrons par la suite comment créer nos constructeurs.

D’ailleurs, savez-vous ce qu’il se passe lorsqu’on utilise le met-clef new ? Trois choses :

  1. Un nouvel objet est créé qui hérite de Toto.prototype.
  2. La fonction constructrice Toto est appelée avec les arguments fournis, this étant lié au nouvel objet créé. new Toto sera équivalent à new Toto() (i.e. un appel sans argument).
  3. L’objet renvoyé par le constructeur devient le résultat de l’expression qui contient new. Si le constructeur ne renvoie pas d’objet de façon explicite, l’objet créé à l’étape 1 sera utilisé. (En général, les constructeurs ne renvoient pas de valeurs mais si on souhaite surcharger le processus habituel, on peut utiliser cette valeur de retour).
MDN

Enfin, la troisième méthode est apparue dans la version 5 de l’ECMAscript, il s’agit de Object.create. La particularité de cette méthode est qu’elle permet de créer un nouvel objet qui « hérite » de l’objet passé en paramètre ; sans utiliser new, ni employer de pattern complexe.

Évidemment, si hérite est entre guillemets, c’est bien parce qu’en réalité, la méthode create ajoute l’objet passé en paramètre au prototype du nouvel objet.

Voyons maintenant les patterns les plus communs.

Constructor pattern

function Vehicule() {
}

var voiture = new Vehicule();

Notre objet ne fait rien, mais si on l’inspecte dans les devtools, on se rend compte que son prototype est Vehicule. Plus exactement, son prototype est de type Object, cet objet possède une propriété constructor : Vehicule.

voiture --> Object --> Object prototype
               |
          constructor
               |--> voiture.__proto__.constructor === Vehicule

Explications. Les objets ont une propriété standard constructor qui référence la Function (en JS les constructeurs sont des fonctions). Lorsqu’une fonction est déclarée, l’interpréteur créé la nouvelle fonction ainsi que son prototype.

Par la suite, lorsqu’on créé un nouvel objet à partir de notre constructeur en utilisant le mot clef new, l’objet ainsi créé contient lui aussi un prototype avec une propriété constructor. Cette dernière référence la fonction ayant servie de constructeur, elle-même contenant le __proto__ du constructeur (ici Vehicule ayant pour prototype Function).

Jusque là, l’objet créé n’a pas grand intérêt. Ajoutons lui quelques détails.

function Car (make, model, color, HP, tires) {
  var HP = HP;
  this.make = make;
  this.model = model;
  this.color = color;
  this.tires = tires;
  this.getHP = function () {
    return HP;
  };
}

var miniCooperS = new Car('mini', 'cooper s', 'pink', 180, '17"');

console.log(miniCooperS.color); // "pink"
console.log(miniCooperS.getHP()); // 180
console.log(miniCooperS.HP); // undefined

On a le feeling d’une POO assez classique, on instancie notre objet en lui passant les paramètres et on peut appeler ses méthodes et accéder ou modifier ses propriétés. Vous remarquerez que les propriétés définies par var sont privées et ne peuvent être accédées de l’extérieur autrement que via la fonction getter.

L’inconvénient du contructor pattern que nous venons de mettre en place est que chaque instance porte l’ensemble des propriétés et non une référence à celles-ci via le prototype. Pour vous en convaincre, il suffit de faire un petit console.log(miniCooperS) dans les devtools.

tout est dans l'objet

Les méthodes et propriétés se trouvent dupliquées dans toutes les instances

De ce fait, si on créé beaucoup d’instances, l’empreinte mémoire peut rapidement devenir conséquente et poser des problèmes de performances.

Constructor / prototype pattern

On réalise qu’il est contre productif de tout stocker dans chaque objet. Ceci nous amène tout naturellement au constructor / prototype pattern (ouais c’est super original comme nom…).

function Car (make, model, color, HP, tires) {
  var HP = HP;
  this.make = make;
  this.model = model;
  this.color = color;
  this.tires = tires;
  this.getHP = function () {
    return HP;
  };
}

Car.prototype = {
  utility: 'transport',
  field: 'ground',
  changeTires: function (size) {
    this.tires = size;
  } 
};

var miniCooperS = new Car('mini', 'cooper s', 'pink', 180, '17"');

console.log(miniCooperS.tires); // 17"
miniCooperS.changeTires('16"');
console.log(miniCooperS.tires); // 16"

Vous noterez que getHP() doit rester dans le constructeur, sinon, faute de closure, la fonction ne pourra plus accéder à l’attribut privé.

méthodes paragées

Nos méthodes et propriétés communes se trouvent toutes dans le prototype

Vous comprenez ici immédiatement l’intérêt de ce modèle. On définit les propriétés propres à chaque objet via le constructeur dès sa création tandis que les méthodes communes sont partagées via le prototype et n’encombrent pas l’espace mémoire.

Dynamic Prototype Pattern

Le fait de devoir définir le prototype en dehors de la fonction constructeur peut parfois être embêtant et/ou déroutant car visuellement nous n’avons pas toute la logique encapsulée dans le constructeur.

Le dynamic prototype pattern permet d’outrepasser ce désagrément en définissant le prototype directement depuis le constructeur.

function Car (make, model, color, HP, tires) {
  var HP = HP;
  this.make = make;
  this.model = model;
  this.color = color;
  this.tires = tires;
  this.getHP = function () {
    return HP;
  };
  
  // si changeTires n'existe pas,
  // c'est que l'objet n'a pas encore été instancié
  if (typeof this.changeTires !== "function") {
    // on peut vérifier si l'appel est exécuté plusieurs fois
    // console.log('yo yo yo');

    Car.prototype.changeTires = function (size) {
      this.tires = size;
    };
  }
}

var miniCooperS = new Car('mini', 'cooper s', 'pink', 180, '17"');
var miniCooperSWinterTires = new Car('mini', 'cooper s', 'pink', 180, '15"');

console.log(miniCooperS.tires); // 17"
miniCooperS.changeTires('16"');
console.log(miniCooperS.tires); // 16"

Il n’y a dans ce pattern pas d’avantage fonctionnel par rapport au précédent, il s’agit ici uniquement d’esthétique. Aussi libre à chacun d’opter pour l’un ou l’autre. Les plus perfectionnistes observeront que la condition sera exécutée à chaque instanciation, ce qui rajoute un petit traitement lors de la création d’objets…

Le problème du constructeur

Nous l’avons vu plus haut, lorsque l’on utilise new, l’objet créé contient dans son prototype une propriété constructor faisant référence à la fonction de laquelle il est issue. Lorsque l’on redéfini le constructeur comme nous l’avons fait dans les deux exemples précédents, ce dernier est effacé et fait du coup référence à Object alors qu’il devrait faire référence à Car.

console.log(miniCooperS.contructor === Car); // false
console.log(miniCooperS.contructor === Object); // true

Bien que ça n’ait pas d’impact la plupart du temps puisque l’on en fait pas un usage extensif, certains codes et certaines bibliothèques s’y réfèrent, il peut donc être préférable de le conserveur. Deux solutions pour cela :

function Car () {}

// première méthode, redéfinir les propriétés une à une
// cela n'écrase pas le prototype… mais c'est un peu fastidieux
Car.prototype.make = 'mini';
Car.prototype.model = 'cooper'; 

// seconde méthode, redéfinir manuellement le constructeur
Car.prototype = {
  constructor: Car,
  make: 'mini',
  model: 'cooper',
  color: 'pink',
  tires: '17"'
}

var miniCooperS = new Car();

console.log(miniCooperS.contructor === Car); // true
console.log(miniCooperS.contructor === Object); // false

Anti new-isme

De nombreux auteurs, notamment Douglas Crockford dans son livre, JavaScript: The Good Parts mettent en garde contre l’utilisation du mot clef « new » pour instancier les objets, et donc du pattern pseudoclassique (le constructor pattern).

Les arguments [en] sont nombreux, Crockford explique que le JS est un langage expressif et que ce pattern ne sert qu’à « mimiquer » d’autre langages plus classiques tels que le Java, alors que le JS, à travers son modèle prototypal, possède de nombreuses autres manières de réutiliser le code et de fournir de l’héritage.

Plus flagrant et concret, si l’on tente d’instancier un objet en omettant le mot new, alors this ne sera pas lié à l’objet créé mais à l’objet global (en général window dans un navigateur).

Il est peut être quelque peu extrême de vouloir bannir new de nos codes, d’autant plus que la méthode Object.create l’utilise en interne. Néanmoins, pour éviter les erreurs, il est possible de minimiser son usage [en] lorsque cela est possible. Notamment en utilisant des factory (ou approche fonctionnelle).

Factory pattern

Le factory pattern a l’avantage de découpler la logique du constructeur de son invocation (on ne se soucie pas de savoir comment ça marche quand on en a besoin) et de permettre le polymorphisme, c’est à dire d’avoir un objet constructeur qui permet de créer différentes choses selon le contexte ou les arguments passés. Le comportement est déterminé à l’exécution (dynamic binding).

// Cas le plus simple: tout dans le constructeur
function createCar (make, model, color, HP) {
  return {
    make: make,
    model: model,
    color: color,
    HP: HP,
    getHP: function () {
      return this.HP;
    }
  };
}

// si on veut utiliser l'héritage prototypal
var carProto = {
  getColor: function () {
    return this.color;
  }
};

function createCar (make, model, color, HP) {
  var obj = Object.create(carProto);
  obj.make = make;
  obj.model = model;
  obj.color = color;
  obj.HP = HP;
    
  return obj;
}

var clio = createCar('renault', 'clio', 'green', '90');

Prototype pattern

Le prototype pattern implémente un héritage prototypal dans lequel on créé des objets que l’on utilise comme prototypes pour d’autres objets. Il n’y a ici ni notion de classe ni de constructeur.

Ce schéma de conception permet de tirer parti de l’héritage prototypal du JS tout en s’affranchissant des carcans du modèle objet traditionnel. Il utilise pour cela Object.create.

var car = {
  make: 'mini',
  model: 'cooper',
  color: 'pink',
  HP: 180,
  tires: '17"',
  getHP: function () {
    return this.HP;
  }
}

var miniCooperS = Object.create(car);

Object.create accepte aussi d’autres paramètres, lesquels permettent de définir des propriétés propres à notre objet.

var car = {
  make: 'mini',
  model: 'cooper',
  color: 'pink',
  HP: 180,
  tires: '17"',
  getColor: function () {
    return this.HP;
  }
}

var miniCooperSClassic = Object.create(car);
var miniCooperS = Object.create(car, {make: {value: 'ford'}, tires: {value: '14"'}});

console.log(miniCooperSClassic.make); // "mini"
console.log(miniCooperS.make); // "ford"

OLOO pattern

Toujours dans cette logique de travailler autour de l’héritage prototypal sans utilier new, un pattern assez récent, popularisé par Kyle Simpson dans son livre You don’t know JS n’utilise que de pures objets pour l’héritage.

OLOO n’est pas le cri de Jacquouille ! Cela signifie Objects Linked to Other Objects. Petite démo :

var Car = {
  init: function (make, model, color, HP, tires) {
    this.make = make;
    this.model = model;
    this.color = color;
    this.tires = tires;
  },
  changeTires: function (size) {
    this.tires = size;
  }
};

var miniCooperS = Object.create(Car);
var miniCooperSWinterTires = Object.create(Car);

miniCooperS.init('mini', 'cooper s', 'pink', 180, '17"');
miniCooperSWinterTires.init('mini', 'cooper s', 'pink', 180, '15"');

console.log(miniCooperS.tires); // 17"
miniCooperS.changeTires('16"');
console.log(miniCooperS.tires); // 16"

console.log(miniCooperS);

Dans l’exemple précédent, qui commence à être familier, les deux objets créés héritent de Car. On vérifiera facilement l’héritage avec un petit coup de console log sur l’un des objets.

héritage prototypal OLOO patern

Par ailleurs, au lieu d’avoir une initialisation implicite via le constructeur, on a ici une méthode dédiée : init. Cela requiert une instruction supplémentaire car la création et l’initialisation de l’objet consistent en deux étapes distinctes, mais on échappera ainsi à toute erreur liée à new.

Cette méthode peut avantageusement être combinée à celle du prototype afin d’exposer une API propre et de ne pas avoir à manuellement appeler la fonction init à chaque fois.

Parasitic combination inheritance

On revient ici à la charge avec le mot clef new. La raison pour laquelle je ne vous parle que maintenant de ce pattern est qu’il est un peu plus complexe à appréhender que les autres. Sous ce nom barbare se cache un pattern particulièrement indiqué pour l’héritage multiple.

Avant de créer nos constructeurs, nous devons définir une fonction chargée d’implémenter l’héritage. Concentrez-vous bien, c’est assez court, mais intense !

function inheritPrototype(childObject, parentObject) {
  // on créé un nouvel objet qui possède parentObject dans son proto
  var copyOfParent = Object.create(parentObject.prototype);

  // on définit son constructeur (écrasé par Object.create)
  copyOfParent.constructor = childObject;
  
  // enfin on définit le prototype de childObject
  // avec notre copyOfParent tout beau tout frais
  childObject.prototype = copyOfParent;
}

Le plus dur est fait, nous n’avons plus qu’à créer nos fonctions et à les faire hériter les unes des autres.

function Vehicule(field) {
  this.utility = 'transportation'; 
  this.field = field;
}

Vehicule.prototype = {
  getColor : function () {
      console.log(this.color);
  }
}

function Car(field, make, model, color) {
  // on ajuste le scope pour que le this dans Vehicule
  // fasse bien référence au this de Car
  // et on passe le ou les arguments 
  // il doivent être dans le même ordre dans Vehicule et Car
  Vehicule.apply(this, arguments)
  this.wheels = 4;
  this.make = make;
  this.model = model;
  this.color = color;
  this.handbrake = true;
  this.toogleHandbrake = function () {
    return handbrake = handbrake ? false : true;
  };
}

function Plane(field, make, model, color) {
  Vehicule.apply(this, arguments)
  this.make = make;
  this.model = model;
  this.color = color;
}

inheritPrototype(Plane, Vehicule);
inheritPrototype(Car, Vehicule);

var cessna = new Plane('sky', 'Cessna', '320', 'red&white');
var mini = new Car('road', 'mini', 'cooper', 'red');
var aston = new Car('road', 'Aston Martin', 'DB9', 'grey');

// vérification
console.log(cessna);

Vous pouvez légitimement vous demander pourquoi on utilise Object.create dans inheritPrototype au lieu de simplement faire var copyOfParent = parentObject.prototype;. Au même titre, pourquoi dans Object.create utilisons-nous parentObject.prototype; au lieu de simplement passer parentObject. Eh bien faisons l’expérience les amis !

// les deux méthodes sont ici commentées,
// on les décommentera l'une après l'autre pour les besoins de nos tests
function inheritPrototype(childObject, parentObject) {
  // on fait fi de Object.create
  // on créé alors simplement une RÉFÉRENCE
  // vers le prototype du parent au niveau du prototype enfant
  // on a donc un lien du prototype enfants vers le parent
  // var copyOfParent = parentObject.prototype;
  
  // si on ne copie pas parentObject.prototype, mais juste parentObject
  // on casse notre chaine de prototype car le prototype de nos objets
  // fait référence à véhicule et non à Object
  // notre objet ne sera donc pas capable de récupérer getColor()
  // var copyOfParent = Object.create(parentObject);
 
  copyOfParent.constructor = childObject;
  childObject.prototype = copyOfParent;
}

function Vehicule(field) {
  this.utility = 'transportation'; 
  this.field = field;
}

Vehicule.prototype = {
  getColor : function () {
      console.log(this.color);
  }
}

// déclaration des classes …

inheritPrototype(Plane, Vehicule);
inheritPrototype(Car, Vehicule);

var cessna = new Plane('sky', 'Cessna', '320', 'red&white');
var mini = new Car('road', 'mini', 'cooper', 'red');
var aston = new Car('road', 'Aston Martin', 'DB9', 'grey');

// on démontre ici que le prototype est bien lié via
// référence et non copie puisque cette méthode
// apparaît à deux endroits même sur d'autres objets
aston.__proto__.pasBien = function () {
  console.log('portnawwaaak');
}

// on ajoute une méthode au proto de véhicule
// afin de voir où elle va
Vehicule.prototype.boostEngine = function () {
  console.log('engine has been boosted');
}

// on appelle une méthode pour voir si elle est trouvée
mini.getColor();

console.log(aston);

On voit ici la chaine de prototype normale. On a le bon constructeur et le prototype contient nos deux méthodes.

prototype normal avec la parasitic inheritence

Nous avons ensuite omis Object.create. De ce fait, le prototype est simplement référencé depuis le parent vers l’enfant. On voit clairement que les méthodes apparaissent deux fois, et la modification du prototype d’une instance modifie celle des autres également.

prototype référencé en omettant Object.create

Enfin, nous avons copié l’objet parent et non son prototype. On constate que le proto du parent est la fonction véhicule, et non son prototype. La chaine prototypale est donc cassée.

chaine de prototype cassée

Les classes ES6

Maintenant que nous avons bien appréhendé le modèle objet « traditionnel » du JS, abordons la nouvelle syntaxe ES6. Au cas où vous ne le sauriez pas, la norme ECMAScript 2015 enrichit le langage de nouvelles manière de faire des classes, qui nous rapprochent encore plus du modèle pseudo-classique… en apparence.

Je dis bien « en apparence », car cette nouvelle syntaxe ne constitue vraiment que du sucre syntaxique. Sous le capot, nous avons bien l’héritage prototypal que nous chérissons tant. Si certains développeurs provenant d’autres langages se sentirons comme à la maison avec cette nouvelle syntaxe, ça risque de décoiffer encore plus lorsqu’ils se rendront compte que malgré les apparences, on n’est pas chez mémé.

Quoi qu’il en soit, malgré la confusion que les mots clef class et cie peuvent apporter, pour les développeurs qui comme vous, connaissent la vraie nature de l’OOJS, cette syntaxe peut apporter un peu de clarté et de concision dans l’écriture de notre code. Reprenons nos exemples et mettons les à la sauce classique.

Constructeur et prototype

class Car {
  constructor(make, model, color, HP, tires) {
    var HP = HP;
    this.make = make;
    this.model = model;
    this.color = color;
    this.tires = tires;

    this.getHP = function () {
      return HP;
    };
  }
}

var mini = new Car('mini', 'cooper s', 'pink', 180, '17"');

console.log(mini);
class ES6 tout dans le constructeur

Dans ce premier cas, tout est dans le constructeur et nous retrouvons tout dans chaque objet

class Car {
  constructor(make, model, color, HP, tires) {
    var HP = HP;
    this.make = make;
    this.model = model;
    this.color = color;
    this.tires = tires;
    
    this.getHP = function () {
      return HP;
    };
  }
  
  get getMake() {
    return this.make;
  }
  
  set setMake(newMake) {
    this.make = newMake;
    return this.make;
  }
}

var mini = new Car('mini', 'cooper s', 'pink', 180, '17"');

console.log(mini);
class ES6 tout dans le constructeur

Ici, de manière assez classique, certaines méthodes sont placées dans le prototype de façon à partager des propriétés et/ou méthodes

Méthode statique

class Car {
  constructor(make, model, color, HP, tires) {
    var HP = HP;
    this.make = make;
    this.model = model;
    this.color = color;
    this.tires = tires;
    
    this.getHP = function () {
      return HP;
    };
  }
  
  static range(totalFuel) {
    // 7L/100km
    return totalFuel * (100 / 7);
  }
  
  get getMake() {
    return this.make;
  }
  
  set setMake(newMake) {
    this.make = newMake;
    return this.make;
  }
}

var mini = new Car('mini', 'cooper s', 'pink', 180, '17"');

console.log(mini);
console.log(Car.range(50));
class ES6 tout dans le constructeur

La méthode statique ne peut s’invoquer que sur la classe car elle n’est présente que dans constructor

Ce code, revient à faire ça en ES5 (avec quelques méthodes en moins pour plus de concisions) :

function Car (make, model, color, HP, tires) {
  var HP = HP;
  this.make = make;
  this.model = model;
  this.color = color;
  this.tires = tires;
}

Car.range = function range (totalFuel) {
  return totalFuel * (100 / 7);
};

var mini = new Car('mini', 'cooper s', 'pink', 180, '17"');

console.log(Car.range(50));

Extends et super

Ces deux mot clefs permettent vraiment de simplifier l’héritage. Reprenons l’exemple mis en place dans parasitic combination inheritance.

class Vehicule {
  constructor (field) {
    this.utility = 'transportation'; 
    this.field = field;
  }
  
  getColor() {
    console.log(this.color);
  }
}

class Car extends Vehicule {
  constructor (field, make, model, color) {
    // pour surcharger une méthode, il faut utiliser super
    // et lui passer les arguments pour la méthode parente
    super(field);
    make = make;
    model = model;
    color = color;
  }

  getColor () {
    console.log(this.color);
  }  
}

var aston = new Car('road', 'Aston Martin', 'DB9', 'grey');

Je vous laisse le soin de vérifier les propriétés et le prototype de l’objet, c’est exactement le même. C’est ici flagrant, la gain en clareté n’est pas négligeable !

Conclusion

Il resterait encore beaucoup à dire dans la POO est un vaste sujet. Mais nous avons tout de même passé en revue les points principaux, vous devriez être très à l’aise avec les concepts de l’OOJS si vous avez bien suivi et bien compris. Vous pourrez utiliser le sucre ES6 tout en comprenant le fonctionnement implicite du moteur JS.

Vous ne vous retrouverez donc pas comme un c** face à la nature prototypale de l’OOJS, même en faisant mumuse avec des mot clefs bien rassurants comme class ou extends.

Si vous souhaitez creuser encore un peu le sujet, je vous suggère de lire JavaScript Design Patterns de Addy Osmani et la série de livre You don’t know JS de Kyle Simpson. Enfin, le Mozilla Developper Network est une ressource à ne pas négliger ; c’est officieusement la documentation officielle du JS et des API HTML5.

Et vous, ça se passe comment la POO en JS ? Aussi, n’hésitez pas à me faire part de vos remarques !

Il n'y a pas encore de commentaire

Laisser un commentaire

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