ÉLOQUENT JAVASCRIPT

3 e Édition


précédentsommaire

VII. La vie secrète des objets

VII-A. Introduction

Un type de données abstrait est réalisé en écrivant un type spécial de programme […] qui définit le type en termes d'opérations qui peuvent être effectuées dessus.
-Barbara Liskov, Programming with Abstract Data Types

On a introduit les objets JavaScript dans le chapitre 4. Dans la culture de la programmation, il existe une chose appelée la programmation orientée objet, un ensemble de techniques qui utilisent des objets (et tous les concepts en relation) comme un principe central de la structure d’un programme.

Bien qu’il y ait de nombreux différents sur sa définition précise, la programmation orientée objet a façonné la conception de nombreux langages de programmation dont le JavaScript. Ce chapitre décrira comment ces idées peuvent êtres appliquées pour ce langage.

VII-B. Encapsulation

L’idée mère de la programmation orientée objet est de fragmenter des programmes en plus petites parties et faire en sorte que chaque partie puisse gérer son propre état.

De cette manière, les détails de fonctionnement d’une partie du programme sont « local » à celle-ci. Quelqu’un qui travaille sur le reste du programme n’a pas à se rappeler ou même à être au courant de ces détails. À chaque fois que ces détails en local changent, seul le code autour de ceux-ci a besoin d’être mis à jour.

Les différentes pièces d’un tel programme interagissent entre elles à travers des interfaces, c’est-à-dire des ensembles de fonctions ou d’attaches limitées de fonctions ou de liaisons qui fournissent des fonctionnalités à un niveau plus abstrait tout en cachant leur implémentation précise.

Ces morceaux de programme sont modélisés à l’aide d’objets. Leur interface consiste en un ensemble de méthodes et de propriétés spécifiques. Ces propriétés qui font partie de l’interface sont appelées publiques (public), tandis que les autres, celles auxquelles on ne devrait pas avoir accès, sont appelées privées (private).

De nombreux langages nous permettent de distinguer les propriétés publiques et privés et d’empêcher le code extérieur d’accéder à celles qui sont privées. JavaScript, qui encore une fois prend l’approche la plus minimaliste, ne le permet pas, en tous cas pas pour l’instant. Même si on a commencé à travailler là-dessus pour l’ajouter au langage.

Bien que ce langage n’ait pas cette distinction implémentée, les programmeurs JavaScript utilisent malgré tout cette idée. Typiquement, les interfaces disponibles sont décrites dans les documentations ou les commentaires. Il est aussi courant de mettre un tiret-du-bas (_) au début des noms de propriétés pour indiquer que celles-ci sont privées.

Séparer l’interface de l’implémentation est une bonne idée. On donne généralement le nom d’encapsulation à cette pratique.

VII-C. Méthodes

Les méthodes ne sont rien de plus que des propriétés qui stockent des valeurs de fonctions. Voici une méthode très simple :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
let rabbit = {};
rabbit.speak = function(line) {
  console.log(`The rabbit says '${line}'`);
};

rabbit.speak("I'm alive.");
// → The rabbit says 'I'm alive.'

En général une méthode a pour but d’effectuer quelque chose avec l’objet depuis lequel elle a été appelée. Lorsqu’une fonction est appelée en tant que méthode elle est recherchée en tant que propriété puis elle est immédiatement appelée comme dans object.method() , la liaison appelée this , utilisable dans le corps de la méthode, pointe automatiquement vers l’objet sur lequel elle a été appelée.

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
function speak(line) {
  console.log(`The ${this.type} rabbit says '${line}'`);
}
let whiteRabbit = {type: "white", speak};
let hungryRabbit = {type: "hungry", speak};

whiteRabbit.speak("Oh my ears and whiskers, " +
                  "how late it's getting!");
// → The white rabbit says 'Oh my ears and whiskers, how
//   late it's getting!'
hungryRabbit.speak("I could use a carrot right now.");
// → The hungry rabbit says 'I could use a carrot right now.'

Vous pouvez par exemple considérer this comme un paramètre supplémentaire transmis en argument d’une manière implicite. Si vous voulez le passer de manière explicite vous pouvez utiliser la méthode call , qui prend comme premier argument la valeur de this et traite normalement les autres arguments.

 
Sélectionnez
1.
2.
speak.call(hungryRabbit, "Burp!");
// → The hungry rabbit says 'Burp!'

Puisque chaque fonction a sa propre attache à this , dont la valeur dépend de la façon dont elle est appelée, vous ne pouvez pas vous référer au this global dans le cadre d’une fonction définie avec le mot clé function.

Les fonctions fléchées sont différentes : elles n’attachent pas leur propre this , mais elles peuvent voir l’attache du this autour d’elles. Ainsi vous pouvez faire quelque chose comme le code suivant, qui mentionne this depuis une fonction locale :

 
Sélectionnez
1.
2.
3.
4.
5.
function normalize() {
  console.log(this.coords.map(n => n / this.length));
}
normalize.call({coords: [0, 2, 3], length: 5});
// → [0, 0.4, 0.6]

Si j’avais écrit l’argument de la méthode map avec une fonction (en utilisant le mot-clé function ) au lieu d’une fonction fléchée, le code (this.length) n’aurait pas fonctionné.

VII-D. Prototypes

Regardez attentivement.

 
Sélectionnez
1.
2.
3.
4.
5.
let empty = {};
console.log(empty.toString);
// → function toString(){…}
console.log(empty.toString());
// → [object Object]

J’ai récupéré une propriété depuis un objet vide. Magique !

J'ai simplement reçu des informations sur le fonctionnement des objets JavaScript. La plupart des objets ont aussi un prototype en plus de leur ensemble de propriétés. Un prototype est un autre objet qui est utilisé comme source de propriétés. Quand un objet obtient une requête pour une propriété qu’il n’a pas, son prototype cherchera cette propriété, puis le prototype de ce prototype, et ainsi de suite.

Du coup, quel est le prototype de cet objet vide ? C’est le prototype d’origine, l’entité derrière quasiment tous les objets,Object.prototype .

 
Sélectionnez
1.
2.
3.
4.
5.
console.log(Object.getPrototypeOf({}) ==
            Object.prototype);
// → true
console.log(Object.getPrototypeOf(Object.prototype));
// → null

Vous pouvez vous en douter Object.getPrototypeOf retourne le prototype d’un objet.

La relation des prototypes JavaScript forme une structure arborescente, et à la racine de celle-ci siège Object.prototype. Il fournit quelques méthodes qui sont valables pour tous les objets tels que toString, qui converti un objet en une représentation sous forme de chaîne de caractères.

Beaucoup d’objets n’ont pas directement un Object.prototype comme prototype, mais ont à la place un autre objet qui fournit différents ensembles de propriétés par défaut. Les fonctions dérivent de Fonction.prototype et les arrays dérivent de Array.prototype.

 
Sélectionnez
1.
2.
3.
4.
5.
6.
console.log(Object.getPrototypeOf(Math.max) ==
            Function.prototype);
// → true
console.log(Object.getPrototypeOf([]) ==
            Array.prototype);
// → true

Un tel objet prototype aura lui-même un prototype, très souvent Object.prototype. De cette manière il fournit toujours les méthodes comme toString mais indirectement.

Vous pouvez utiliser Object.create pour créer un objet avec un prototype spécifique :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
let protoRabbit = {
  speak(line) {
    console.log(`The ${this.type} rabbit says '${line}'`);
  }
};
let killerRabbit = Object.create(protoRabbit);
killerRabbit.type = "killer";
killerRabbit.speak("SKREEEE!");
// → The killer rabbit says 'SKREEEE!'

Une propriété telle que speak(line) dans un objet est une manière abrégée de définir une méthode. Une propriété appelée speak est en fait créée, et on lui donne une fonction comme valeur.

Le lapin (rabbit) « proto » agit comme un container pour les propriétés qui sont partagées par tous les lapins. Un objet lapin individuel tel que le lapin tueur (killer rabbit) contient des propriétés qui s’appliquent sur lui-même, en l’occurrence son type, et qui dérivent des propriétés partagées par son prototype.

VII-E. Classes

Le système prototype de JavaScript peut être interprété comme une approche quelque peu informelle d'un concept orienté objet appelé « classe ». Une classe définit la forme d'un type d'objet, ses méthodes et ses propriétés. Un tel objet s'appelle une instance de la classe.

Les prototypes sont utiles pour définir des propriétés pour lesquelles toutes les instances d’une classe partagent la même valeur : il en va de même pour les méthodes. Les propriétés différentes entre chaque instance, telles que notre propriété type sur nos lapins, doivent être stockées directement dans les objets eux-mêmes.

Pour créer une instance d’une classe donnée, il faut donc faire un objet qui dérive du prototype approprié, mais vous devez aussi faire en sorte qu’il veille sur lui-même et qu’il ait les propriétés que les instances de cette classe sont supposées avoir. C’est ce qu‘une fonction constructor (constructeur) fait :

 
Sélectionnez
1.
2.
3.
4.
5.
function makeRabbit(type) {
  let rabbit = Object.create(protoRabbit);
  rabbit.type = type;
  return rabbit;
}

JavaScript fournit une meilleure façon de définir ce type de fonction. Si vous mettez le mot-clé

new devant l’appel d’une fonction, cette dernière est traitée comme un constructeur. Cela signifie qu’un objet avec le prototype approprié est automatiquement créé, attaché à this dans la fonction et retourné à la fin de la fonction.

L’objet prototype utilisé lorsqu’on construit des objets peut être obtenu en récupérant la propriété prototype de la fonction « constructeur ».

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
function Rabbit(type) {
  this.type = type;
}
Rabbit.prototype.speak = function(line) {
  console.log(`The ${this.type} rabbit says '${line}'`);
};

let weirdRabbit = new Rabbit("weird");

Les constructeurs (en fait, toutes les fonctions) récupèrent automatiquement une propriété appelée prototype qui par défaut stocke un objet vide dérivant de Object.prototype. Vous pouvez soit la réécrire avec un nouvel objet, soit ajouter des propriétés à l’objet existant tel que le fait l’exemple.

Par convention, les noms des constructeurs sont en capitales pour qu’ils soient facilement distinguables des autres fonctions.

Il est important de comprendre la distinction entre la façon dont un prototype est associé avec un constructeur (à travers sa propriété prototype) et la façon qu’ont les objets d’avoir un prototype (qui peut être obtenu avec Object.getPrototypeOf). Le prototype actuel d’un constructeur est Function.prototype depuis que les constructeurs sont des fonctions. Sa propriété prototype stocke le prototype utilisé pour les instances créées à travers celui-ci.

 
Sélectionnez
1.
2.
3.
4.
5.
6.
console.log(Object.getPrototypeOf(Rabbit) ==
            Function.prototype);
// → true
console.log(Object.getPrototypeOf(weirdRabbit) ==
            Rabbit.prototype);
// → true

VII-F. La notation de classe

Les classes JavaScript sont donc des fonctions « constructeur » avec une propriété « prototype ». Voilà comment cela fonctionne, et jusqu’en 2015 c’était comme ça que vous deviez les écrire. De nos jours, on a une notation moins délicate.

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
class Rabbit {
  constructor(type) {
    this.type = type;
  }
  speak(line) {
    console.log(`The ${this.type} rabbit says '${line}'`);
  }
}

let killerRabbit = new Rabbit("killer");
let blackRabbit = new Rabbit("black");

Le mot-clé class lance la déclaration d’une classe, ce qui nous permet de définir un constructeur et un ensemble de méthodes, toutes à l’intérieur des accolades de déclaration : peu importe leur nombre. Celle intitulée constructor est traitée de manière spéciale. Elle fournit la fonction « constructeur » actuelle qui sera liée au nom Rabbit. Les autres sont empaquetées dans ce prototype de constructeur. Ainsi, la déclaration de la classe précédente est équivalente à la définition du constructeur du paragraphe précédent. C’est juste plus beau maintenant.

Les déclarations de classes n’autorisent pour l’instant que les méthodes, les propriétés qui contiennent les fonctions qui seront ajoutées au prototype. Cela peut être quelque peu problématique lorsque vous voulez sauvegarder une variable n’appartenant pas à une seule fonction. Pour l’instant, vous pouvez créer de telles propriétés en manipulant directement le prototype après que vous avez défini la classe.

Comme le mot clé function, class peut être utilisé à la fois dans les déclarations et dans les expressions. Lorsqu’il est utilisé comme expression, il n’effectue pas d’attache mais renvoie juste le constructeur comme une variable. Vous avez le droit d’omettre le nom de la classe dans l’expression d’une classe.

 
Sélectionnez
1.
2.
3.
let object = new class { getWord() { return "hello"; } };
console.log(object.getWord());
// → hello

VII-G. Réécrire des propriétés dérivées

Quand vous ajoutez une propriété à un objet, qu’elle soit présente dans le prototype ou pas, la propriété est ajoutée à l’objet lui-même. S’il y avait déjà une propriété avec le même nom dans le prototype, cette propriété n’affectera désormais plus cet objet comme elle est maintenant cachée derrière la propriété de son propre objet.

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
function Rabbit(type) {
    this.type = type;
}

Rabbit.prototype.teeth = "small";

let blackRabbit = new Rabbit("black");
let killerRabbit = new Rabbit("killer");

console.log(killerRabbit.teeth);
// → small

killerRabbit.teeth = "long, sharp, and bloody";

console.log(killerRabbit.teeth);
// → long, sharp, and bloody

console.log(blackRabbit.teeth);
// → small

console.log(Rabbit.prototype.teeth);
// → small

Le diagramme suivant illustre la situation après que ce code a été lancé. Les prototypes Object et Rabbit se situent derrière killerRabbit comme une sorte d’arrière-plan où les propriétés qui ne sont pas trouvées dans l’objet lui-même sont consultées.

Image non disponible

Réécrire des propriétés qui existent dans un prototype peut être utile. En effet, comme l’exemple sur la dent de lapin le montre, la réécriture peut être utilisée pour exprimer des propriétés exceptionnelles d’une classe d’objets plus générique plutôt que laisser l’objet prendre une valeur par défaut depuis son prototype.

La réécriture est aussi utilisée pour donner aux prototypes de fonctions standards et d’array, une méthode toString différente par rapport à celle de l’objet prototype basique.

 
Sélectionnez
1.
2.
3.
4.
5.
console.log(Array.prototype.toString ==
            Object.prototype.toString);
// → false
console.log([1, 2].toString());
// → 1,2

L’appel de toString sur un Array donne un résultat similaire à l’appel de .join(","): cela met des virgules entre chaque valeur dans l’array. Appeler directement Object.prototype.toString avec un Array donne un résultat différent. Cette fonction ne sait rien sur les Arrays : elle insère donc tout simplement le mot object et le nom du type entre des crochets.

 
Sélectionnez
1.
2.
console.log(Object.prototype.toString.call([1, 2]));
// → [object Array]

VII-H. Maps

Nous avons abordé le mot-clé map utilisé dans le chapitre précédent pour une opération qui transforme une structure de données en appliquant une fonction sur ses éléments. Aussi confus que cela puisse paraître, en programmation le même mot-clé est aussi utilisé pour autre chose bien que ce quelque chose ait à voir avec la première utilisation.

Une map (nom) est une structure de données qui associe des valeurs (les clefs) avec d’autres valeurs. Par exemple, il est possible que vous vouliez associer des noms avec des ages. Il est donc possible d’utiliser des objets pour cela.

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
let ages = {
  Boris: 39,
  Liang: 22,
  Júlia: 62
};

console.log(`Júlia is ${ages["Júlia"]}`);
// → Júlia is 62
console.log("Is Jack's age known?", "Jack" in ages);
// → Is Jack's age known? false
console.log("Is toString's age known?", "toString" in ages);
// → Is toString's age known? true

Ici, les noms de la propriété de l’objet sont les noms des personnes et les valeurs de la propriété sont leur age. Mais nous n’avons certainement pas listé les noms de chaque personne dans notre map. Cependant, comme les objets classiques dérivent de Object.prototype, il semblerait que la propriété se trouve ici.

Utiliser des objets comme maps est donc dangereux. Il y a plusieurs manières d’éviter ce problème. Premièrement, il est possible de créer des objets sans prototype. Si vous donnez null à Object.create, l’objet résultant ne dérivera plus de Object.prototype et pourra être utilisé comme map de manière sécurisée :

 
Sélectionnez
1.
2.
console.log("toString" in Object.create(null));
// → false

Les noms des propriétés « objet » doivent être des chaînes de caractères. Si vous avez besoin d’une map dont les clefs ne peuvent pas être facilement converties en chaînes de caractères tel que les objets, vous ne pouvez pas utiliser votre objet en tant que map.

Heureusement, JavaScript fourni une classe nommée Map qui a été écrite dans ce but précis. Elle stocke une map qui autorise tous les types de clefs, pas seulement les chaînes de caractères.

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
let ages = new Map();
ages.set("Boris", 39);
ages.set("Liang", 22);
ages.set("Júlia", 62);

console.log(`Júlia is ${ages.get("Júlia")}`);
// → Júlia is 62
console.log("Is Jack's age known?", ages.has("Jack"));
// → Is Jack's age known? false
console.log(ages.has("toString"));
// → false

Les méthodes set, get et has font partie de l’interface d’un objet Map. Écrire une structure de données qui peut être rapidement mise à jour et peut chercher un ensemble de valeurs important n’est pas facile, mais heureusement, on n’a pas besoin de s’embêter avec ça. Quelqu’un d’autre l’a fait pour nous, et on va pouvoir passer à travers cette interface très simple pour utiliser leur travail.

Si vous avez besoin de traiter un objet ordinaire comme une map, peu importent les raisons, il est important de savoir que Object.keys renvoie seulement les clefs de son propre objet et non pas ceux qui se trouvent dans le prototype. Comme alternative à l’opérateur in, vous pouvez utiliser la méthode hasOwnProperty qui ignore le prototype de l’objet.

 
Sélectionnez
1.
2.
3.
4.
console.log({x: 1}.hasOwnProperty("x"));
// → true
console.log({x: 1}.hasOwnProperty("toString"));
// → false

VII-I. Polymorphisme

Quand vous appelez la fonction String (qui convertit n’importe quelle valeur en chaîne de caractères) sur un objet, la méthode toString sera en fait appelé sur cet objet pour essayer de créer une chaîne de caractères compréhensible depuis celui-ci. Je tiens à le préciser, car certains des prototypes standards définissent leur propre version de toString pour qu’il puisse créer une chaîne de caractères qui contienne plus d’informations utiles que « [object Object]». Vous pouvez par ailleurs le faire vous-même.

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
function Rabbit(type) {
    this.type = type;
}

Rabbit.prototype.speak = function (line) {
    console.log(`The ${this.type} rabbit says '${line}'`);
};

Rabbit.prototype.toString = function () {
    return `a ${this.type} rabbit`;
};

let blackRabbit = new Rabbit("black");

console.log(String(blackRabbit));
// → a black rabbit

Voici un exemple simple tiré d’une idée bien plus complexe. Quand un bout de code est écrit de façon à fonctionner avec des objets qui ont une interface donnée, dans notre cas une méthode toString, n’importe quel objet qui peut supporter cette interface peut être ajouté au code et cela fonctionnera parfaitement

Cette technique est appelée polymorphism. Un code polymorphique peut fonctionner avec des valeurs de différentes formes tant qu’elles sont en adéquation avec l’interface attendue.

J’ai mentionné dans le chapitre 4 qu’une boucle for/ of peut parcourir plusieurs types de structures de données. C’est un autre exemple du polymorphisme : de telles boucles s’attendent à ce que la structure de données soit constituée d’un type de données spécifique, ce que les arrays et les chaînes de caractères font ! Mais avant de pouvoir faire cela, nous devons savoir ce que sont les symboles.

VII-J. Symboles

Il est possible pour de multiples interfaces d’utiliser un même nom de propriété pour des choses différentes. Par exemple, je pourrais définir une interface dans laquelle la méthode toString convertit l’objet en une chaîne de caractères plus exhaustive. Il est donc impossible pour l’objet de se conformer à la fois à cette interface et à l’utilisation standard de toString .

Ça serait donc une mauvaise idée, et ce problème n’est pas si commun. La plupart des programmeurs JavaScript n’y pensent tout simplement pas. Mais les concepteurs de langage, dont le travail est de justement penser à ça, nous ont fourni une solution.

Lorsque j’ai dit que les noms de propriétés sont des chaînes de caractères, ce n’était pas en fait cent pour cent vrai. Bien que ce soit généralement le cas, ils peuvent aussi être des symboles. Les symboles sont des valeurs créées avec la fonction Symbol. Au contraire des chaînes de caractères les symboles nouvellement créés sont uniques, vous ne pouvez pas créer le même symbole deux fois.

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
function Rabbit(type) {
    this.type = type;
}

Rabbit.prototype.speak = function (line) {
    console.log(`The ${this.type} rabbit says '${line}'`);
};

Rabbit.prototype.toString = function () {
    return `a ${this.type} rabbit`;
};

let blackRabbit = new Rabbit("black");

let sym = Symbol("name");

console.log(sym == Symbol("name"));
// → false
Rabbit.prototype[sym] = 55;

console.log(blackRabbit[sym]);
// → 55

La chaîne de caractère que vous passez à Symbol est incluse lorsque vous convertissez ce dernier en chaîne de caractère : cela peut donc être plus facile à reconnaître lorsque, par exemple, vous l’affichez dans la console. Mais il n’y a aucun sens derrière ceci, il est possible que plusieurs symboles aient le même nom.

Étant à la fois uniques et utilisables en tant que noms de propriétés, les symboles conviennent parfaitement à la définition d'interfaces pouvant cohabiter pacifiquement avec d'autres propriétés, quel que soit leur nom.

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
const toStringSymbol = Symbol("toString");
Array.prototype[toStringSymbol] = function() {
  return `${this.length} cm of blue yarn`;
};

console.log([1, 2].toString());
// → 1,2
console.log([1, 2][toStringSymbol]());
// → 2 cm of blue yarn

Il est possible d'inclure des propriétés de symbole dans des expressions d'objet et des classes en utilisant des crochets autour du nom de la propriété. Cela provoque l'évaluation du nom de la propriété, un peu comme la notation d'accès à la propriété entre crochets, qui nous permet de faire référence à une liaison qui contient le symbole.

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
const toStringSymbol = Symbol("toString");

let stringObject = {
    [toStringSymbol]() {
        return "a jute rope";
    }
};

console.log(stringObject[toStringSymbol]());
// → a jute rope

VII-K. L’interface Iterator

On attend d’un objet qu’il soit itérable lorsqu’il est donné à une boucle for /of. Cela signifie qu’il a une méthode nommée avec le symbole Symbol.iterator(une valeur symbole définie par le langage et stockée comme une propriété de la fonction Symbol).

Lorsqu'elle est appelée, cette méthode doit renvoyer un objet fournissant une deuxième interface, l'itérateur. C'est la chose réelle qui itère. Elle possède une méthode next qui renvoie le prochain résultat. Ce résultat doit être un objet avec une propriétévalue qui fournit la prochaine valeur s’il y en a une, ainsi qu’une propriété done qui doit être true lorsqu’il n’y a plus de résultat ou false dans le cas opposé.

Gardez en tête que les noms de propriétés next , value et done sont des strings natives et non pas des symboles. C’est seulement Symbol.iterator, susceptible d’être ajouté à beaucoup d’autres objets, qui est un symbole réel.

On peut directement utiliser ces interfaces soi-même.

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
let okIterator = "OK"[Symbol.iterator]();
console.log(okIterator.next());
// → {value: "O", done: false}
console.log(okIterator.next());
// → {value: "K", done: false}
console.log(okIterator.next());
// → {value: undefined, done: true}

Implémentons une structure de données itérable. Pour ce faire nous construirons une classe matrix (matrice) qui se comportera comme un array à deux dimensions.

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
class Matrix {
  constructor(width, height, element = (x, y) => undefined) {
    this.width = width;
    this.height = height;
    this.content = [];

    for (let y = 0; y < height; y++) {
      for (let x = 0; x < width; x++) {
        this.content[y * width + x] = element(x, y);
      }
    }
  }

  get(x, y) {
    return this.content[y * this.width + x];
  }
  set(x, y, value) {
    this.content[y * this.width + x] = value;
  }
}

La classe stocke son contenu dans un seul tableau d'éléments largeur × hauteur. Les éléments sont stockés ligne par ligne. Ainsi, par exemple, le troisième élément de la cinquième ligne est (en utilisant l’indexation basée sur zéro) stocké à la position 4 × largeur + 2.

La fonction constructeur prend en arguments une largeur, une hauteur et une fonction element optionnelle qui sera utilisée pour initialiser les valeurs. Par la suite, les méthodes get et set permettent de retrouver et de mettre à jour des éléments dans notre matrice.

Lorsque vous utilisez cette matrice dans une boucle, vous êtes autant intéressés par la position des éléments que par les éléments eux-mêmes : on va donc faire en sorte que notre itérateur renvoie des objets comprenant les propriétés X, Y et value.

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
29.
30.
31.
32.
33.
34.
35.
36.
37.
38.
39.
40.
41.
42.
43.
44.
45.
46.
47.
48.
49.
50.
51.
52.
53.
54.
55.
56.
57.
58.
59.
60.
61.
62.
63.
64.
65.
66.
67.
class Matrix {
    constructor(width, height, element = (x, y) => undefined) {
        this.width = width;
        this.height = height;
        this.content = [];

        for (let y = 0; y < height; y++) {
            for (let x = 0; x < width; x++) {
                this.content[y * width + x] = element(x, y);
            }
        }
    }

    get(x, y) {
        return this.content[y * this.width + x];
    }

    set(x, y, value) {
        this.content[y * this.width + x] = value;
    }
}

class MatrixIterator {
    constructor(matrix) {
        this.x = 0;
        this.y = 0;
        this.matrix = matrix;
    }

    next() {
        if (this.y == this.matrix.height) return {
            done: true
        };

        let value = {
            x: this.x,
            y: this.y,
            value: this.matrix.get(this.x, this.y)
        };
        this.x++;
        if (this.x == this.matrix.width) {
            this.x = 0;
            this.y++;
        }
        return {
            value,
            done: false
        };
    }
}

Matrix.prototype[Symbol.iterator] = function () {
    return new MatrixIterator(this);
};

let matrix = new Matrix(2, 2, (x, y) => `value ${x},${y}`);

// {x, y, value} est la déstructuration de 
// l'objet renvoyé par MatrixIterator
for (let {x, y, value} of matrix) { 
    console.log(`x = ${x}, y = ${y}, ${value}`);
}

x = 0, y = 0, value 0,0
x = 1, y = 0, value 1,0
x = 0, y = 1, value 0,1
x = 1, y = 1, value 1,1

Comme ceci, la classe suit la progression de l’itération sur sa matrice avec ses propriétés x et y. La méthode next commence donc par vérifier si la fin de la matrice a été atteinte. Si ce n’est pas le cas, elle commence par créer l’objet qui comprend la valeur actuelle, puis elle met à jour sa position en se déplaçant à la ligne suivante si cela est nécessaire.

Nous allons maintenant faire en sorte que la classe Matrix soit itérable. À travers ce livre, j’utiliserai occasionnellement la manipulation de prototypes après coup, pour rajouter des méthodes à des classes de façon que les bouts de codes individuels restent petits et indépendants. Dans un programme normal, où il n’est pas utile de séparer le code en petits bouts, vous pouvez déclarer ces méthodes directement dans la classe à la place.

 
Sélectionnez
1.
2.
3.
Matrix.prototype[Symbol.iterator] = function() {
  return new MatrixIterator(this);
};

Nous pouvons maintenant boucler sur une matrice avec for / of .

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
29.
30.
31.
32.
33.
34.
35.
36.
37.
38.
39.
40.
41.
42.
43.
44.
45.
46.
47.
48.
49.
50.
51.
52.
53.
54.
55.
56.
57.
58.
59.
60.
61.
62.
63.
64.
65.
66.
67.
class Matrix {
    constructor(width, height, element = (x, y) => undefined) {
        this.width = width;
        this.height = height;
        this.content = [];

        for (let y = 0; y < height; y++) {
            for (let x = 0; x < width; x++) {
                this.content[y * width + x] = element(x, y);
            }
        }
    }

    get(x, y) {
        return this.content[y * this.width + x];
    }

    set(x, y, value) {
        this.content[y * this.width + x] = value;
    }
}

class MatrixIterator {
    constructor(matrix) {
        this.x = 0;
        this.y = 0;
        this.matrix = matrix;
    }

    next() {
        if (this.y == this.matrix.height) return {
            done: true
        };

        let value = {
            x: this.x,
            y: this.y,
            value: this.matrix.get(this.x, this.y)
        };
        this.x++;
        if (this.x == this.matrix.width) {
            this.x = 0;
            this.y++;
        }
        return {
            value,
            done: false
        };
    }
}

Matrix.prototype[Symbol.iterator] = function () {
    return new MatrixIterator(this);
};

let matrix = new Matrix(2, 2, (x, y) => `value ${x},${y}`);

// {x, y, value} est la déstructuration de 
// l'objet renvoyé par MatrixIterator
for (let {x, y, value} of matrix) { 
    console.log(`x = ${x}, y = ${y}, ${value}`);
}

x = 0, y = 0, value 0,0
x = 1, y = 0, value 1,0
x = 0, y = 1, value 0,1
x = 1, y = 1, value 1,1

VII-L. Accesseurs(getters), mutateurs(setters) et statiques

Les interfaces consistent souvent en un ensemble de méthodes, mais il est aussi permis d’y inclure des propriétés qui ne contiennent pas des valeurs de fonctions. Par exemple, l’objet Map possède une propriété size qui vous dit combien de clefs sont stockées.

Il n’est même pas obligatoire pour de tels objets de calculer et de stocker ce type de propriété directement dans une instance. Même les propriétés auxquelles on accède directement peuvent masquer un appel de méthode. De telles méthodes sont appelées getters (accesseurs) et elles sont définies grâce au mot get devant le nom d’une méthode dans l’expression d’un objet ou dans une déclaration de classe.

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
let varyingSize = {
  get size() {
    return Math.floor(Math.random() * 100);
  }
};

console.log(varyingSize.size);
// → 73
console.log(varyingSize.size);
// → 49

À chaque fois que quelque chose lit cette propriété d’objet size, la méthode associée est appelée. Vous pouvez faire de même lorsqu’une propriété est attribuée à une valeur en utilisant un setter (mutateur).

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
class Temperature {
  constructor(celsius) {
    this.celsius = celsius;
  }
  get fahrenheit() {
    return this.celsius * 1.8 + 32;
  }
  set fahrenheit(value) {
    this.celsius = (value - 32) / 1.8;
  }

  static fromFahrenheit(value) {
    return new Temperature((value - 32) / 1.8);
  }
}

let temp = new Temperature(22);
console.log(temp.fahrenheit);
// → 71.6
temp.fahrenheit = 86;
console.log(temp.celsius);
// → 30

La classe Temperature vous permet d’écrire et de lire la température, que se soit en degrés Celsius ou en degrés Fahrenheit, mais en interne elle stocke seulement les Celsius et converti automatiquement en Fahrenheit depuis des Celsius dans l’accesseur et le mutateur fahrenheit .

Il est possible que parfois vous vouliez relier certaines propriétés directement à votre fonction constructeur plutôt que à votre prototype. De telles méthodes n’auront pas accès à une instance de classe mais peuvent par exemple être utilisées pour fournir d’autres façons de créer des instances.

À l’intérieur de la déclaration d’une classe, les méthodes qui ont le mot clef static écrit devant leur nom sont stockées dans le constructeur. Par conséquent, la classe Temperature vous permet d’écrire Temperature.fromFahrenheit(100) pour créer une température en utilisant des degrés Fahrenheit.

VII-M. Héritage

Certaines matrices sont connues pour être symétriques. Si vous regardez l’image miroir d’une matrice symétrique avec pour axe sa diagonale partant de « en haut à gauche » et allant « en bas à droite », elle reste la même. En d’autres termes, la valeur stockée en (x,y) est toujours la même que en (y,x).

Imaginez que nous ayons besoin d’une structure de données semblable à notre classe Matrix mais qui applique le fait que la matrice est et demeure symétrique. On pourrait l’écrire depuis le départ mais cela impliquerait de se répéter dans notre code, puisqu’on écrirait quelque chose de très similaire à ce que l’on a déjà écrit.

Le système de prototypage JavaScript rend possible la création d’une nouvelle classe, ressemblant à l’ancienne, mais avec certaines redéfinitions pour certaines de ses propriétés. Le prototype de cette nouvelle classe dérive de l’ancien prototype mais rajoute une nouvelle définition pour, disons, la méthode set.

Dans les termes de la programmation orientée objet, on appelle cela l’héritage. La nouvelle classe hérite les propriétés et le comportement de l’ancienne classe.

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
class SymmetricMatrix extends Matrix {
  constructor(size, element = (x, y) => undefined) {
    super(size, size, (x, y) => {
      if (x < y) return element(y, x);
      else return element(x, y);
    });
  }

  set(x, y, value) {
    super.set(x, y, value);
    if (x != y) {
      super.set(y, x, value);
    }
  }
}

let matrix = new SymmetricMatrix(5, (x, y) => `${x},${y}`);
console.log(matrix.get(2, 3));
// → 3,2

L’utilisation du mot extend indique que cette classe ne doit pas être directement basée sur le prototype Object par défaut, mais sur une autre classe. On appelle ça la Super-classe tandis que la classe dérivée est la sous-classe.

Pour initialiser une instance SymmetricMatrix , le constructeur appelle le constructeur de sa superclass avec le mot-clef super . Cela est obligatoire car si ce nouvel objet doit se comporter (à peu près) comme la classe Matrix, il va avoir besoin des propriétés de l’instance que cette classe possède. Pour que la matrice soit symétrique, le constructeur encapsule la fonction element pour permuter les coordonnées des valeurs situées au-dessous de la diagonale.

La méthode set utilise encore une fois supermais cette fois ci non pas pour appeler le constructeur, mais pour appeler une méthode spécifique de l’ensemble de méthodes de la superclass. Nous redéfinissons set mais nous voulons quand même utiliser le comportement original. Comme this.set se réfère à la nouvelle méthode set , l’appeler ne fonctionnerait pas. À l’intérieur de méthodes de classes, super permet d’appeler des méthodes qui ont été définies dans la superclass.

L’héritage nous permet de construire des types de données légèrement différents d’autres types de données existants avec relativement peu de travail. C’est une partie fondamentale de la tradition de la programmation orientée objet, tout autant que l’encapsulation ou le polymorphisme. Bien que ces deux derniers soient généralement vus comme des inventions brillantes, l’héritage est plus controversé.

Alors que l’encapsulation et le polymorphisme peuvent être utilisés pour séparer des bouts de code les uns des autres, ce qui réduit l’enchevêtrement d’un programme global, l’héritage noue les classes entre elles ce qui crée d’autres enchevêtrements. Lorsqu’une classe hérite d’une autre, vous devez généralement en savoir plus sur la manière dont la classe parente fonctionne que si vous l’utilisiez simplement. L’héritage peut être un outil utile et je l’utilise et l’utiliserai dans mes propres programmes, mais ce ne devrait pas être le premier outil que vous recherchez, et vous ne devriez pas activement chercher des opportunités de construire des hiérarchies de classes (arbres généalogiques de classes).

VII-N. L’opérateur instanceof

Il est parfois utile de savoir si un objet a été dérivé d’une classe spécifique. Pour cela, JavaScript fournit un opérateur binaire appelé instanceof .

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
console.log(
  new SymmetricMatrix(2) instanceof SymmetricMatrix);
// → true
console.log(new SymmetricMatrix(2) instanceof Matrix);
// → true
console.log(new Matrix(2, 2) instanceof SymmetricMatrix);
// → false
console.log([1] instanceof Array);
// → true

L’opérateur verra à travers les types hérités, donc SymmetricMatrix est une instance de Matrix . Il peut être aussi appliqué à des constructeurs standards comme Array, bien que presque tous les objets soient une instance de Object .

VII-O. Résumé

Ainsi, les objets font bien plus que juste contenir leurs propres propriétés. Ils ont des prototypes, qui sont en fait d’autres objets. Ils vont agir comme s’ils avaient des propriétés qu’ils n’ont pas, du moment que leur prototype les ont. Les objets simples ont comme prototype Object.prototype .

Les constructeurs, qui sont en fait des fonctions dont le nom commence habituellement par une majuscule, peuvent être utilisés avec l’opérateur new pour créer de nouveaux objets. Le prototype de l’objet créé sera l’objet trouvé dans la propriété prototype du constructeur. Vous pouvez utilisez intelligemment ceci en mettant les propriétés que toutes les valeurs d’un type donné partagent dans le prototype. Il existe la notation class qui permet de définir de façon claire un constructeur et son prototype.

Vous pouvez définir des getters et des setters pour secrètement appeler des méthodes à chaque fois que la propriété d’un objet est obtenue ou modifiée. Les méthodes statiques sont des méthodes stockées dans le constructeur de la classe, au lieu d’être stockées dans son prototype.

L’opérateur instanceof peut, pour un objet et un constructeur donné, vous dire si cet objet est une instance de ce constructeur.

Une chose utile à faire avec les objets est de leur spécifier une interface et dire à tout le monde qu’ils sont censés discuter avec votre objet seulement à travers cette interface. Les détails supplémentaires qui composent votre objet sont ainsi encapsulés, c’est-à-dire cachés derrière cette interface.

Il est possible que plusieurs types implémentent la même interface. Un code écrit pour utiliser une interface automatiquement sait comment fonctionner, peu importe le nombre d’objets différents qui fournissent l’interface. On appelle cela le polymorphisme.

Lorsqu’on implémente différentes classes qui diffèrent sur seulement quelques détails, il peut être utile d’écrire la nouvelle classe comme une sous-classe de celle existante, de façon qu’elle hérite son comportement.

VII-P. Exercices

VII-P-1. Un type Vector

Écrivez une classe Vec qui représente un vecteur en deux dimensions. Il doit prendre comme paramètre x et y (des nombres), qui devront être sauvegardés en propriétés du même nom.

Donnez au prototype de Vec deux méthodes, plus et minus, qui prennent un autre vector en paramètre et retournent un nouveau vector comprenant la somme ou la différence de x et y des deux vectors (celui de l’objet et celui du paramètre).

Ajoutez une propriété getter nommée length au prototype qui calcule la taille du vector, c’est-à-dire la distance du point (x, y) depuis l’origine (0,0).

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
// Your code here.

console.log(new Vec(1, 2).plus(new Vec(2, 3)));
// → Vec{x: 3, y: 5}
console.log(new Vec(1, 2).minus(new Vec(2, 3)));
// → Vec{x: -1, y: -1}
console.log(new Vec(3, 4).length);
// → 5

Indices :

  • regardez à nouveau l’exemple de la classe Rabbit si vous n’êtes pas certain du fonctionnement de class;
  • ajouter une propriété getter au constructeur peut être accompli en mettant le mot-clef get devant le nom de la méthode. Pour calculer la distance de (0, 0) à (x, y), vous pouvez utiliser le théorème de Pythagore, qui dit que le carré de la distance que nous recherchons est égale au carré de la coordonnée x plus le carré de la coordonnée y. Ainsi la solution peut être donnée par la formule √(x2 + y2) : sachez que Math.sqrt permet de calculer une racine carrée en JavaScript.

VII-P-2. Des groupes

L’environnement standard du JavaScript fournit une autre structure de données nommée Set. De même que pour une instance de Map, un ensemble contenant une série de valeurs. Contrairement à Map, il n’est pas possible d’associer d’autres valeurs avec celles-ci. Cela dit juste quelles valeurs font partie de l’ensemble. Une valeur peut faire partie d’un ensemble seulement une fois, l’ajouter à nouveau n’aura aucun effet.

Écrivez une classe appelée Group (puisque Set est déjà pris). Comme Set, elle a les méthodes add, delete et has. Son constructeur crée un groupe vide, la méthode add ajoute une nouvelle valeur au groupe (seulement s’il n’y a pas déjà cette valeur), delete supprime la valeur donnée en argument de la méthode du groupe (si elle y était), et has renvoie une valeur booléenne indiquant si l’argument qui lui a été passé existe.

Utilisez l’opérateur ===, ou quelque chose d’équivalent tel que indexOf, pour déterminer si deux valeurs sont identiques.

Donnez à la classe une méthode statique from qui prend un objet itérable en argument et crée un groupe qui contient toutes les valeurs produites en itérant ce dernier.

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
class Group {
  // Your code here.
}

let group = Group.from([10, 20]);
console.log(group.has(10));
// → true
console.log(group.has(30));
// → false
group.add(10);
group.delete(10);
console.log(group.has(10));
// → false

Indices :

  • la méthode la plus facile pour faire cela consiste à stocker un array contenant les membres du groupe dans une propriété d’instance. Les méthodes includes ou indexOf peuvent être utilisées pour vérifier si une valeur donnée existe dans l’array ;
  • le constructeur de votre classe peut paramétrer la série d’éléments en un array vide. Lorsque add est appelée, elle vérifiera si la valeur donnée se trouve déjà dans l’array et le cas échéant elle l’ajoutera, par exemple avec push ;
  • supprimer un élément d’un array dans delete est plus difficile, mais vous pouvez utilisez filter pour créer un nouvel array sans la valeur. N’oubliez pas de réécrire la propriété pour qu’elle contienne l’array nouvellement créé ;
  • la méthode from peut utiliser une boucle for/ of pour récupérer les valeurs à l’extérieur d’un objet itérable et appeler add pour les mettre dans un groupe nouvellement créé.

VII-P-3. Des groupes itérables

Faites en sorte que la classe Group de l’exercice précédent soit itérable. Référez-vous à la section sur l’interface iterator plus haut dans le chapitre si vous n’êtes pas certain de la forme exacte de l’interface.

Si vous avez utilisé un array pour représenter les membres du groupe, ne retournez pas juste un itérateur de l’interface iterator en appelant la méthode Symbol.iterator sur l’array. Cela fonctionnerait, mais ça supprime le but de cet exercice.

Il est permis que votre itérateur se comporte bizarrement lorsque le groupe est modifié pendant l’itération.

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
// Your code here (and the code from the previous exercise)

for (let value of Group.from(["a", "b", "c"])) {
  console.log(value);
}
// → a
// → b
// → c

C’est probablement rentable de définir une nouvelle classe GroupIterator. Les instances d’itérateurs doivent avoir une propriété qui suit la position actuelle de l’itérateur dans le groupe. À chaque fois que next est appelée, elle vérifie si cette dernière a été déplacée sur la valeur suivant la valeur actuelle et le cas échéant elle modifie cette dernière puis la renvoie.

La classe Group récupère elle-même une méthode nommée Symbol.iterator qui, lorsqu’elle est appelée, retourne une nouvelle instance de la classe de l’itérateur pour ce groupe.

VII-P-4. Emprunter une méthode

Plus tôt dans le chapitre, j’ai spécifié que la propriété hasOwnProperty peut être utilisée comme une alternative plus fiable à l’opérateur in quand vous voulez ignorer les propriétés du prototype. Mais que se passe-t-il si votre map a besoin d’inclure le mot clef « hasOwnProperty» ? Vous ne serez dorénavant pas capable d’appeler cette méthode, car la propriété propre à l’objet cache la valeur de cette méthode.

Pouvez-vous penser à une façon d’appeler hasOwnProperty sur un objet qui possède une propriété du même nom ?

 
Sélectionnez
1.
2.
3.
4.
5.
let map = {one: true, two: true, hasOwnProperty: true};

// Fix this call
console.log(map.hasOwnProperty("one"));
// → true

Indices :

  • rappelez-vous que les méthodes qui existent sur des objets ordinaires proviennent de Object.prototype;
  • rappelez-vous aussi que vous pouvez appeler une fonction avec une attache this particulière en utilisant sa méthode call.

précédentsommaire

Vous avez aimé ce tutoriel ? Alors partagez-le en cliquant sur les boutons suivants : Viadeo Twitter Facebook Share on Google+   

  

Les sources présentées sur cette page sont libres de droits et vous pouvez les utiliser à votre convenance. Par contre, la page de présentation constitue une œuvre intellectuelle protégée par les droits d'auteur. Copyright © 2019 Marijn Haverbeke. Aucune reproduction, même partielle, ne peut être faite de ce site ni de l'ensemble de son contenu : textes, documents, images, etc. sans l'autorisation expresse de l'auteur. Sinon vous encourez selon la loi jusqu'à trois ans de prison et jusqu'à 300 000 € de dommages et intérêts.