Lâhéritage de classe est un moyen pour une classe dâétendre une autre classe.
Nous pouvons donc créer de nouvelles fonctionnalités à partir de celles qui existent déjà .
Le mot-clé âextendsâ
Supposons que nous ayons la classe Animal :
class Animal {
constructor(name) {
this.speed = 0;
this.name = name;
}
run(speed) {
this.speed = speed;
alert(`${this.name} runs with speed ${this.speed}.`);
}
stop() {
this.speed = 0;
alert(`${this.name} stands still.`);
}
}
let animal = new Animal("My animal");
Voici comment nous pouvons représenter graphiquement lâobjet animal et la classe Animal :
⦠Et nous aimerions créer une autre class Rabbit.
Comme les lapins sont des animaux, la classe Rabbit devrait être basé sur Animal, avoir accès à des méthodes animales, de sorte que les lapins puissent faire ce que les animaux âgénériquesâ peuvent faire.
La syntaxe pour étendre une autre classe est la suivante : class Child extends Parent.
Créons class Rabbit qui hérite de Animal :
class Rabbit extends Animal {
hide() {
alert(`${this.name} hides!`);
}
}
let rabbit = new Rabbit("White Rabbit");
rabbit.run(5); // White Rabbit court à la vitesse 5.
rabbit.hide(); // White Rabbit se cache!
Lâobjet de la classe Rabbit a accès à la fois aux méthodes Rabbit, telles que rabbit.hide(), et aux méthodes Animal, telles que rabbit.run().
En interne, le mot clé extended fonctionne en utilisant le bon vieux prototype. Il établit Rabbit.prototype.[[Prototype]] vers Animal.prototype. Donc, si une méthode nâest pas trouvée dans Rabbit.prototype, JavaScript le prend de Animal.prototype.
Par exemple, pour trouver la méthode rabbit.run, le moteur vérifie (de bas en haut sur lâimage) :
- Lâobjet
rabbit(nâa pas derun). - Son prototype, câest-Ã -dire
Rabbit.prototype(ahide, mais pasrun). - Son prototype, câest-Ã -dire (en raison de
extends)Animal.prototype, qui a finalement la méthoderun.
Comme nous pouvons nous en rappeler dans le chapitre Prototypes natifs, JavaScript lui-même utilise lâhéritage prototypale pour les objets intégrés. Exemple : Date.prototype.[[Prototype]] est Object.prototype. Câest pourquoi les dates ont accès aux méthodes dâobjet génériques.
extendsLa syntaxe de classe permet de spécifier non seulement une classe, mais toute expression après extends.
Par exemple, un appel de fonction qui génère la classe parent :
function f(phrase) {
return class {
sayHi() { alert(phrase); }
};
}
class User extends f("Hello") {}
new User().sayHi(); // Hello
Ici la classe user hérite du résultat de f("Hello").
Cela peut être utile pour les modèles de programmation avancés lorsque nous utilisons des fonctions pour générer des classes en fonction de nombreuses conditions et que nous pouvons en hériter.
Remplacer une méthode
Maintenant, avançons et substituons une méthode. Par défaut, toutes les méthodes qui ne sont pas spécifiées dans class Rabbit sont prises directement âtelles quellesâ dans class Animal.
Mais si nous spécifions notre propre méthode dans Rabbit, telle que stop(), elle sera utilisée à la place :
class Rabbit extends Animal {
stop() {
// ...maintenant ceci sera utilisé pour rabbit.stop()
// au lieu de stop() de la classe Animal
}
}
Mais en général, nous ne voulons pas remplacer totalement une méthode parente, mais plutôt construire dessus, modifier ou étendre ses fonctionnalités. Nous faisons quelque chose dans notre méthode, mais appelons la méthode parente avant / après ou dans le processus.
Les classes fournissent le mot clé "super" pour cela.
super.method(...)pour appeler une méthode parente.super(...)pour appeler un constructeur parent (dans notre constructeur uniquement).
Par exemple, laissons rabbit se cacher automatiquement à lâarrêt :
class Animal {
constructor(name) {
this.speed = 0;
this.name = name;
}
run(speed) {
this.speed = speed;
alert(`${this.name} runs with speed ${this.speed}.`);
}
stop() {
this.speed = 0;
alert(`${this.name} stands still.`);
}
}
class Rabbit extends Animal {
hide() {
alert(`${this.name} hides!`);
}
stop() {
super.stop(); // appeler stop du parent
this.hide(); // puis hide
}
}
let rabbit = new Rabbit("White Rabbit");
rabbit.run(5); // White Rabbit court à la vitesse 5.
rabbit.stop(); // White Rabbit reste immobile. White Rabbit se cache!
Maintenant, Rabbit a la méthode stop qui appelle le super.stop() du parent dans le processus.
superComme mentionné dans le chapitre Les fonctions fléchées revisitées, les fonctions fléchées nâont pas super.
Si on y accède, câest tiré de la fonction externe. Par exemple :
class Rabbit extends Animal {
stop() {
setTimeout(() => super.stop(), 1000); // appel stop du parent après 1sec
}
}
Le super dans la fonction fléchée est le même que dans stop(), donc cela fonctionne comme prévu. Si nous spécifions ici une fonction ârégulièreâ, il y aurait une erreur :
// Super inattendu
setTimeout(function() { super.stop() }, 1000);
Remplacement du constructeur
Avec les constructeurs, cela devient un peu délicat.
Jusquâà maintenant, Rabbit nâavait pas son propre constructor.
Selon la spécification, si une classe étend une autre classe et nâa pas de constructor, alors le constructor âvideâ suivant est généré :
class Rabbit extends Animal {
// généré pour l'extension de classes sans constructeur propre
constructor(...args) {
super(...args);
}
}
Comme nous pouvons le constater, il appelle essentiellement le constructor du parent en lui passant tous les arguments. Cela se produit si nous nâécrivons pas notre propre constructeur.
Ajoutons maintenant un constructeur personnalisé à Rabbit. Il spécifiera le earLength en plus de name :
class Animal {
constructor(name) {
this.speed = 0;
this.name = name;
}
// ...
}
class Rabbit extends Animal {
constructor(name, earLength) {
this.speed = 0;
this.name = name;
this.earLength = earLength;
}
// ...
}
// Ãa ne marche pas!
let rabbit = new Rabbit("White Rabbit", 10); // Error: this is not defined.
Oups ! Nous avons une erreur. Maintenant, nous ne pouvons pas créer de lapins. Quâest-ce qui sâest passé ?
La réponse courte est :
- les constructeurs dans les classes qui héritent doivent appeler
super(...), et (!) le faire avant dâutiliserthis.
â¦Mais pourquoi ? Que se passe t-il ici ? En effet, lâexigence semble étrange.
Bien sûr, il y a une explication. Entrons dans les détails pour que vous compreniez vraiment ce qui se passe.
En JavaScript, il existe une distinction entre une fonction constructeur dâune classe héritante (appelée âconstructeur dérivéâ) et dâautres fonctions. Un constructeur dérivé a une propriété interne spéciale [[ConstructorKind]] : "derived". Câest un label interne spécial.
Ce label affecte son comportement avec new.
- Lorsquâune fonction normale est exécutée avec
new, elle crée un objet vide et lâassigne Ãthis. - Mais lorsquâun constructeur dérivé sâexécute, il ne le fait pas. Il sâattend à ce que le constructeur parent fasse ce travail.
Ainsi, un constructeur dérivé doit appeler super pour exécuter son constructeur parent (de base), sinon lâobjet pour this ne sera pas créé. Et nous aurons une erreur.
Pour que le constructeur Rabbit fonctionne, il doit appeler super() avant dâutiliser this, comme ici :
class Animal {
constructor(name) {
this.speed = 0;
this.name = name;
}
// ...
}
class Rabbit extends Animal {
constructor(name, earLength) {
super(name);
this.earLength = earLength;
}
// ...
}
// maintenant ça marche
let rabbit = new Rabbit("White Rabbit", 10);
alert(rabbit.name); // White Rabbit
alert(rabbit.earLength); // 10
Remplacer les champs de classe : une note délicate
Cette note suppose que vous avez une certaine expérience avec les classes, peut-être dans dâautres langages de programmation.
Cela donne un meilleur aperçu du langage et explique également le comportement qui pourrait être une source de bogues (mais pas très souvent).
Si vous avez du mal à comprendre, continuez, poursuivez votre lecture et revenez-y un peu plus tard.
Nous pouvons remplacer non seulement les méthodes, mais également les champs de classe.
Cependant, il existe un comportement délicat lorsque nous accédons à un champ surchargé dans le constructeur parent, assez différent de la plupart des autres langages de programmation.
Prenons cet exemple :
class Animal {
name = 'animal';
constructor() {
alert(this.name); // (*)
}
}
class Rabbit extends Animal {
name = 'rabbit';
}
new Animal(); // animal
new Rabbit(); // animal
Ici, la classe Rabbit étend Animal et remplace le champ name par sa propre valeur.
Il nây a pas de constructeur propre dans Rabbit, donc le constructeur Animal est appelé.
Ce qui est intéressant, câest que dans les deux cas : new Animal() et new Rabbit(), lâalert dans la ligne (*) montre animal.
En dâautres termes, le constructeur parent utilise toujours sa propre valeur de champ, pas celle remplacée.
Quâest-ce qui est étrange à ce sujet ?
Si ce nâest pas encore clair, veuillez comparer avec les méthodes.
Voici le même code, mais au lieu du champ this.name, nous appelons la méthode this.showName() :
class Animal {
showName() { // au lieu de this.name = 'animal'
alert('animal');
}
constructor() {
this.showName(); // au lieu de alert(this.name);
}
}
class Rabbit extends Animal {
showName() {
alert('rabbit');
}
}
new Animal(); // animal
new Rabbit(); // rabbit
Remarque : maintenant la sortie est différente.
Et câest ce à quoi nous nous attendons naturellement. Lorsque le constructeur parent est appelé dans la classe dérivée, il utilise la méthode substituée.
⦠Mais ce nâest pas le cas pour les champs de classe. Comme nous lâavons dit, le constructeur parent utilise toujours le champ parent.
Pourquoi y a-t-il une différence ?
Eh bien, la raison est dans lâordre dâinitialisation du champ. Le champ de classe est initialisé :
- Avant le constructeur de la classe de base (qui nâétend rien),
- Immédiatement après
super()pour la classe dérivée.
Dans notre cas, Rabbit est la classe dérivée. Il nây a pas de constructor() dedans. Comme dit précédemment, câest la même chose que sâil y avait un constructeur vide avec seulement super(...args).
Ainsi, new Rabbit() appelle super(), exécutant ainsi le constructeur parent, et (selon la règle pour les classes dérivées) seulement après cela ses champs de classe sont initialisés. Au moment de lâexécution du constructeur parent, il nây a pas encore de champs de classe Rabbit, câest pourquoi les champs Animal sont utilisés.
Cette subtile différence entre les champs et les méthodes est propre à JavaScript.
Heureusement, ce comportement ne se révèle que si un champ surchargé est utilisé dans le constructeur parent. Ensuite, il peut être difficile de comprendre ce qui se passe, alors nous lâexpliquons ici.
Si cela devient un problème, on peut le résoudre en utilisant des méthodes ou des getters / setters au lieu de champs.
Super: les internes, [[HomeObject]]
Si vous lisez le tutoriel pour la première fois â cette section peut être ignorée.
Il concerne les mécanismes internes de lâhéritage et du âsuperâ.
Poussons un peu plus loin sous le capot de super. Nous y verrons des choses intéressantes.
Tout dâabord, dâaprès tout ce que nous avons appris jusquâà présent, super ne devrait pas fonctionner du tout !
Oui, en effet, demandons-nous comment cela devrait fonctionner techniquement ? Lorsquâune méthode dâobjet est exécutée, lâobjet actuel est remplacé par this. Si nous appelons super.method(), le moteur doit obtenir la method à partir du prototype de lâobjet actuel. Mais comment ?
La tâche peut sembler simple, mais elle ne lâest pas. Le moteur connaît lâobjet en cours this, de sorte quâil pourrait obtenir la method parent sous la forme this.__proto__.Method. Malheureusement, une telle solution ânaïveâ ne fonctionnera pas.
Montrons le problème. Sans les classes, en utilisant des objets simples pour des raisons de simplicité.
Si vous ne voulez pas connaître les détails, vous pouvez sauter cette partie et aller en bas à la sous-section [[HomeObject]]. Cela ne fera pas de mal. Ou lisez si vous souhaitez comprendre les choses en profondeur.
Dans lâexemple ci-dessous, rabbit.__proto__=animal. Essayons maintenant : dans rabbit.eat() nous appellerons animal.eat(), en utilisant this.__proto__ :
let animal = {
name: "Animal",
eat() {
alert(`${this.name} eats.`);
}
};
let rabbit = {
__proto__: animal,
name: "Rabbit",
eat() {
// c'est ainsi que super.eat() pourrait fonctionner
this.__proto__.eat.call(this); // (*)
}
};
rabbit.eat(); // Rabbit eats.
à la ligne (*) nous prenons eat du prototype (animal) et lâappelons dans le contexte de lâobjet actuel. Veuillez noter que .call(this) est important ici, car un simple this.__proto__.eat() exécuterait le eat du parent dans le contexte du prototype et non de lâobjet actuel.
Et dans le code ci-dessus, cela fonctionne réellement comme prévu : nous avons la bonne alert.
Ajoutons maintenant un objet de plus à la chaîne. Nous verrons comment les choses se cassent :
let animal = {
name: "Animal",
eat() {
alert(`${this.name} eats.`);
}
};
let rabbit = {
__proto__: animal,
eat() {
// ...rebondir et appeler la méthode du parent (animal)
this.__proto__.eat.call(this); // (*)
}
};
let longEar = {
__proto__: rabbit,
eat() {
// ...faire quelque chose puis appeler la méthode du parent (rabbit)
this.__proto__.eat.call(this); // (**)
}
};
longEar.eat(); // Error: Maximum call stack size exceeded
Le code ne fonctionne plus ! Nous pouvons voir lâerreur en essayant dâappeler longEar.eat().
Ce nâest peut-être pas si évident, mais si nous suivons lâappel de longEar.eat(), nous pouvons voir pourquoi. Dans les deux lignes (*) et (**), la valeur de this est lâobjet actuel (longEar). Câest essentiel : toutes les méthodes dâobjet obtiennent lâobjet actuel sous la forme this, pas un prototype ou quelque chose dâautre.
Ainsi, dans les deux lignes (*) et (**), la valeur de this.__proto__ est exactement la même : rabbit. Ils appellent tous deux rabbit.eat sans remonter la chaîne, dans une boucle infinie.
Voici lâimage de ce qui se passe :
-
Dans
longEar.eat(), la ligne(**)appellerabbit.eatet lui fournitthis = longEar.// dans longEar.eat() nous avons this = longEar this.__proto__.eat.call(this) // (**) // devient longEar.__proto__.eat.call(this) // Ã savoir rabbit.eat.call(this); -
Ensuite, dans la ligne
(*)derabbit.eat, nous aimerions passer lâappel encore plus haut dans la chaîne, maisthis = longEar, doncthis.__proto__.eatest encorerabbit.eat!// dans rabbit.eat() nous avons aussi this = longEar this.__proto__.eat.call(this) // (*) // devient longEar.__proto__.eat.call(this) // ou (encore) rabbit.eat.call(this); -
â¦Donc
rabbit.eatsâappelle lui-même dans une boucle infinie, car il ne peut pas monter plus loin.
Le problème ne peut pas être résolu en utilisant seulement this.
[[HomeObject]]
Pour fournir la solution, JavaScript ajoute une propriété interne spéciale supplémentaire pour les fonctions : [[HomeObject]].
Lorsquâune fonction est spécifiée en tant que méthode de classe ou dâobjet, sa propriété [[HomeObject]] devient cet objet.
Ensuite, super lâutilise pour résoudre le prototype parent et ses méthodes.
Voyons comment cela fonctionne, dâabord avec les objets simples :
let animal = {
name: "Animal",
eat() { // animal.eat.[[HomeObject]] == animal
alert(`${this.name} eats.`);
}
};
let rabbit = {
__proto__: animal,
name: "Rabbit",
eat() { // rabbit.eat.[[HomeObject]] == rabbit
super.eat();
}
};
let longEar = {
__proto__: rabbit,
name: "Long Ear",
eat() { // longEar.eat.[[HomeObject]] == longEar
super.eat();
}
};
// fonctionne correctement
longEar.eat(); // Long Ear eats.
Cela fonctionne comme prévu, en raison de la mécanique [[HomeObject]]. Une méthode, telle que longEar.eat, connaît son [[HomeObject]] et prend la méthode parente de son prototype. Sans aucune utilisation de this.
Les méthodes ne sont pas âlibresâ
Comme nous lâavons vu précédemment, les fonctions sont généralement âlibresâ et ne sont pas liées à des objets en JavaScript. Ils peuvent donc être copiés entre des objets et appelés avec un autre this.
Lâexistence même de [[HomeObject]] viole ce principe, car les méthodes se souviennent de leurs objets. [[HomeObject]] ne peut pas être changé, ce lien est donc permanent.
Le seul endroit dans le langage où [[HomeObject]] est utilisé est super. Donc, si une méthode nâutilise pas super, on peut toujours la considérer comme libre et la copier entre les objets. Mais avec super, les choses peuvent mal tourner.
Voici la démo dâun mauvais résultat de super après la copie :
let animal = {
sayHi() {
alert(`I'm an animal`);
}
};
// rabbit hérite de animal
let rabbit = {
__proto__: animal,
sayHi() {
super.sayHi();
}
};
let plant = {
sayHi() {
alert("I'm a plant");
}
};
// tree hérite de plant
let tree = {
__proto__: plant,
sayHi: rabbit.sayHi // (*)
};
tree.sayHi(); // I'm an animal (?!?)
Un appel à tree.sayHi() indique âIâm an animalâ. Certainement faux.
La raison est simple :
- Dans la ligne
(*), la méthodetree.sayHia été copiée à partir derabbit. Peut-être que nous voulions simplement éviter la duplication de code ? - Son
[[[HomeObject]]estrabbit, comme il a été créé dansrabbit. Il nây a aucun moyen de changer[[HomeObject]]. - Le code de
tree.sayHi()asuper.sayHi()à lâintérieur. Il monte derabbitet prend la méthode deanimal.
Voici le schéma de ce qui se passe :
Méthodes, pas des propriétés de fonction
[[HomeObject]] est défini pour les méthodes à la fois dans les classes et dans les objets simples. Mais pour les objets, les méthodes doivent être spécifiées exactement comme method(), pas comme "method: function()".
La différence peut être non essentielle pour nous, mais câest important pour JavaScript.
Dans lâexemple ci-dessous, une syntaxe non-méthode est utilisée pour la comparaison. La propriété [[HomeObject]] nâest pas définie et lâhéritage ne fonctionne pas :
let animal = {
eat: function() { // écrire intentionnellement comme ceci au lieu de eat() {...
// ...
}
};
let rabbit = {
__proto__: animal,
eat: function() {
super.eat();
}
};
rabbit.eat(); // Error calling super (parce qu'il n'y a pas de [[HomeObject]])
Résumé
- Pour étendre une classe :
class Child extends Parent:- Cela signifie que
Child.prototype.__proto__seraParent.prototype, donc les méthodes sont héritées.
- Cela signifie que
- Lors du remplacement dâun constructeur :
- Nous devons appeler le constructeur parent en tant que
super()dans le constructeurChildavant dâutiliserthis.
- Nous devons appeler le constructeur parent en tant que
- Lors du remplacement dâune autre méthode :
- Nous pouvons utiliser
super.method()dans une méthodeChildpour appeler la méthodeParent.
- Nous pouvons utiliser
- Internes :
- Les méthodes se souviennent de leur classe/objet dans la propriété interne
[[HomeObject]]. Câest ainsi quesuperrésout les méthodes parent. - Il nâest donc pas prudent de copier une méthode avec
superdâun objet à un autre.
- Les méthodes se souviennent de leur classe/objet dans la propriété interne
Ãgalement :
- Les fonctions fléchées nâont pas leurs propre
thisousuper, elles sâadaptent donc de manière transparente au contexte environnant.
Commentaires
<code>, pour plusieurs lignes â enveloppez-les avec la balise<pre>, pour plus de 10 lignes - utilisez une sandbox (plnkr, jsbin, codepenâ¦)