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.
extends
La 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.
super
Comme 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.eat
et 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__.eat
est 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.eat
s’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.sayHi
a é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 derabbit
et 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 constructeurChild
avant 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éthodeChild
pour 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 quesuper
résout les méthodes parent. - Il n’est donc pas prudent de copier une méthode avec
super
d’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
this
ousuper
, elles s’adaptent donc de manière transparente au contexte environnant.