Chapitre 8: Programmation orientée objet
Au début des années 90, une chose appelée programmation orientée objet souffla un vent nouveau sur l’industrie du logiciel. La plupart des idées derrière ce concept n’étaient pas vraiment nouvelles, mais elles avaient enfin suffisamment d’élan pour décoller, et devenir « à la mode ». Des livres furent écrits sur le sujet, des cours furent organisés, des langages de programmation développés. Tout d’un coup, tout le monde se mit à vanter les mérites de la programmation orientée objet, appliquant ses recettes à tous les problèmes avec enthousiasme, se convainquant qu’on avait enfin trouvé la bonne façon d’écrire des programmes.
Ces choses arrivent souvent. Quand un problème est compliqué, les gens cherchent toujours une solution magique. Quand arrive quelque chose qui ressemble à cette solution, ils sont prêts à s’y jeter corps et âme. Pour de nombreux programmeurs, encore aujourd’hui, l’orientation objet (ou du moins la vision qu’ils en ont) est la panacée. Si un programme n’est pas « en pur objet », quel que soit le sens de cette expression, il est considéré comme résolument inférieur.
Toutefois, peu d’engouements ont duré si longtemps. La longévité de la programmation orientée objet peut sûrement s’expliquer par le fait que les idées centrales de concept sont utiles et simples. Dans ce chapitre, nous allons parler de ces idées et de leur application, plutôt excentriques, au JavaScript. Les paragraphes précédents n’étaient absolument pas destinés à discréditer ces idées. Mon objectif était juste d’éviter qu’on ne jure plus que par elles.
Comme son nom l’indique, la programmation orientée objet est centrée sur la
notion d’objet. Depuis le début, nous avons utilisé les objets comme des
espèces de fourre-tout plein de valeurs, où l’on ajoute ou modifie des
propriétés à notre guise. En fait, dans une approche orientée objet, les objets
sont vus comme des microcosmes indépendants, qui ne communiquent avec
l’extérieur qu’à travers un nombre limité d’interfaces, un ensemble de
méthodes et propriétés spécifiques. La « liste des nœuds atteints » utilisée à
la fin du chapitre 7 en est un exemple : nous avons utilisé trois fonctions,
creerListePointsParcourus
, stockerPointsParcourus
et
trouverPointsParcourus
pour interagir avec elle. Ces trois fonctions forment
une interface pour cette sorte d’objets.
Les objets Date
, Error
et BinaryHeap
que nous avons vus fonctionnent
également comme cela. Au lieu de fournir des fonctions classiques pour
travailler avec ces objets, ils fournissent une manière d’être créés, via le
mot-clé new
, et un certain nombre de méthodes et propriétés qui forment le
reste de l’interface.
Pour faire une méthode d’objet, il suffit de définir une variable qui contiendra une fonction.
var lapin = {}; lapin.parler = function(tirade) { print("Le lapin dit '", tirade, "'"); }; lapin.parler("Eh bien, maintenant c’est vous qui me le demandez.");
Dans la plupart des cas, la méthode aura besoin de savoir sur qui elle doit
s’appliquer. Par exemple, s’il y a plusieurs lapins, la méthode parler
doit
pouvoir indiquer quel est le lapin qui parle. Pour ce faire, il y a une
variable spéciale appelée this
, qui est toujours définie à l’intérieur
d’une fonction et qui pointe vers l’objet sur lequel la fonction s’applique.
Une fonction définie en tant que propriété d’un objet s’utilise comme une
méthode, elle est appelée de la façon suivante : objet.methode()
.
function parler(tirade) { print("Le lapin ", this.adjectif, " dit « ", tirade, " »"); } var lapinBlanc = {adjectif: "blanc", parler: parler}; var grosLapin = {adjectif: "gras", parler: parler}; lapinBlanc.parler("Par ma moustache et mes oreilles, comme il se fait tard !"); grosLapin.parler("J’ai bien envie d’une carotte, maintenant.");
Je peux maintenant clarifier la présence du mystérieux premier argument de la
méthode apply
, pour lequel nous avons toujours mis null
dans le chapitre 6.
Cet argument peut être utilisé pour spécifier un objet sur lequel la fonction
s’appliquera, qui prendra donc le rôle de this
. Toutefois, pour les fonctions
qui ne sont pas des méthodes, cela n’a pas de sens, d’où le null
.
parler.apply(grosLapin, ["Miam."]);
Les fonctions ont également une méthode call
, qui se comporte comme
apply
, à l’exception du fait que les arguments peuvent être fournis
séparément, et non dans un tableau :
parler.call(grosLapin, "Rot.");
Le mot-clé new
fournit un bon moyen de créer de nouveaux objets. Quand une
fonction est appelée avec le mot new
devant, sa variable this
pointe sur
un nouvel objet, qui sera automatiquement retourné (à moins que la fonction
ne retourne explicitement autre chose). Les fonctions utilisées pour créer de
nouveaux objets de cette manière sont appelées des constructeurs. En voici un
pour les lapins :
function Lapin(adjectif) { this.adjectif = adjectif; this.parler = function(tirade) { print("Le lapin ", this.adjectif, " dit '", tirade, "'"); }; } var lapinTueur = new Lapin("tueur"); lapinTueur.parler("GRAAAAAAAAAH !");
Il y a une convention, parmi les programmeurs JavaScript, qui consiste à faire débuter les noms de constructeurs par une lettre majuscule. Cela permet de mieux les reconnaître au milieu des autres fonctions.
Mais pourquoi le mot clé new
est-il nécessaire ? Après tout, nous aurions pu
écrire simplement :
function creerLapin(adjectif) { return { adjectif: adjectif, parler: function(tirade) {/*etc.*/} }; } var lapinNoir = creerLapin("noir");
Mais ce n’est pas exactement la même chose. new
en fait discrètement plus. En
fait, notre fonction lapinTueur
a une propriété appelée constructor
, qui
pointe vers la fonction Lapin
l’ayant créée. lapinNoir
a également cette
propriété, mais elle pointe vers la fonction Object
.
show(lapinTueur.constructor); show(lapinNoir.constructor);
D’où vient la propriété constructor
? Elle fait partie du prototype d’un
lapin. Les prototypes sont une partie importante du fonctionnement des objets
en JavaScript. Chaque objet est basé sur un prototype, qui lui confère un
ensemble de propriétés. Les objets simples que nous avons utilisés jusque-là
sont tous basés sur le plus élémentaires des prototypes, celui associé au
constructeur Object
. En fait, taper {}
est équivalent à taper new
Object()
.
var objetSimple = {}; show(objetSimple.constructor); show(objetSimple.toString);
toString
est une méthode qui fait partie du prototype Object
. Ça signifie
que tous les objets de base ont une méthode toString
, qui les convertit en
chaîne de caractères. Nos objets lapin sont basés sur le prototype associé au
constructeur Lapin
. Il est possible d’utiliser la propriété prototype
d’un
constructeur pour accéder à… leur prototype :
show(Lapin.prototype); show(Lapin.prototype.constructor);
Chaque fonction est automatiquement munie d’une propriété prototype
, dont la
propriété constructor
renvoie à la fonction. Puisque que le prototype lapin
est lui-même un objet, il est basé sur le prototype Object
, et partage sa
méthode toString
.
show(lapinTueur.toString == objetSimple.toString);
Même si les objets semblent partager des propriétés avec leur prototype, ce partage n’est qu’à sens unique. Les propriétés des prototypes influencent les objets basés dessus, mais les propriétés de cet objet ne changent jamais le prototype.
Les règles sont précisément les suivantes : pour trouver la valeur d’une
propriété, JavaScript cherche d’abord parmi les propriétés de l’objet
lui-même. Si une propriété porte le nom que l’on recherche, c’est sa valeur
que l’on obtient. Si le nom n’existe pas, la recherche se poursuit à travers le
prototype de l’objet, et ensuite à travers le prototype du prototype, et ainsi
de suite. Si aucune propriété n’est trouvée, c’est la valeur undefined
qui
est renvoyée. À l’inverse, lorsqu’on définit la valeur d’une propriété,
JavaScript ne remonte jamais au prototype, il attribue directement la valeur à
une propriété de l’objet lui-même.
Lapin.prototype.dents = "petites"; show(lapinTueur.dents); lapinTueur.dents = "longues, pointues et sanglantes"; show(lapinTueur.dents); show(Lapin.prototype.dents);
Cela signifie que le prototype peut être utilisé pour ajouter des propriétés et des méthodes à tous les objets basés dessus. Par exemple, il se peut que nos lapins aient soudainement besoin de danser.
Lapin.prototype.danser = function() { print("Le lapin ", this.adjectif, " danse une jigue."); }; lapinTueur.danser();
Et, comme vous vous en doutez, le prototype de lapin est le meilleur endroit où
ajouter des éléments communs à tous les lapins, comme la méthode parler
.
Voici donc une nouvelle approche pour notre constructeur de Lapin
:
function Lapin(adjectif) { this.adjectif = adjectif; } Lapin.prototype.parler = function(tirade) { print("Le lapin ", this.adjectif, " dit '", tirade, "'"); }; var noisetteLeLapin = new Lapin("noisette"); noisetteLeLapin.parler("Good Frith!");
Le fait que tous les objets aient leur prototype et reçoivent des propriétés de
ce prototype peut apporter quelques complications. Ça signifie qu’utiliser un
objet pour stocker des trucs, comme les chats du chapitre 4, peut mal se passer.
Par exemple, si nous nous étions demandé s’il y a un chat nommé « constructor
», nous aurions implémenté le test suivant :
var pasUnSeulChat = {}; if ("constructor" in pasUnSeulChat) print("Oui, il y a sans aucun doute un chat appelé « constructor ».");
C’est problématique. Un autre problème tient au fait qu’il est souvent pratique
d’étendre les prototypes des constructeurs standards comme Object
ou Array
avec de nouvelles fonctions. Par exemple, nous pouvons donner à tous les objets
une méthode nommée properties
, qui retourne un tableau contenant le nom des
propriétés (non cachées) d’un objet.
Object.prototype.properties = function() { var resultat = []; for (var property in this) resultat.push(property); return resultat; }; var test = {x: 10, y: 3}; show(test.properties());
Et cela met tout de suite le problème en évidence. Maintenant que le prototype
Object
a une propriété appelée properties
, parcourir les propriétés de
n’importe quel objet, en utilisant for
et in
, renverra également cette
propriété partagée, ce qui n’est généralement pas ce que nous souhaitons. Nous
sommes seulement intéressés par les propriétés que l’objet a lui-même.
Heureusement, il y a un moyen de trouver si une propriété appartient à un objet
lui-même, ou à l’un de ses prototypes. Malheureusement, cela complique un peu
le parcours des propriétés d’un objet. Tout objet a une méthode appelée
hasOwnProperty
, qui nous indique si l’objet possède une propriété dont le
nom est passé en argument. En se basant sur ce mécanisme, nous pouvons réécrire
notre méthode properties
de la manière suivante :
Object.prototype.properties = function() { var resultat = []; for (var property in this) { if (this.hasOwnProperty(property)) resultat.push(property); } return resultat; }; var test = {"Gros Igor": true, "Boule de Feu": true}; show(test.properties());
Et bien sûr, nous pouvons abstraire cela dans une fonction de
haut niveau. Notez que la fonction action
est appelée avec à la fois le nom
de la propriété et la valeur qu’elle a dans l’objet.
function forEachIn(objet, action) { for (var property in objet) { if (objet.hasOwnProperty(property)) action(property, objet[property]); } } var chimere = {visage: "lion", corps: "chèvre", derrière: "serpent"}; forEachIn(chimere, function(nom, valeur) { print("Un ", nom, " de ", valeur, "."); });
Mais, que se passe-t-il si on rencontre un chat nommé hasOwnProperty
? (on ne
sait jamais.) Il sera stocké dans l’objet, et la tentative suivante de
parcourir la collection de chats, utilisant objet.hasOwnProperty
, sera un
échec, car cette propriété ne pointera plus vers la fonction. Une façon
d’éviter ce problème est d’agir encore plus salement :
function forEachIn(objet, action) { for (var property in objet) { if (Object.prototype.hasOwnProperty.call(objet, property)) action(property, objet[property]); } } var test = {name: "Mardochée", hasOwnProperty: "Oh-oh"}; forEachIn(test, function (nom, valeur) { print ("Property ", nom, " = ", valeur); });
(Note : Cet exemple ne fonctionne pas pour l’instant correctement dans Internet Explorer 8, qui a semble-t-il des problèmes avec la redéfinition des propriétés intégrées.)
Ici, au lieu d’utiliser la méthode trouvée dans l’objet lui-même, nous prenons
la méthode fournie par le prototype Object
, et l’appliquons en utilisant
call
sur le bon objet. À moins que quelqu’un n’ait joué avec la méthode de
Object.prototype
(et ne faites pas ça), le programme devrait fonctionner
correctement.
hasOwnProperty
peut également être utilisée dans les situations où l’on
utilise l’opérateur in
pour savoir si un objet contient une propriété
particulière. Mais il y a encore une subtilité. Nous avons vu dans le chapitre 4
que certaines propriétés, comme toString
, sont « cachées », et ne sont donc
pas considérées lors du parcours des éléments d’un objet via une instruction
for
/in
. Il s’avère que les navigateurs de la famille Gecko (Firefox
principalement) donnent à chaque objet une propriété cachée nommée __proto__
,
qui pointe vers le prototype de cet objet. hasOwnProperty
retourne true
,
pour cette propriété, même si le programme ne l’a pas explicitement ajoutée.
Avoir accès au prototype d’un objet peut être très pratique, mais en faire une
propriété comme ça n’était pas une très bonne idée. Toutefois, Firefox est un
navigateur web très utilisé, et il convient de faire attention à cela quand
vous écrivez des programmes pour le web. Il y a une méthode
propertyIsEnumerable
, qui retourne false
, pour les propriétés cachées, et
peut donc être utilisé pour filtrer les étrangetés comme __proto__
. Pour
contourner ce problème de manière fiable, on peut utiliser une expression
comme :
var objet = {foo: "bar"}; show (Object.prototype.hasOwnProperty.call(objet, "foo") && Object.prototype.propertyIsEnumerable.call(objet, "foo"));
Simple et agréable n’est-ce pas ? C’est l’un des aspects de JavaScript qui ne sont pas si bien conçus que ça. Les objets jouent à la fois le rôle de « valeurs avec méthodes », qui fonctionnent très bien avec les prototypes, et « d’ensemble de propriétés », pour lesquels les prototypes ne font que déranger.
Écrire l’expression ci-dessus à chaque fois qu’on a besoin de vérifier la
présence d’une propriété dans un objet n’est pas viable. Nous pourrions le
mettre dans une fonction, mais une meilleure approche est encore d’écrire un
constructeur et un prototype dédié aux situations où nous voulons utiliser un
objet simplement comme un ensemble propriétés. Puisqu’il est prévu pour pouvoir
y chercher des choses par leurs noms, nous l’appellerons Dictionary
(dictionnaire).
function Dictionary(valeursInitiales) { this.valeurs = valeursInitiales || {}; } Dictionary.prototype.store = function(nom, valeur) { this.valeurs[nom] = valeur; }; Dictionary.prototype.lookup = function(nom) { return this.valeurs[nom]; }; Dictionary.prototype.contains = function(nom) { return Object.prototype.hasOwnProperty.call(this.valeurs, nom) && Object.prototype.propertyIsEnumerable.call(this.valeurs, nom); }; Dictionary.prototype.each = function(action) { forEachIn(this.valeurs, action); }; var couleurs = new Dictionary({Grover: "bleu", Elmo: "orange", Bart: "jaune"}); show(couleurs.contains("Grover")); show(couleurs.contains("constructor")); couleurs.each(function(nom, couleur) { print(nom, " est ", couleur); });
Désormais, tous les inconvénients de l’utilisation des objets en tant
qu’ensemble de propriétés sont « cachés » derrière une interface pratique : un
constructeur et quatre méthodes. Notez que la propriété valeurs
d’un objet
Dictionary
ne fait pas partie de son interface, ce n’est qu’un détail
interne, et quand vous utilisez des objets Dictionary
, vous n’avez pas besoin
de l’utiliser directement.
Chaque fois que vous écrivez une interface, il est utile d’y ajouter un commentaire retraçant rapidement ce qu’elle fait et comment l’utiliser. De cette manière, quand quelqu’un (éventuellement vous dans trois mois) souhaite travailler avec cette interface, il peut se faire rapidement une idée de comment l’utiliser, et n’a alors pas besoin d’étudier tout le programme.
La plupart du temps, quand vous concevez une nouvelle interface, des problèmes et des limitations dans ce que vous aviez prévu viennent rapidement se confronter à vous. En conséquence, vous changez votre interface. Pour éviter de perdre du temps, il est conseillé de ne documenter vos interfaces qu’après les avoir expérimentées un certain temps sur des cas concrets, et qu’elles se soient révélées efficaces. ― Bien sûr, cela pourrait augmenter la tentation de ne pas documenter du tout. Mais personnellement, je considère la documentation comme une « touche finale » à ajouter au système. Quand ça donne l’impression d’être prêt, c’est qu’il est temps d’écrire un peu sur le sujet, et de voir si ça sonne aussi bien en français (ou n’importe quelle autre langue vivante), qu’en JavaScript (où n’importe quel autre langage de programmation).
La distinction entre l’interface d’un objet et ses détails de fonctionnement internes est importante pour deux raisons. D’abord, avoir une petite interface bien décrite rend un objet plus facile à utiliser. Il suffit de garder l’interface en tête, sans plus se préoccuper du reste, à moins d’avoir à changer l’objet lui-même.
Ensuite, il arrive régulièrement d’avoir à changer quelque chose dans le fonctionnement interne d’un type1 d’objet, pour le rendre plus efficace par exemple, ou pour corriger un problème. Si le code extérieur a accès à toutes les propriétés d’un objet, il est difficile de changer le moindre détail sans avoir à mettre à jour tout le reste du code. Si le code extérieur utilise une petite interface, vous pouvez changer le fonctionnement interne de l’objet comme bon vous semble, tant que l’interface ne change pas.
Certaines personnes vont assez loin avec ce concept. Ils n’incluent, par
exemple, aucune propriété dans l’interface d’un objet, et n’y autorisent que
des méthodes ― si leur type d’objet a une longueur, elle sera accessible via
une méthode getLength
, et pas directement comme une propriété length
. De
cette manière, si jamais ils décident de modifier leur objet de telle manière
qu’il n’a plus de propriété length
, par exemple parce que la longueur à
retourner devient celle d’un tableau, ils peuvent mettre la fonction à jour,
sans changer l’interface.
D’après mon point de vue personnel, dans la plupart des cas cela n’en vaut pas
la peine. Ajouter une méthode getLength
ne contenant que return
this.length;
est essentiellement un ajout de code inutile. En règle générale,
je considère le code inutile plus problématique que la nécessité de modifier de
temps à autre l’interface de mes objets.
Ajouter de nouvelles méthodes à des prototypes existants peut être très utile.
En particulier les prototypes de Array
et String
en JavaScript peuvent
recevoir quelques méthodes basiques supplémentaires. Nous pouvons, par exemple,
remplacer forEach
et map
par des méthodes sur les tableaux, et transformer
la fonction chaineCommencePar
que nous avons écrite au chapitre 4 en méthode sur
les chaînes de caractère.
De plus, si votre programme doit fonctionner sur la même page web qu’un autre
programme (qu’il soit de vous ou non) qui utilise naïvement for
/in
― comme
nous l’avons vu jusque-là ― alors ajouter des choses aux prototypes, et
précisément à ceux d’Object
et Array
aura toutes les chances de casser
quelque chose, vu que ces boucles vont d’un coup se mettre à inclure les
nouvelles propriétés. Du coup, certaines personnes préfèrent ne pas toucher du
tout à ces prototypes. Mais bien sûr, si vous êtes prudent, et qu’il n’y a
aucune raison que votre code cohabite avec un code mal écrit, ajouter des
méthodes aux prototypes standards est une très bonne technique.
Dans ce chapitre, nous allons fabriquer un terrarium virtuel, une boîte en verre avec des insectes vivants dedans. Ça impliquera de jouer avec des objets (ce qui tombe assez bien vu le nom du chapitre). Nous allons adopter une approche assez simple, en modélisant le terrarium par une grille à deux dimensions, comme la deuxième carte du chapitre 7. Sur cette grille, il y a un certain nombre de bestioles. Quand le terrarium est actif, toutes les bébêtes ont une opportunité d’agir (comme d’effectuer un déplacement) toutes les demi-secondes.
Du coup, on découpe l’espace et le temps en unités de taille fixe ― des cases pour l’espace et des demi-secondes pour le temps. Ça rend généralement les choses plus simple à modéliser dans un programme, mais ça a bien sûr l’inconvénient d’être largement imprécis. Heureusement, ce simulateur de terrarium n’a pas besoin d’être précis et nous pouvons donc faire avec.
Un terrarium peut être représenté comme un « plan », défini comme étant un tableau de chaînes de caractères. Nous aurions pu n’utiliser qu’une seule chaîne de caractères, mais comme les chaînes de caractères JavaScript ne doivent comporter qu’une seule ligne, ça aurait été beaucoup plus compliqué à taper.
var lePlan = ["############################", "# # # o ##", "# #", "# ##### #", "## # # ## #", "### ## # #", "# ### # #", "# #### #", "# ## o #", "# o # o ### #", "# # #", "############################"];
Les caractères "#"
sont utilisés pour représenter les murs du terrarium (et
les éléments de décors, comme les rochers au sol), les "o"
représentent les
bêtes et les espaces, comme vous vous en êtes sûrement doutés, représentent les
espaces vides.
Un plan-tableau de ce type est approprié pour représenter un objet terrarium.
Cet objet garde trace de la forme et du contenu du terrarium, et permet aux
insectes à l’intérieur de bouger. Il a quatre méthodes : tout d’abord
toString
, qui convertit le terrarium en une chaîne de caractères affichable,
permettant d’avoir un aperçu de ce qui se passe dedans. Ensuite, il y a step
,
qui permet à toutes les bêtes du terrarium de se déplacer d’une case si elles
le veulent. Et enfin il y a start
et stop
, qui contrôlent l’activité du
terrarium. Lorsqu’il fonctionne, step
est appelé automatiquement toutes les
demi-secondes, et donc les insectes se déplacent.
Les points sur la grille représenteront également des objets. Dans le
chapitre 7 nous avons utilisé trois fonctions : point
, ajouterPoints
et
pointsIdentiques
pour travailler avec les points. Cette fois, nous
utiliserons un constructeur et deux méthodes. Écrire le constructeur Point
,
qui prend deux arguments, les coordonnées x et y du point, et produit un objet
avec des propriétés x
et y
. Ajoutez au prototype de ce constructeur une
méthode add
, qui prend un autre point en argument et retourne un nouveau
point dont les x
et y
sont la somme des x
et y
des deux points donnés.
Ajoutez également une méthode isEqualTo
, qui prend un point et renvoie un
booléen, indiquant si le point local (this
) a les mêmes coordonnées que le
point donné.
En dehors des deux méthodes, les propriétés x
et y
font également partie de
l’interface de ce type d’objets : le code utilisant des objets de type point
pourra lire et modifier librement les x
et y
.
function Point(x, y) { this.x = x; this.y = y; } Point.prototype.add = function(autre) { return new Point(this.x + autre.x, this.y + autre.y); }; Point.prototype.isEqualTo = function(autre) { return this.x == autre.x && this.y == autre.y; }; show((new Point(3, 1)).add(new Point(2, 4)));
Assurez-vous que votre version de add
laisse le point local (this
) intact
et produise bien un nouvel objet Point. Une méthode qui change le point courant
serait similaire à l’opérateur +=
, alors qu’on la veut équivalente à
l’opérateur +
.
Quand on écrit des objets pour développer un programme, on ne sait pas toujours quelle fonctionnalité va où. Certaines choses sont mieux implémentées sous forme de méthodes de l’objet, d’autres mieux rangées dans des fonctions et d’autres encore mieux modélisées par de nouveaux types d’objets. Pour garder l’organisation limpide, il est important de garder le nombre de méthodes et de responsabilités des objets aussi petit que possible. Quand un objet en fait trop, il devient un gros bazar de fonctionnalités et une formidable source de confusions.
J’ai dit plus haut que l’objet terrarium serait responsable du stockage de son contenu et de permettre aux insectes de bouger. Tout d’abord, précisons qu’il ne fait que permettre aux insectes de bouger. Les bébêtes seront elles-mêmes des objets, et ces objets seront responsables de leur propres décisions. Le terrarium ne fournit en gros que l’infrastructure qui leur demande quoi faire chaque demi-seconde. Et s’ils décident de bouger, il s’assure que ça se fasse.
Stocker la grille sur laquelle le contenu du terrarium prend place peut vite se
compliquer. Il faut définir une représentation, des moyens d’accéder à cette
représentation, d’initialiser la grille depuis le « plan » (fourni sous forme
de tableau) et de restituer le contenu de la grille sous la forme d’une chaîne
de caractères pour la méthode toString
, sans oublier le mouvement des
insectes sur la grille.
Lorsque vous vous retrouvez à mélanger représentations de données et code
spécifique à un problème donné dans un seul objet, c’est une bonne idée
d’essayer de mettre la représentation des données dans un type d’objet séparé.
Dans ce cas, nous avons besoin de représenter une grille de valeurs, j’ai donc
écrit un type Grille
, qui supporte les opérations dont ce terrarium aura
besoin.
Pour stocker les valeurs de la grille, il y a deux options. L’une peut utiliser un tableau de tableaux :
var grille = [["0,0", "1,0", "2,0"], ["0,1", "1,1", "2,1"]]; show(grille[1][2]);
Ou alors les valeurs peuvent toutes être mises dans un seul tableau. Dans ce
cas, on retrouve l’élément x
/y
en cherchant dans le tableau l’élément en
position x + y * largeur
, où largeur
est la largeur de la grille.
var grille = ["0,0", "1,0", "2,0", "0,1", "1,1", "2,1"]; show(grille[2 + 1 * 3]);
J’ai choisi la seconde représentation, car elle simplifie
l’initialisation du tableau. new Array(x)
produit un nouveau tableau de
longueur x
, rempli de valeurs undefined
(indéfinies).
function Grille(largeur, hauteur) { this.largeur = largeur; this.hauteur = hauteur; this.cellules = new Array(largeur * hauteur); } Grille.prototype.valeurEn = function(point) { return this.cellules[point.y * this.largeur + point.x]; }; Grille.prototype.ecritValeurEn = function(point, valeur) { this.cellules[point.y * this.largeur + point.x] = valeur; }; Grille.prototype.estDedans = function(point) { return point.x >= 0 && point.y >= 0 && point.x < this.largeur && point.y < this.hauteur; }; Grille.prototype.deplaceElement = function(depuis, vers) { this.ecritValeurEn(vers, this.valeurEn(depuis)); this.ecritValeurEn(depuis, undefined); };
Nous allons également avoir besoin de parcourir tous les éléments de la grille,
pour trouver les insectes qui ont besoin de bouger, ou pour convertir
l’ensemble en une chaîne de caractères. Pour simplifier la chose, nous pouvons
utiliser une fonction de haut niveau qui prend une action en argument. Ajouter
une méthode each
au prototype de Grille
, qui prend en argument une fonction
à deux arguments. Elle appelle cette fonction pour chaque point de la grille,
lui donnant l’objet point comment premier argument, et la valeur du point sur
la grille comme deuxième argument.
Parcourir les points depuis 0
, 0
, une ligne à la fois, de manière à ce que
le point 1
, 0
soit parcouru avant 0
, 1
. Cela simplifiera l’écriture de
la fonction toString
du terrarium après. (Indice : mettre une boucle for
pour la coordonnée x
à l’intérieur de la boucle for de la coordonnée y
.)
Il est conseillé de ne pas mettre son nez directement dans la propriété
cellules
de la grille, mais d’utiliser valeurEn
, pour récupérer ces
valeurs. De cette manière, si nous décidons (pour une raison ou pour une autre)
d’utiliser une méthode différente pour stocker les valeurs, nous n’aurons qu’à
réécrire la fonction valeurEn
et ecritValeurEn
, et les autres méthodes
resterons intactes.
Grille.prototype.each = function(action) { for (var y = 0; y < this.hauteur; y++) { for (var x = 0; x < this.largeur; x++) { var point = new Point(x, y); action(point, this.valeurEn(point)); } } };
Enfin, pour tester l’objet grille :
var testGrille = new Grille(3, 2); testGrille.ecritValeurEn(new Point(1, 0), "#"); testGrille.ecritValeurEn(new Point(1, 1), "o"); testGrille.each(function(point, valeur) { print(point.x, ",", point.y, ": ", valeur); });
Avant d’écrire un nouveau constructeur Terrarium
, nous devons être plus
précis à propos de ces « objets insectes » qui évolueront à l’intérieur.
Précédemment, j’ai dit que le terrarium demandera aux insectes quelle action
ils veulent effectuer. Cela fonctionnera de la fonction suivante : chaque
insecte aura une méthode agit
qui, appelée, renverra une « action ». Une
action est un objet doté d’une propriété type
, nommant le type d’action que
l’insecte souhaitera effectuer. Par exemple "déplacement"
. La plupart des
actions porteront d’autres informations, par exemple la direction souhaitée par
l’insecte qui voudra se déplacer.
Les insectes sont terriblement myopes, ils ne peuvent voir que les cases à côté
d’eux sur la grille. Mais ils peuvent s’en servir pour déterminer leurs
actions. Quand la méthode agit
est appelée, il lui est fourni un objet avec
des informations sur l’environnement de l’insecte en question. Il porte une
propriété pour chacune des huit directions autour de l’insecte. La propriété
indiquant ce qu’il y a au-dessus de l’insecte est appelé "n"
, pour Nord, pour
ce qu’il y a au-dessus à droite "ne"
, pour Nord-Est, et ainsi de suite. Pour
savoir quelle direction explorer selon le nom de la direction, l’objet
dictionnaire suivant sera utile :
var directions = new Dictionary( {"n": new Point( 0, -1), "ne": new Point( 1, -1), "e": new Point( 1, 0), "se": new Point( 1, 1), "s": new Point( 0, 1), "so": new Point(-1, 1), "o": new Point(-1, 0), "no": new Point(-1, -1)}); show(new Point(4, 4).add(directions.lookup("se")));
Quand un insecte décide de se déplacer, il indique dans quelle direction il
veut aller en renvoyant un objet action dont la propriété direction
nomme
laquelle de ces directions. Nous pouvons programmer un insecte primitif et
idiot qui va toujours vers le sud, « vers la lumière », comme ceci :
function InsecteStupide() {}; InsecteStupide.prototype.agit = function(alentours) { return {type: "déplacement", direction: "s"}; };
Nous pouvons maintenir construire le type d’objet Terrarium
lui-même.
Commençons par son constructeur, qui reçoit un plan (un tableau de chaîne)
comme argument, et initialise son objet grille.
var mur = {}; function Terrarium(plan) { var grille = new Grille(plan[0].length, plan.length); for (var y = 0; y < plan.length; y++) { var ligne = plan[y]; for (var x = 0; x < ligne.length; x++) { grille.ecritValeurEn(new Point(x, y), elementdApresCaractere(ligne.charAt(x))); } } this.grille = grille; } function elementdApresCaractere(caractere) { if (caractere == " ") return undefined; else if (caractere == "#") return mur; else if (caractere == "o") return new InsecteStupide(); }
mur
est un objet utilisé pour repérer la position des murs sur la grille.
Comme un vrai mur, il ne fait pas grand-chose, juste être quelque part et
occuper une partie de l’espace.
La méthode la plus évidente de l’objet terrarium est toString
, qui transforme
un terrarium en chaîne de caractères. Pour faciliter cette tâche, nous donnons
à mur
et au prototype de InsecteStupide
une propriété caractere
,
contenant la représentation sous forme de caractère de ceux-ci.
mur.caractere = "#"; InsecteStupide.prototype.caractere = "o"; function caracteredApresElement(element) { if (element == undefined) return " "; else return element.caractere; } show(caracteredApresElement(mur));
Maintenant, nous pouvons utiliser la méthode each
de l’objet Grille
pour
construire une chaîne de caractères. Pour que le résultat soit lisible, il est
préférable d’avoir un retour chariot à chaque ligne. La coordonnée x
de
chaque case de la grille sera utilisée pour déterminer si la fin d’une ligne
est atteinte. Ajouter une méthode toString
au prototype de Terrarium
.
Cette méthode ne prend pas d’argument et renvoie une chaîne de caractères
destinée à être passée à print
, affichant ainsi une belle vue
bidimensionnelle du terrarium.
Terrarium.prototype.toString = function() { var caracteres = []; var finDeLigne = this.grille.largeur - 1; this.grille.each(function(point, valeur) { caracteres.push(caracteredApresElement(valeur)); if (point.x == finDeLigne) caracteres.push("\n"); }); return caracteres.join(""); };
Et pour l’essayer …
var terrarium = new Terrarium(lePlan); print(terrarium.toString());
Il est possible qu’en essayant de résoudre l’exercice précédent, vous ayez
voulu accéder à this.grille
dans le corps de la fonction passé en argument de
la méthode each
de l’objet grille. Cela ne peut pas fonctionner, car l’appel
à une fonction a pour conséquence qu’à l’intérieur de cette fonction, this
prend une nouvelle valeur, même si elle n’est pas utilisée en tant que méthode.
Ainsi, aucune variable this
à l’extérieur d’une fonction ne peut être
visible.
Parfois, il est nécessaire de contourner ceci en stockant les informations dont
on a besoin dans une variable, par exemple finDeLigne
, qui elle est visible
dans la fonction imbriquée. Si vous avez besoin d’accéder à la variable this
d’un objet, vous pouvez la stocker dans une autre variable. Le nom self
(ou
that
) est souvent utilisée pour une telle
variable.
Mais l’utilisation de ces variables en plus peut être source de confusion. Une
autre bonne solution est d’utiliser une fonction proche de partial
décrite
dans le chapitre 6. Au lieu d’ajouter un argument à la fonction, celle-ci passe
l’objet this
, par l’intermédiaire du premier argument de la méthode apply
dont disposent toutes les fonctions :
function bind(func, objet) { return function(){ return func.apply(objet, arguments); }; } var tableauTest = []; var ajouterDansTest = bind(tableauTest.push, tableauTest); ajouterDansTest("A"); ajouterDansTest("B"); show(tableauTest);
De cette façon, vous pouvez lier la variable this
d’une fonction imbriquée à
la variable this
de la fonction appelante, les deux this
seront identiques.
Dans l’expression bind(tableauTest.push, tableauTest)
le nom tableauTest
est encore utilisé deux fois. Pouvez-vous concevoir une fonction method
,
qui permet de lier un objet à une de ses méthodes sans nommer deux fois
l’objet ?
Il est possible de passer à un objet une chaîne de caractères contenant le nom
d’une de ses méthodes. De cette façon, la fonction method
peut connaître le
nom de la fonction à appliquer à l’objet.
function method(objet, nom) { return function() { objet[nom].apply(objet, arguments); }; } var ajouterDansTest = method(tableauTest, "push");
Nous aurons besoin de bind
(ou method
) quand nous écrirons la méthode
step
de l’objet terrarium. Cette méthode devra parcourir tous les insectes de
la grille, en leur demandant quelle action ils veulent effectuer, et en
effectuant pour eux cette action. Vous pourriez être tenté d’utiliser each
sur l’objet grille, et traiter les insectes un par un au fur et à mesure que
vous les rencontriez. Mais ce faisant, si un insecte se déplaçait vers le sud
ou l’est, vous le rencontriez à nouveau dans le même tour, et il serait à
nouveau déplacé.
À la place, nous allons extraire tous les insectes vers un tableau, et partant
de là, les traiter un par un. La méthode ci-dessous extrait les insectes, et
même tout objet qui possède une méthode agit
, et enregistre ces objets, et
leurs positions respectives avant déplacement, dans un tableau d’objets.
Terrarium.prototype.listeCreaturesEnAction = function() { var trouves = []; this.grille.each(function(point, valeur) { if (valeur != undefined && valeur.agit) trouves.push({object: valeur, point: point}); }); return trouves; };
Lorsque l’on demande à un insecte quel déplacement il souhaite réaliser, il
faut lui passer un objet lui décrivant les cases alentours. Cet objet utilisera
les noms de direction que nous avons vu précédemment ("n"
, "ne"
, etc.)
comme noms de propriétés. Chaque propriété contiendra une chaîne d’un caractère
tel que renvoyé par caracteredApresElement
, indiquant ce que peut voir
l’insecte dans cette direction.
Ajouter une méthode listeAlentours
au prototype de Terrarium
. Elle prend un
argument, le point où l’insecte se trouve, et renvoie un objet décrivant
l’entourage de ce point. Quand un point se trouve à une bordure de la grille,
utiliser "#"
pour les directions qui débordent de la grille, ainsi l’insecte
ne pourra s’y rendre.
Conseil : ne pas décrire chacune des directions, mais utiliser la méthode
each
sur le dictionnaire directions
.
Terrarium.prototype.listeAlentours = function(centre) { var resultat = {}; var grille = this.grille; directions.each(function(nom, direction) { var place = centre.add(direction); if (grille.estDedans(place)) resultat[nom] = caracteredApresElement(grille.valeurEn(place)); else resultat[nom] = "#"; }); return resultat; };
Remarquez l’utilisation de la variable grille
pour passer outre les
difficultés liées à l’usage de this
.
Les deux méthodes ci-dessus ne font pas partie de l’interface externe de
l’objet Terrarium
, mais sont des détails internes à l’objet. Certains
langages de programmation permettent de déclarer explicitement certaines
méthodes et propriétés comme "privées", et provoquent une erreur si on accède à
celles-ci en dehors de l’objet. Ce n’est pas le cas de JavaScript, c’est
pourquoi vous pourriez utiliser des commentaires pour décrire l’interface d’un
objet. Parfois il est utile d’utiliser des conventions de nommage pour
distinguer les propriétés externes et internes, par exemple en préfixant les
propriétés internes avec un caractère souligné ("_"). Cela permet de repérer
plus facilement les utilisations accidentelles des propriétés qui ne font pas
partie de l’interface des objets.
Voici encore une méthode interne, celle qui va demander à un insecte ce qu’il
veut faire, et l’effectuer. Elle prend en argument un objet avec les propriétés
object
et point
, comme le renvoie listeCreaturesEnAction
. Pour le
moment, elle ne reconnaît que l’action "déplacement"
:
Terrarium.prototype.actionnerUneCreature = function(creature) { var alentours = this.listeAlentours(creature.point); var action = creature.object.agit(alentours); if (action.type == "déplacement" && directions.contains(action.direction)) { var to = creature.point.add(directions.lookup(action.direction)); if (this.grille.estDedans(to) && this.grille.valeurEn(to) == undefined) this.grille.deplaceElement(creature.point, to); } else { throw new Error("Action invalide : " + action.type); } };
Remarquez que la méthode vérifie si la direction choisie amène bien à une case
vide. Dans le cas contraire, la méthode ignore le déplacement. De cette façon,
les insectes peuvent bien demander tout ce qu’ils veulent ― l’action ne sera
effectuée que si elle est possible. Ce mécanisme agit comme une couche
d’isolation entre les insectes et le terrarium, et nous autorise quelques
approximations dans l’écriture des méthodes agit
des insectes ― par exemple
InsecteStupide
ne se déplace que vers le sud, sans se demander si un mur se
trouve sur son chemin.
Ces trois méthodes internes vont nous permettre enfin d’écrire la méthode
step
, qui permettra aux insectes de faire quelque chose (et même tout élément
doté d’une méthode agit
― nous pourrions tout aussi bien donner une telle
méthode à l’objet mur
et les murs se déplaceraient).
Terrarium.prototype.step = function() { forEach(this.listeCreaturesEnAction(), bind(this.actionnerUneCreature, this)); };
Maintenant, construisons un terrarium et voyons les insectes se déplacer.
var terrarium = new Terrarium(lePlan); print(terrarium); terrarium.step(); print(terrarium);
Examinons un instant l’instruction ci-dessus print(terrarium)
, comment
fait-elle pour renvoyer le contenu de notre méthode toString
? print
transforme les arguments qui lui sont passés en chaîne de caractères, en
utilisant la fonction String
. Les objets sont transformés en chaîne de
caractères par l’appel de leur méthode toString
, aussi, écrire une méthode
toString
dans nos propres objets est un bon moyen de les rendre lisibles lors
de l’appel de print
.
Point.prototype.toString = function() { return "(" + this.x + "," + this.y + ")"; }; print(new Point(5, 5));
Comme prévu, l’objet Terrarium
sera doté de méthode start
et stop
pour
démarrer et arrêter la simulation. Pour cela, nous utiliserons deux fonctions
fournies par le navigateur web, appelées setInterval
et clearInterval
.
La première est utilisée dans le but que son premier argument (une fonction ou
une chaîne de caractères contenant du code JavaScript) soit exécuté
périodiquement. Son deuxième argument est la durée en millisecondes (1/1000 de
seconde) entre les exécutions. La fonction renvoie une valeur qui pourra
servir d’argument à clearInterval
pour arrêter les exécutions périodiques.
var pénible = setInterval(function() {print("Quoi?");}, 400);
Et…
clearInterval(pénible);
Il existe des fonctions proches pour exécuter une action une seule fois après
un laps de temps. setTimeout
exécute une fonction ou une chaîne de
caractères après un délai exprimé en millisecondes, et clearTimeout
permet
d’annuler une telle action.
Terrarium.prototype.start = function() { if (!this.running) this.running = setInterval(bind(this.step, this), 500); }; Terrarium.prototype.stop = function() { if (this.running) { clearInterval(this.running); this.running = null; } };
À ce stade, nous avons un terrarium avec des insectes très simplistes, que nous
pouvons faire fonctionner. Mais pour voir ce qu’il s’y passe, il nous faut
constamment exécuter print(terrarium)
. Ce n’est pas très pratique. Ce serait
agréable que le contenu s’affiche automatiquement. Ce serait encore mieux si,
au lieu d’afficher par milliers les images successives des terraria, nous
n’ayons qu’une seule image que nous mettrions à jour. Pour ce dernier
problème, cette page offre une fonction nommée inPlacePrinter
. Elle renvoie
une fonction comme print
qui, au lieu d’effectuer un nouvel affichage,
remplace l’affichage précédent.
var printHere = inPlacePrinter(); printHere("Actuellement vous voyez ceci."); setTimeout(partial(printHere, "Plus maintenant."), 1000);
Pour que le terrarium s’affiche à chaque changement, nous modifions la méthode
step
comme suit:
Terrarium.prototype.step = function() { forEach(this.listeCreaturesEnAction(), bind(this.actionnerUneCreature, this)); if (this.onStep) this.onStep(); };
En faisant cela, si une propriété onStep
est présente dans l’objet terrarium,
elle est appelée à chaque étape.
var terrarium = new Terrarium(lePlan); terrarium.onStep = partial(inPlacePrinter(), terrarium); terrarium.start();
Remarquez l’utilisation de partial
― cette méthode partial
renvoie une
fonction d’affichage appliquée à l’objet terrarium. La fonction d’affichage ne
demandant qu’un seul argument, après application partielle, nous obtenons une
fonction sans argument. C’est exactement ce dont nous avons besoin pour la
propriété onStep
.
N’oubliez pas d’arrêter la simulation du terrarium, quand il perd de son intérêt (ce qui ne devrait pas tarder), pour éviter de consommer les ressources de votre ordinateur inutilement :
terrarium.stop();
Qui voudrait d’une simulation de terrarium avec une seule sorte d’insecte,
stupide qui plus est ? Pas moi. Ce serait judicieux si nous pouvions ajouter
différentes sortes d’insectes. Heureusement, il nous suffit pour cela de rendre
la fonction elementdApresCaractere
plus générale. Pour le moment, elle décrit
trois possibilités « codés en dur », c’est-à-dire de façon linéaire et sans
flexibilité :
function elementdApresCaractere(caractere) { if (caractere == " ") return undefined; else if (caractere == "#") return mur; else if (caractere == "o") return new InsecteStupide(); }
Les deux premiers cas restent tels quels, le dernier étant trop spécifique. Une meilleure approche serait de stocker les constructeurs des objets insectes et les caractères qui leur correspondent dans un dictionnaire, et de rechercher dans ce dictionnaire ces caractères :
var typesDeCreature = new Dictionary(); typesDeCreature.enregistre = function(constructeurDeInsecte) { this.store(constructeurDeInsecte.prototype.caractere, constructeurDeInsecte); }; function elementdApresCaractere(caractere) { if (caractere == " ") return undefined; else if (caractere == "#") return mur; else if (typesDeCreature.contains(caractere)) return new (typesDeCreature.lookup(caractere))(); else throw new Error("Caractère inconnu: " + caractere); }
Remarquez qu’une méthode enregistre
est ajoutée à l’objet typesDeCreature
― celui-ci est de type dictionnaire, ce qui n’empêche en rien de lui ajouter
une méthode. Cette fonction extrait le caractère associé au constructeur de
l’insecte, et stocke ce caractère dans le dictionnaire. Cette méthode ne doit
être appelée que sur des objets dont le prototype possède une propriété
caractere
.
La fonction elementdApresCaractere
est modifiée pour rechercher le caractère
présent dans typesDeCreature
, et provoque une exception si elle tombe sur un
caractère inconnu.
Voici une nouvelle sorte d’insecte, ainsi que les instructions pour enregistrer
son caractère dans typesDeCreature
:
function InsecteaRebond() { this.direction = "ne"; } InsecteaRebond.prototype.agit = function(alentours) { if (alentours[this.direction] != " ") this.direction = (this.direction == "ne" ? "so" : "ne"); return {type: "déplacement", direction: this.direction}; }; InsecteaRebond.prototype.caractere = "%"; typesDeCreature.enregistre(InsecteaRebond);
Pouvez-vous comprendre ce qu’il fait ?
Créer un insecte nommé InsecteIvre
qui essaie de se déplacer dans une
direction quelconque à chaque tour, peu importe s’il y a un mur en face de
lui. Rappelez-vous le fonctionnement de Math.random
dans le chapitre 7.
Pour déterminer une direction de façon aléatoire, nous avons besoin d’un
tableau avec la liste des directions. Nous pourrions juste écrire un tableau de
cette façon : ["n", "ne", …]
, mais cela dupliquerait des informations, et les
duplications d’information me rendent nerveux. Nous pourrions également
utiliser la méthode each
sur le dictionnaire directions
pour construire un
nouveau tableau, ce serait déjà mieux.
Mais vous devez comprendre qu’il y a, ici, une façon bien plus générale de
procéder. Récupérer la liste des noms de propriété d’un dictionnaire est un
outil utile, aussi, ajoutons-le au prototype de l’objet Dictionary
.
Dictionary.prototype.names = function() { var noms = []; this.each(function(nom, valeur) {noms.push(nom);}); return noms; }; show(directions.names());
Un programmeur vraiment névrosé voudrait immédiatement rétablir la symétrie en
ajoutant une méthode values
qui retournerait la liste des valeurs d’un
dictionnaire. Mais je suppose que nous pouvons attendre d’en avoir vraiment
besoin.
Voici une façon de prendre un élément d’un tableau au hasard :
function elementAuHasard(tableau) { if (tableau.length == 0) throw new Error("Le tableau est vide."); return tableau[Math.floor(Math.random() * tableau.length)]; } show(elementAuHasard(["face", "pile"]));
Et l’insecte lui-même :
function InsecteIvre() {}; InsecteIvre.prototype.agit = function(alentours) { return {type: "déplacement", direction: elementAuHasard(directions.names())}; }; InsecteIvre.prototype.caractere = "~"; typesDeCreature.enregistre(InsecteIvre);
Essayons maintenant ces nouveaux insectes :
var nouveauPlan = ["############################", "# #####", "# ## ####", "# #### ~ ~ ##", "# ## ~ #", "# #", "# ### #", "# ##### #", "# ### #", "# % ### % #", "# ####### #", "############################"]; var terrarium = new Terrarium(nouveauPlan); terrarium.onStep = partial(inPlacePrinter(), terrarium); terrarium.start();
Vous voyez comment les insectes à rebond rebondissent sur les insectes en état d’ébriété ? Dramatique. De toute façon, quand vous en aurez assez de regarder ce spectacle fascinant, vous pourrez y mettre fin :
terrarium.stop();
Nous avons maintenant deux sortes d’objets possédant chacun une méthode agit
et une propriété caractere
. Comme ils partagent ces caractéristiques, le
terrarium peut dialoguer avec eux d’une façon commune. Ceci nous autorise à
avoir toutes sortes d’insectes, sans rien changer au code de l’objet terrarium.
Cette technique est appelée polymorphisme, et c’est sûrement l’un des aspects
les plus puissants de la programmation orientée objet.
L’idée de base du polymorphisme est que lorsqu’un morceau de programme est
écrit pour manipuler des objets ayant une certaine interface, n’importe quel
objet qui présente cette interface pourra être raccordé à ce morceau de
programme, et le tout fonctionne. Nous avons déjà vu un exemple de cela, à
savoir la méthode toString
de nombreux objets. Tous les objets ayant une
méthode toString
pertinente peuvent être passés à la fonction print
, ou
toute autre fonction qui aura besoin de convertir un objet en chaîne de
caractères, peu importe la façon dont cette dernière est produite.
De la même façon, forEach
travaille sur de véritables objets tableau ou sur
des objets similaires aux tableaux, forEach
recevant cet objet tableau dans
sa variable arguments
, car tout ce dont cette fonction a besoin, ce sont des
propriétés numérotées 0
, 1
, et ainsi de suite pour tous les éléments du
tableau.
Pour rendre la vie dans le terrarium plus réelle, nous allons y ajouter les
concepts de nourriture et de reproduction. Chaque créature vivante du terrarium
reçoit une nouvelle propriété, energie
, qui est diminuée lorsqu’elle effectue
une action, et augmentée lorsqu’elle mange quelque chose. Lorsqu’elle a
suffisamment d’énergie, une chose vivante peut se reproduire2, engendrant une
nouvelle créature du même type.
S’il n’y avait que des insectes, les dépenses d’énergie de leurs déplacements, et le fait qu’ils se mangeraient entre eux, feraient que notre terrarium succomberait rapidement sous l’effet de l’entropie, serait à court d’énergie, et deviendrait un lieu abandonné et sans vie. Pour empêcher que ceci se produise (au moins, que cela ne se produise pas trop vite), nous ajoutons du lichen au terrarium. Les lichens ne se déplacent pas, ils utilisent la photosynthèse pour produire de l’énergie et se reproduire.
Pour que cela fonctionne, nous aurons besoin d’un terrarium avec une méthode
actionnerUneCreature
différente. Nous pourrions simplement changer cette
méthode dans le prototype de Terrarium
, mais nous sommes très attachés à la
simulation des insectes sauteurs et des insectes en état d’ébriété, et ne
voulons pas casser ce premier terrarium.
Ce que nous pouvons faire est écrire un nouveau constructeur,
TerrariumPlusVivant
, dont le prototype est basé sur le prototype de
Terrarium
, mais qui possède une méthode actionnerUneCreature
différente.
Il existe plusieurs façon de faire cela. Nous pourrions énumérer les propriétés
de Terrarium.prototype
, et les ajouter une à une dans
TerrariumPlusVivant.prototype
. Ce serait simple à faire, et dans certains cas
la meilleure solution. Mais ici nous avons une façon plus propre de faire. Si
nous faisons du prototype du premier objet terrarium le prototype du nouveau
terrarium (prenez le temps de bien comprendre cette phrase), ce nouveau
Terrarium en aurait toutes les propriétés.
Malheureusement, JavaScript ne propose pas de moyen direct de créer un objet dont le prototype est celui d’un autre objet. Il est possible d’écrire une fonction qui fait cela, en utilisant l’astuce suivante :
function clone(objet) { function ConstructeurNouveauPourChaqueClone(){} ConstructeurNouveauPourChaqueClone.prototype = objet; return new ConstructeurNouveauPourChaqueClone(); }
Cette fonction clone déclare un constructeur nommé
ConstructeurNouveauPourChaqueClone qui est vide et unique, dont le prototype
est l’objet passé en argument. En appelant new
sur ce constructeur, un
nouvel objet est créé, basé sur l’objet passé en argument.
function TerrariumPlusVivant(plan) { Terrarium.call(this, plan); } TerrariumPlusVivant.prototype = clone(Terrarium.prototype); TerrariumPlusVivant.prototype.constructor = TerrariumPlusVivant;
Le nouveau constructeur n’a pas besoin de faire quoi que ce soit de plus que
l’ancien, donc il se contente d’appeler l’ancien sur l’objet this
. Il nous
faut également restaurer la propriété constructor
du nouveau prototype, sinon
il clamerait que son constructeur est Terrarium
(ce qui, bien sûr, n’est un
problème que si on se sert de cette propriété, ce qui n’est pas notre cas).
Il est maintenant possible de remplacer certaines méthodes de l’objet
TerrariumPlusVivant
, et d’en ajouter d’autres. Nous avons un type d’objet
basé sur un autre, ce qui nous épargne le travail de récrire toutes les
méthodes communes à Terrarium
et TerrariumPlusVivant
. Cette technique est
appelée « héritage ». Le nouveau type hérite des propriétés de l’ancien type.
Dans la plupart des cas, cela signifie que le nouveau type possèdera toujours
l’interface de l’ancien, bien qu’il puisse posséder des méthodes en plus, que
l’ancien n’a pas. De cette façon, les objets du nouveau type pourraient
prendre la place (selon le polymorphisme) des objets de l’ancien type.
Dans les langages de programmation avec un support explicite de l’orientation objet, l’héritage est une chose très simple à mettre en œuvre. JavaScript n’a pas de moyen simple de le faire. À cause de cela, les programmeurs en JavaScript ont inventé différentes approches pour le faire. Malheureusement, aucune d’entre elles n’est parfaite.
À la fin de ce chapitre, je vous montrerai d’autres façons de mettre en œuvre l’héritage, et leurs inconvénients.
Voici une nouvelle méthode actionnerUneCreature
. Elle est volumineuse :
TerrariumPlusVivant.prototype.actionnerUneCreature = function(creature) { var alentours = this.listeAlentours(creature.point); var action = creature.object.agit(alentours); var cible = undefined; var elementDansCible = undefined; if (action.direction && directions.contains(action.direction)) { var direction = directions.lookup(action.direction); var directionSouhaitee = creature.point.add(direction); if (this.grille.estDedans(directionSouhaitee )) { cible = directionSouhaitee; elementDansCible = this.grille.valeurEn(cible); } } if (action.type == "déplacement") { if (cible && !elementDansCible) { this.grille.deplaceElement(creature.point, cible); creature.point = cible; creature.object.energie -= 1; } } else if (action.type == "manger") { if (elementDansCible && elementDansCible.energie) { this.grille.ecritValeurEn(cible, undefined); creature.object.energie += elementDansCible.energie; } } else if (action.type == "photosynthese") { creature.object.energie += 1; } else if (action.type == "reproduction") { if (cible && !elementDansCible) { var espece = caracteredApresElement(creature.object); var nouvelleCreature = elementdApresCaractere(espece); //la créature parente perd 2 fois la quantité d’énergie de la créature naissante creature.object.energie -= nouvelleCreature.energie * 2; if (creature.object.energie > 0) this.grille.ecritValeurEn(cible, nouvelleCreature); } } else if (action.type == "attente") { creature.object.energie -= 0.2; } else { throw new Error("Action invalide : " + action.type); } if (creature.object.energie <= 0) this.grille.ecritValeurEn(creature.point, undefined); };
La fonction commence toujours par interroger les créatures pour une action.
Ensuite, si l’action possède une propriété direction
, la fonction détermine
immédiatement à quel endroit de la grille cette direction amène, et ce qu’il y
a à cet endroit. Trois des cinq actions implantées dans notre simulation ont
besoin de savoir cela, et le code serait encore plus difficile à comprendre si
ces calculs étaient faits à part. Si l’action n’a pas de propriété
direction
, ou si celle-ci est incorrecte, les variables cible
et
elementDansCible
restent à leur valeur undefined.
Après cela, toutes les actions sont passées en revue. Certaines actions
demandent des vérifications supplémentaires avant leur exécution, ce qui est
fait en utilisant un if
distinct pour que si une créature cherche, par
exemple, à passer à travers un mur, une exception "Action invalide"
ne soit
pas générée.
Remarquez que dans l’action "reproduction"
, la créature parente perd deux
fois la quantité d’énergie reçue par la nouvelle créature (la procréation n’est
pas une chose facile), et la nouvelle créature n’est placée sur la grille que
si son parent a suffisant d’énergie pour l’engendrer.
Après qu’une action a été effectuée, nous regardons si la créature a encore de l’énergie. Si elle n’en a plus, elle meurt, et nous la supprimons.
Le lichen n’est pas un organisme très complexe. Nous allons utiliser le
caractère "*"
pour le représenter. Vérifiez que vous avez bien défini la
fonction elementAuHasard
pour l’exercice 8.6, car elle sera utilisée de
nouveau ici.
function Lichen() { this.energie = 5; } Lichen.prototype.agit = function(alentours) { var espaceVide = trouverDirections(alentours, " "); if (this.energie >= 13 && espaceVide.length > 0) return {type: "reproduction", direction: elementAuHasard(espaceVide)}; else if (this.energie < 20) return {type: "photosynthese"}; else return {type: "attente"}; }; Lichen.prototype.caractere = "*"; typesDeCreature.enregistre(Lichen); function trouverDirections(alentours, directionSouhaite) { var trouve = []; directions.each(function(name) { if (alentours[name] == directionSouhaite) trouve.push(name); }); return trouve; }
Les lichens ne grandissent jamais au-delà de 20 unités d’énergie, sinon ils seraient trop imposants, quand, encerclés par d’autre lichens, ils n’ont plus de place pour se reproduire.
Créez une créature dévoreuse de lichens, MangeuseLichen
. Elle commence avec
10
unités d’énergie, et agit de la façon suivante :
- Quand elle a 30 ou plus d’énergie et une case vide près d’elle, elle se reproduit.
- Sinon, s’il y a du lichen près d’elle, elle en mange un, choisi aléatoirement.
- Sinon, s’il y a la place de se bouger, elle va vers une cases vide aléatoire.
- Sinon elle attend.
Utiliser les fonctions trouverDirections
et elementAuHasard
pour déterminer
le contenu de l’entourage de la créature, et faire des choix aléatoires. Donner
à cette créature le caractère "c"
(pour faire penser à pac-man).
function MangeuseLichen() { this.energie = 10; } MangeuseLichen.prototype.agit = function(alentours) { var espaceVide = trouverDirections(alentours, " "); var lichen = trouverDirections(alentours, "*"); if (this.energie >= 30 && espaceVide.length > 0) return {type: "reproduction", direction: elementAuHasard(espaceVide)}; else if (lichen.length > 0) return {type: "manger", direction: elementAuHasard(lichen)}; else if (espaceVide.length > 0) return {type: "déplacement", direction: elementAuHasard(espaceVide)}; else return {type: "attente"}; }; MangeuseLichen.prototype.caractere = "c"; typesDeCreature.enregistre(MangeuseLichen);
Et pour l’essayer.
var lichenPlan = ["############################", "# ######", "# *** **##", "# *##** ** c *##", "# *** c ##** *#", "# c ##*** *#", "# ##** *#", "# c #* *#", "#* #** c *#", "#*** ##** c **#", "#***** ###*** *###", "############################"]; var terrarium = new TerrariumPlusVivant(lichenPlan); terrarium.onStep = partial(inPlacePrinter(), terrarium); terrarium.start();
La plupart du temps, vous devriez voir le lichen envahir rapidement le terrarium ; cette abondance de nourriture provoquera une abondance de créatures voraces, si nombreuses qu’elles finiront par épuiser les ressources en lichen, et enfin s’épuiser elles-mêmes. La nature est si tragique.
terrarium.stop();
Constater que les occupants de votre terrarium disparaissent après quelques
minutes est un peu déprimant. Pour y faire face, nous allons éduquer nos
créatures dévoreuses de lichen au principe de l’agriculture raisonnée. En
faisant qu’elles ne mangent du lichen que si elles sont à proximité de deux
d’entre eux, quel que soit l’état de leur faim, elles ne pourront plus
exterminer le lichen. Cela demande de la discipline, mais le résultat est un
biotope qui ne s’autodétruit pas. Voici une nouvelle méthode agit
― le seul
changement est que l’action de manger ne se fait que si lichen.length
est au
moins égal à 2.
MangeuseLichen.prototype.agit = function(alentours) { var espaceVide = trouverDirections(alentours, " "); var lichen = trouverDirections(alentours, "*"); if (this.energie >= 30 && espaceVide.length > 0) return {type: "reproduction", direction: elementAuHasard(espaceVide)}; else if (lichen.length > 1) return {type: "manger", direction: elementAuHasard(lichen)}; else if (espaceVide.length > 0) return {type: "déplacement", direction: elementAuHasard(espaceVide)}; else return {type: "attente"}; };
Faites fonctionner la simulation du terrarium lichenPlan
à nouveau et
constatez son évolution. À moins d’être très chanceux, vous allez probablement
constater l’extinction des créatures dévoreuses au bout d’un certain temps,
parce que lorsque survient la famine, ces créatures se déplacent de façon
désordonnée, au lieu de rechercher le lichen qui n’est pas très loin d’elles.
Cherchez un moyen de rendre la créature MangeuseLichen
plus apte à la survie.
Ne trichez pas ― une instruction this.energie += 100
serait de la triche. Si
vous réécrivez le constructeur, n’oubliez pas de l’enregistrer à nouveau dans
le dictionnaire typesDeCreature
, sinon le terrarium continuerait d’utiliser
l’ancien constructeur.
Une approche serait de restreindre le caractère aléatoire des déplacements. En choisissant systématiquement une direction aléatoire, elles reviennent très souvent sur leurs pas, sans rien trouver à manger. En se rappelant la direction d’où elles viennent, et en privilégiant cette direction, elles dépenseraient moins de temps et trouveraient plus facilement de la nourriture.
function MangeuseLichenHabile() { this.energie = 10; this.direction = "ne"; } MangeuseLichenHabile.prototype.agit = function(alentours) { var espaceVide = trouverDirections(alentours, " "); var lichen = trouverDirections(alentours, "*"); if (this.energie >= 30 && espaceVide.length > 0) { return {type: "reproduction", direction: elementAuHasard(espaceVide)}; } else if (lichen.length > 1) { return {type: "manger", direction: elementAuHasard(lichen)}; } else if (espaceVide.length > 0) { if (alentours[this.direction] != " ") this.direction = elementAuHasard(espaceVide); return {type: "déplacement", direction: this.direction}; } else { return {type: "attente"}; } }; MangeuseLichenHabile.prototype.caractere = "c"; typesDeCreature.enregistre(MangeuseLichenHabile);
Essayez-la avec le plan de terrarium précédent.
Une chaîne alimentaire à un seul maillon est un peu rudimentaire. Pouvez-vous
écrire une nouvelle créature, nommée MangeuseMangeuseLichen
, (avec un
caractère "@"
), qui survit en mangeant des dévoreuses de lichens ? Trouver
également un moyen pour cette nouvelle créature de s’intégrer dans l’écosystème
sans qu’elles ne s’éteignent trop vite. Modifiez le tableau lichenPlan
pour
inclure quelques-unes d’entre elles, et essayez le tout.
C’est maintenant à vous de jouer, je n’ai pas trouvé de moyen véritablement efficace d’empêcher ces créatures de s’éteindre immédiatement ou d’engloutir toutes les dévoreuses de lichen, et de s’éteindre ensuite. L’astuce qui consiste à autoriser une créature à ne manger que lorsque deux unités de nourriture sont à proximité ne fonctionnent pas très bien pour elles, car, leur nourriture étant souvent en déplacement, il est rare d’en trouver deux à proximité l’une de l’autre. Rendre les dévoreuses de dévoreuses très grasses (avec beaucoup d’énergie) à quelque efficacité, car elles peuvent survivre lorsque les dévoreuses de lichen se font rare et se reproduisent doucement, ce qui empêche une raréfaction trop rapide de leur nourriture.
Les lichens et les créatures qui les mangent sont dans un mouvement périodique
― parfois les lichens sont abondants, ce qui provoque beaucoup de naissance de
mangeurs de lichen, ce qui provoque ensuite une rareté du lichen, puis la
rareté des mangeurs de lichen, enfin le lichen prospère à nouveau, et ainsi de
suite. Vous pouvez essayer de faire "hiberner" les mangeurs de mangeurs de
lichen (utiliser l’action "attente"
un certain temps), quand ils n’ont rien à
manger pour quelques tours. Une stratégie serait de trouver la bonne durée
d’hibernation, en nombre de tours, ou de leur donner un moyen de se réveiller
lorsqu’ils sentent beaucoup de nourriture.
Ceci termine notre discussion sur les terraria. Le reste de ce chapitre est dédié à une exploration en profondeur du concept d’héritage, et les problèmes liés à l’héritage en JavaScript.
Maintenant, un peu de théorie. Les étudiants qui abordent la programmation orientée objet sont souvent confrontés à des discussions longues et pleines de subtilité sur les façons correctes et incorrectes d’utiliser l’héritage. Il est important de garder à l’esprit qu’au bout du compte, l’héritage est un moyen pour des programmeurs paresseux3 d’écrire moins de code. Ainsi, la question de savoir si l’héritage est correctement utilisé se résume à la question de savoir si le code produit fonctionne correctement et n’a pas de répétition inutile. Pour autant, les principes discutés par ces étudiants sont aussi une bonne façon d’aborder l’héritage.
L’héritage est la création de nouveaux types d’objet, les « sous-types », basés sur des types existants, les « super-types ». Le sous-type commence avec la totalité des propriétés et des méthodes du super-type, il hérite de lui, ensuite, il en modifie quelques-uns, éventuellement en ajoute. L’héritage est mieux utilisé quand les objets décrits par le sous-type peuvent être considérés comme étant également des objets du super-type.
Ainsi, un type Piano
peut être un sous-type du type Instrument
, parce qu’un
piano est un instrument. Un piano comportant un tableau de touches, on peut
être tenté de faire de Piano
un sous-type de Array
, mais un piano n’est
pas un tableau, et l’implémenter de cette façon entraînerait de nombreux
comportements idiots. Par exemple, un piano a aussi des pédales. Pourquoi
piano[0]
me renverrait-il la première touche, et non la première pédale ? Il
se trouve que, évidemment, le piano possède des touches, il est donc
préférable de lui donner une propriété touches
, et éventuellement une autre
propriété pédales
, ces deux propriétés étant des tableaux.
Il est possible pour un sous-type d’être le super-type d’un autre sous-type. Certains problèmes sont mieux résolus en construisant un arbre complexe de types. Prenez garde à ne pas être trop enthousiaste avec l’héritage. Une utilisation abusive de l’héritage est un bon moyen de transformer un programme en un bazar monstrueux.
Le fonctionnement du mot-clé new
et la propriété prototype
d’un
constructeur suggèrent une certaine façon d’utiliser les objets. Pour des
objets simples, comme les créatures du terrarium, cette façon fonctionne bien.
Malheureusement, quand un programme utilise l’héritage de façon plus développé,
cette approche de la programmation objet devient pesante. Ajouter des fonctions
pour prendre en charge les opérations les plus courantes peut rendre les choses
plus fluides. De nombreuses personnes définissent, par exemple, des méthodes
inherit
et method
sur les objets.
Object.prototype.inherit = function(constructeurDeBase) { this.prototype = clone(constructeurDeBase.prototype); this.prototype.constructor = this; }; Object.prototype.method = function(nom, func) { this.prototype[nom] = func; }; function TableauEtrange(){} TableauEtrange.inherit(Array); TableauEtrange.method("push", function(valeur) { Array.prototype.push.call(this, valeur); Array.prototype.push.call(this, valeur); }); var etrange = new TableauEtrange(); etrange.push(4); show(etrange);
Si vous cherchez sur Internet les mots « JavaScript » et « héritage », vous trouverez de nombreuses variantes de ces fonctions, certaines sont plus complexes et plus subtiles que celles ci-dessus.
Remarquez comment la méthode push
écrite ici utilise la méthode push
du
prototype de son type parent. C’est quelque chose qui se fait fréquemment lors
de l’utilisation de l’héritage ― une méthode du sous-type utilise en interne
une méthode du super-type, mais en la modifiant d’une manière ou d’une autre.
La plus grande difficulté dans cette approche simpliste est la dualité entre les constructeurs et les prototypes. Les constructeurs ont un rôle vraiment central, ils sont le moyen par lequel les objets prennent leur nom, et quand vous avez besoin d’accéder à un prototype, vous devez passer par le constructeur et sa propriété prototype.
Cela ajoute beaucoup de frappes au clavier ("prototype"
prend 9 lettres),
de plus, c’est déroutant. Nous avons eu besoin d’écrire un constructeur vide et
inutile pour TableauEtrange
dans l’exemple précédent. Quelquefois, il m’est
arrivé d’ajouter par erreur des méthodes à un constructeur au lieu de son
prototype, ou d’essayer d’appeler Array.slice
alors que je voulais appeler
Array.prototype.slice
. Autant que je sache, le prototype lui-même est
l’aspect le plus important d’un type d’objet, et le constructeur n’est qu’une
extension de cela, une méthode spéciale.
En ajoutant quelques méthodes simples d’aide à Object.prototype
, il devient
possible de créer une approche alternative aux objets et à l’héritage. Dans
cette approche, un type est représenté par son prototype, et nous allons
utiliser des variables en majuscule pour stocker ces prototypes. Quand il faut
faire un peu de travail de « construction », cela est réalisé par une méthode
appelée construct
. Nous ajoutons une méthode appelée create
au prototype
Object
, qui est utilisée à la place du mot-clé new
. Elle clone l’objet, et
appelle sa méthode construct
, si une telle méthode existe, en lui passant en
argument ceux qui ont été passés à create
.
Object.prototype.create = function() { var objet = clone(this); if (typeof objet.construct == "function") objet.construct.apply(objet, arguments); return objet; };
L’héritage peut être réalisé en clonant un objet prototype et en ajoutant ou
remplaçant certaines de ses propriétés. Nous fournissons également une aide
pratique pour réaliser cela, une méthode extend
, qui clone l’objet sur lequel
on l’appelle et qui ajoute à ce clone les propriétés de l’objet qui lui est
donné en argument.
Object.prototype.extend = function(properties) { var resultat = clone(this); forEachIn(properties, function(nom, valeur) { resultat[nom] = valeur; }); return resultat; };
Dans le cas où il n’est pas prudent de tripoter le prototype Object
, cela
peut bien évidemment être implémenté avec des fonctions classiques (pas des
méthodes).
Voici un exemple, si vous êtes suffisamment vieux, vous avez peut-être déjà joué à un jeu d’aventure en mode texte, où vous vous déplaciez dans un monde virtuel en tapant au clavier des commandes, et obteniez des réponses sous forme de texte décrivant ce qu’il y avait autour de vous et les actions que vous effectuiez. Ces jeux ont eu leur temps.
Nous pouvons écrire un prototype pour un élément d’un jeu de ce type.
var Produit = { construct: function(nom) { this.nom = nom; }, examiner: function() { print("C’est ", this.nom, "."); }, frapper: function() { print("Blang !"); }, prendre: function() { print("Vous ne pouvez pas soulever ", this.nom, "."); } }; var lanterne = Produit.create("La lanterne en laiton"); lanterne.frapper();
Héritons de ce type de cette façon…
var ProduitDetaille = Produit.extend({ construct: function(nom, details) { Produit.construct.call(this, nom); this.details = details; }, examiner: function() { print("vous voyez ", this.nom, ", ", this.details, "."); } }); var paresseuxGeant = ProduitDetaille.create( "le paresseux géant", "il s’accroche tranquillement sur un arbre en grignotant des feuilles"); paresseuxGeant.examiner();
Mettre à part l’utilisation de prototype
simplifie les choses, par exemple le
constructeur de ProduitDetaille
peut appeler directement Produit.contruct
.
Remarquez que ce serait une mauvaise idée d’écrire simplement this.nom = nom
dans ProduitDetaille.construct
. Cela duplique une ligne. Bien sûr, dupliquer
cette ligne est plus court qu’appeler la fonction Produit.construct
mais si
on se retrouve à ajouter plus tard quelque chose dans le constructeur, nous
devrons l’ajouter à deux endroits différents.
La plupart du temps, le constructeur d’un sous-type commencera par appeler le constructeur du super-type. De cette façon, il démarre avec un objet valide du super-type, qu’il peut alors étendre. Dans cette nouvelle approche des prototypes, les types qui n’ont pas besoin de constructeurs peuvent les laisser tomber. Ils hériteront automatiquement du constructeur de leur super-type.
var PetitProduit = Produit.extend({ frapper: function() { print(this.nom, " vole à travers la pièce."); }, prendre: function() { // (imaginez ici du code qui déplace l’objet dans votre poche) print("vous prenez ", this.nom, "."); } }); var stylo = PetitProduit.create("le stylo rouge"); stylo.prendre();
Même si PetitProduit
ne définit pas son propre constructeur, le créer avec un
argument nom
fonctionne, car il hérite du constructeur du prototype
Produit
.
JavaScript possède un opérateur appelé instanceof
, qui peut être utilisé
pour déterminer si un objet est basé sur un certain prototype. Vous lui donnez
l’objet du côté gauche, et le constructeur du côté droit, et il renvoie un
booléen, true
si la propriété prototype
du constructeur est le prototype
direct ou indirect de l’objet, et false
sinon.
Lorsque vous utilisez des constructeurs normaux, utiliser cet opérateur devient
plutôt maladroit : il attend la fonction constructeur comme deuxième argument,
mais nous avons seulement des prototypes. Une astuce similaire à la fonction
clone
peut être utilisée pour éviter cela . Nous utilisons un « faux
constructeur », et nous lui appliquons instanceof
.
Object.prototype.hasPrototype = function(prototype) { function FauxConstructeur() {} FauxConstructeur.prototype = prototype; return this instanceof FauxConstructeur; }; show(stylo.hasPrototype(Produit)); show(stylo.hasPrototype(ProduitDetaille));
Ensuite, nous voulons créer un petit élément qui possède une description
détaillée. Il semblerait que cet élément devrait hériter à la fois de
ProduitDetaille
et PetitProduit
. JavaScript ne permet pas à un objet
d’avoir plusieurs prototypes, et même s’il le permettait, le problème ne serait
pas simple à résoudre. Par exemple, si PetitProduit
voulait, pour une raison
quelconque, définir aussi une méthode examiner
, quelle méthode examiner
ce
nouveau prototype devrait-il utiliser ?
Dériver un type d’objet de plus d’un type parent est appelé héritage multiple. Certains langages se dégonflent et l’interdisent totalement, d’autres définissent des systèmes compliqués pour le faire marcher d’une manière pratique et bien définie. Il est possible d’implémenter un framework de multi-héritage décent en JavaScript. En fait, il y a, comme d’habitude, de nombreuses bonnes façons pour réaliser cela. Mais elles sont toutes trop compliquées pour en discuter ici. À la place, je vais vous montrer une approche très simple qui est suffisante dans la plupart des cas.
Un mix-in est un type spécifique de prototype qui peut être « incorporé » à
l’intérieur d’autres prototypes. PetitProduit
peut être considéré comme un de
ces prototypes. En copiant ses méthodes frapper
et prendre
dans un autre
prototype, nous allons incorporer la petitesse dans ce prototype.
function mixInto(object, mixIn) { forEachIn(mixIn, function(nom, valeur) { object[nom] = valeur; }); }; var PetitProduitDetaille = clone(ProduitDetaille); mixInto(PetitProduitDetaille, PetitProduit); var sourisMorte = PetitProduitDetaille.create( "Fred la souris", "il est mort"); sourisMorte.examiner(); sourisMorte.frapper();
Rappelez-vous que forEachIn
parcourt uniquement les propres propriétés de
l’objet, il copiera donc frapper
et prendre
, mais pas le constructeur que
PetitProduit
a hérité de Produit
.
Mélanger les prototypes devient plus complexe quand le mix-in a un
constructeur, ou quand certaines de ses méthodes entrent en « collision » avec
les méthodes du prototype dans lequel il est incorporé. Parfois, il est
possible de faire un mix-in « manuellement ». Disons que nous avons un
prototype Monstre
, qui a son propre constructeur, et nous voulons le mélanger
avec ProduitDetaille
.
var Monstre = Produit.extend({ construct: function(nom, estDangereux) { Produit.construct.call(this, nom); this.estDangereux = estDangereux; }, frapper: function() { if (this.estDangereux) print(this.nom, " arrache votre tête avec ses dents."); else print(this.nom, " fuit en pleurant."); } }); var MonstreDetaille = ProduitDetaille.extend({ construct: function(nom, description, estDangereux) { ProduitDetaille.construct.call(this, nom, description); Monstre.construct.call(this, nom, estDangereux); }, frapper: Monstre.frapper }); var paresseuxGeant = MonstreDetaille.create( "le paresseux géant", "il s’accroche tranquillement sur un arbre en grignotant des feuilles", true); paresseuxGeant.frapper();
Mais remarquez que cela conduit à appeler deux fois le constructeur de
Produit
lorsqu’on crée un MonstreDetaille
: une fois à travers le
constructeur de ProduitDetaille
, et une fois à travers le constructeur de
Monstre
. Dans ce cas, il n’y a pas trop de dégâts, mais il existe des
situations dans lesquelles cela pourrait poser problème.
Mais ne laissez pas ces complications vous décourager d’utiliser l’héritage. Les héritages multiples, même s’ils sont très utiles dans certaines situations, peuvent être ignorés sans problème la plupart du temps. C’est la raison pour laquelle certains langages comme Java s’en sortent en interdisant les héritages multiples. Et si, à un moment, vous pensez que vous en avez vraiment besoin, vous pouvez chercher sur Internet, faire quelques recherches, et trouver une approche qui fonctionne dans votre situation.
Maintenant que j’y pense, JavaScript serait probablement un fabuleux
environnement de développement pour les aventures en mode texte. Cette capacité
à modifier le comportement des objets à volonté, qui est ce que nous offre
l’héritage par prototype, est très bien adapté à cela. Si vous avez un objet
herisson
, qui a la capacité unique de rouler quand on lui tape dedans, vous
pouvez simplement changer sa méthode frapper
.
Malheureusement, les aventures en mode texte ont suivi le même chemin que les disques vinyles, alors qu’ils étaient populaires à une époque, ils ne sont joués de nos jours que par une petite population d’enthousiates.
- Ces types sont souvent appelés des « classes » dans d’autres langages de programmation.
- Pour rendre les choses plus simples, les créatures de notre terrarium se reproduiront de façon asexuée, d’elles-mêmes.
- La paresse, pour un programmeur, n’est pas forcément un péché. Les personnes qui, laborieusement, font et refont toujours les mêmes choses tendent à être de bon travailleurs à la chaîne et de mauvais programmeurs.