La herencia de clases es una forma que tiene una clase de extender otra clase.
Entonces podemos crear nuevas funciones además de las existentes.
Digamos que tenemos la clase Animal
:
clase Animal { constructor(nombre) { esta.velocidad = 0; this.nombre = nombre; } correr (velocidad) { this.speed = velocidad; alert(`${this.name} se ejecuta con velocidad ${this.speed}.`); } detener() { esta.velocidad = 0; alert(`${this.name} se detiene.`); } } let animal = new Animal("Mi animal");
Así es como podemos representar gráficamente el objeto animal
y la clase Animal
:
…Y nos gustaría crear otra class Rabbit
.
Como los conejos son animales, la clase Rabbit
debe basarse en Animal
y tener acceso a métodos animales, para que los conejos puedan hacer lo que pueden hacer los animales "genéricos".
La sintaxis para extender otra clase es: class Child extends Parent
.
Creemos class Rabbit
que hereda de Animal
:
clase Conejo extiende Animal { esconder() { alert(`${this.name} se esconde!`); } } let conejo = new Conejo("Conejo Blanco"); conejo.run(5); // Conejo Blanco corre a velocidad 5. conejo.ocultar(); // ¡El Conejo Blanco se esconde!
El objeto de la clase Rabbit
tiene acceso tanto a los métodos Rabbit
, como rabbit.hide()
, como a los métodos Animal
, como rabbit.run()
.
Internamente, extends
el trabajo de palabras clave utilizando la vieja mecánica de prototipos. Establece Rabbit.prototype.[[Prototype]]
en Animal.prototype
. Entonces, si no se encuentra un método en Rabbit.prototype
, JavaScript lo toma de Animal.prototype
.
Por ejemplo, para encontrar el método rabbit.run
, el motor comprueba (de abajo hacia arriba en la imagen):
El objeto rabbit
(no tiene run
).
Su prototipo, es Rabbit.prototype
(tiene hide
pero no run
).
Su prototipo, es decir (debido a extends
) Animal.prototype
, que finalmente tiene el método run
.
Como podemos recordar del capítulo Prototipos nativos, el propio JavaScript utiliza herencia prototípica para objetos integrados. Por ejemplo, Date.prototype.[[Prototype]]
es Object.prototype
. Es por eso que las fechas tienen acceso a métodos de objetos genéricos.
Se permite cualquier expresión después de extends
La sintaxis de clase permite especificar no solo una clase, sino cualquier expresión después de extends
.
Por ejemplo, una llamada a función que genera la clase principal:
función f(frase) { clase de retorno { diHola() { alerta(frase); } }; } clase Usuario extiende f("Hola") {} nuevo Usuario().sayHola(); // Hola
Aquí class User
hereda del resultado de f("Hello")
.
Esto puede ser útil para patrones de programación avanzados cuando usamos funciones para generar clases dependiendo de muchas condiciones y podemos heredar de ellas.
Ahora avancemos y anulemos un método. De forma predeterminada, todos los métodos que no están especificados en class Rabbit
se toman directamente "tal cual" de class Animal
.
Pero si especificamos nuestro propio método en Rabbit
, como stop()
entonces se usará en su lugar:
clase Conejo extiende Animal { detener() { // ...ahora esto se usará para Rabbit.stop() // en lugar de detener() de la clase Animal } }
Sin embargo, normalmente no queremos reemplazar totalmente un método principal, sino construir sobre él para modificar o ampliar su funcionalidad. Hacemos algo en nuestro método, pero llamamos al método principal antes/después o en el proceso.
Las clases proporcionan una palabra clave "super"
para eso.
super.method(...)
para llamar a un método principal.
super(...)
para llamar a un constructor principal (solo dentro de nuestro constructor).
Por ejemplo, dejemos que nuestro conejo se oculte automáticamente cuando se detenga:
clase animal { constructor(nombre) { esta.velocidad = 0; this.nombre = nombre; } correr (velocidad) { this.speed = velocidad; alert(`${this.name} se ejecuta con velocidad ${this.speed}.`); } detener() { esta.velocidad = 0; alert(`${this.name} se detiene.`); } } clase Conejo extiende Animal { esconder() { alert(`${this.name} se esconde!`); } detener() { super.parada(); // llama a la parada principal this.ocultar(); // y luego ocultar } } let conejo = new Conejo("Conejo Blanco"); conejo.run(5); // Conejo Blanco corre a velocidad 5. conejo.parada(); // Conejo Blanco se queda quieto. ¡El Conejo Blanco se esconde!
Ahora Rabbit
tiene el método stop
que llama al padre super.stop()
en el proceso.
Las funciones de flecha no tienen super
Como se mencionó en el capítulo Funciones de flecha revisadas, las funciones de flecha no tienen super
.
Si se accede, se toma de la función externa. Por ejemplo:
clase Conejo extiende Animal { detener() { setTimeout(() => super.stop(), 1000); // llama a la parada principal después de 1 segundo } }
El super
en la función de flecha es el mismo que en stop()
, por lo que funciona según lo previsto. Si especificáramos una función "normal" aquí, habría un error:
// Súper inesperado setTimeout(función() { super.stop() }, 1000);
Con los constructores la cosa se vuelve un poco complicada.
Hasta ahora, Rabbit
no contaba con su propio constructor
.
Según la especificación, si una clase extiende otra clase y no tiene constructor
, entonces se genera el siguiente constructor
"vacío":
clase Conejo extiende Animal { // generado para extender clases sin constructores propios constructor(...argumentos) { super(...argumentos); } }
Como podemos ver, básicamente llama al constructor
principal pasándole todos los argumentos. Eso sucede si no escribimos un constructor propio.
Ahora agreguemos un constructor personalizado a Rabbit
. Especificará earLength
además del name
:
clase animal { constructor(nombre) { esta.velocidad = 0; this.nombre = nombre; } //... } clase Conejo extiende Animal { constructor(nombre, longitud de oreja) { esta.velocidad = 0; this.nombre = nombre; this.earLength = earLength; } //... } // ¡No funciona! let conejo = new Conejo("Conejo Blanco", 10); // Error: esto no está definido.
¡Vaya! Tenemos un error. Ahora no podemos crear conejos. ¿Qué salió mal?
La respuesta corta es:
Los constructores de clases heredadas deben llamar super(...)
y (!) hacerlo antes de usar this
.
…¿Pero por qué? ¿Qué está pasando aquí? De hecho, el requisito parece extraño.
Por supuesto, hay una explicación. Entremos en detalles para que realmente entiendas lo que está pasando.
En JavaScript, existe una distinción entre una función constructora de una clase heredera (el llamado "constructor derivado") y otras funciones. Un constructor derivado tiene una propiedad interna especial [[ConstructorKind]]:"derived"
. Esa es una etiqueta interna especial.
Esa etiqueta afecta su comportamiento con new
.
Cuando se ejecuta una función normal con new
, crea un objeto vacío y se lo asigna a this
.
Pero cuando se ejecuta un constructor derivado, no hace esto. Espera que el constructor principal haga este trabajo.
Entonces, un constructor derivado debe llamar super
para ejecutar su constructor principal (base); de lo contrario, no se creará el objeto para this
. Y obtendremos un error.
Para que el constructor Rabbit
funcione, necesita llamar super()
antes de usar this
, como aquí:
clase Animal { constructor(nombre) { esta.velocidad = 0; this.nombre = nombre; } //... } clase Conejo extiende Animal { constructor(nombre, longitud de oreja) { super(nombre); this.earLength = earLength; } //... } // ahora bien let conejo = new Conejo("Conejo Blanco", 10); alerta(conejo.nombre); // Conejo Blanco alerta(conejo.earLength); // 10
nota avanzada
Esta nota asume que tienes cierta experiencia con clases, tal vez en otros lenguajes de programación.
Proporciona una mejor comprensión del lenguaje y también explica el comportamiento que podría ser una fuente de errores (pero no muy a menudo).
Si le resulta difícil de entender, continúe leyendo y vuelva a leerlo algún tiempo después.
Podemos anular no sólo métodos, sino también campos de clase.
Sin embargo, hay un comportamiento complicado cuando accedemos a un campo anulado en el constructor principal, bastante diferente de la mayoría de los otros lenguajes de programación.
Considere este ejemplo:
clase animal { nombre = 'animal'; constructor() { alerta(este.nombre); // (*) } } clase Conejo extiende Animal { nombre = 'conejo'; } nuevo animal(); // animal nuevo Conejo(); // animal
Aquí, la clase Rabbit
extiende Animal
y anula el campo name
con su propio valor.
No hay un constructor propio en Rabbit
, por lo que se llama al constructor Animal
.
Lo interesante es que en ambos casos: new Animal()
y new Rabbit()
, la alert
en la línea (*)
muestra animal
.
En otras palabras, el constructor padre siempre usa su propio valor de campo, no el anulado.
¿Qué tiene de extraño?
Si aún no está claro, compárelo con los métodos.
Aquí está el mismo código, pero en lugar del campo this.name
llamamos al método this.showName()
:
clase Animal { showName() { // en lugar de this.name = 'animal' alerta('animal'); } constructor() { this.showName(); // en lugar de alerta(este.nombre); } } clase Conejo extiende Animal { mostrarNombre() { alerta('conejo'); } } nuevo animal(); // animal nuevo Conejo(); // conejo
Tenga en cuenta: ahora el resultado es diferente.
Y eso es lo que naturalmente esperamos. Cuando se llama al constructor principal en la clase derivada, utiliza el método anulado.
…Pero para los campos de clase no es así. Como se dijo, el constructor principal siempre usa el campo principal.
¿Por qué hay una diferencia?
Bueno, la razón es el orden de inicialización de los campos. El campo de clase se inicializa:
Antes del constructor de la clase base (que no extiende nada),
Inmediatamente después de super()
para la clase derivada.
En nuestro caso, Rabbit
es la clase derivada. No hay ningún constructor()
en él. Como se dijo anteriormente, eso es lo mismo que si hubiera un constructor vacío con solo super(...args)
.
Entonces, new Rabbit()
llama a super()
, ejecutando así el constructor principal y (según la regla para las clases derivadas) solo después de eso se inicializan sus campos de clase. En el momento de la ejecución del constructor principal, todavía no hay campos de clase Rabbit
, por eso se utilizan campos Animal
.
Esta sutil diferencia entre campos y métodos es específica de JavaScript.
Afortunadamente, este comportamiento sólo se revela si se utiliza un campo anulado en el constructor principal. Entonces puede resultar difícil entender qué está pasando, por eso lo explicamos aquí.
Si se convierte en un problema, se puede solucionar utilizando métodos o captadores/definidores en lugar de campos.
Información avanzada
Si estás leyendo el tutorial por primera vez, es posible que te saltes esta sección.
Se trata de los mecanismos internos detrás de la herencia y super
.
Profundicemos un poco más bajo el capó de super
. Veremos algunas cosas interesantes en el camino.
En primer lugar, por todo lo que hemos aprendido hasta ahora, ¡es imposible que super
funcione!
Sí, efectivamente, preguntémonos, ¿cómo debería funcionar técnicamente? Cuando se ejecuta un método de objeto, obtiene el objeto actual como this
. Si llamamos super.method()
, el motor necesita obtener el method
del prototipo del objeto actual. ¿Pero cómo?
La tarea puede parecer sencilla, pero no lo es. El motor conoce el objeto actual this
, por lo que podría obtener el method
principal como this.__proto__.method
. Desafortunadamente, una solución tan “ingenua” no funcionará.
Demostremos el problema. Sin clases, usando objetos simples por simplicidad.
Puede omitir esta parte e ir a la subsección [[HomeObject]]
si no desea conocer los detalles. Eso no hará daño. O sigue leyendo si estás interesado en comprender las cosas en profundidad.
En el siguiente ejemplo, rabbit.__proto__ = animal
. Ahora intentemos: en rabbit.eat()
llamaremos animal.eat()
, usando this.__proto__
:
dejar animal = { nombre: "Animal", comer() { alert(`${this.name} come.`); } }; deja conejo = { __proto__: animal, nombre: "Conejo", comer() { // así es como presumiblemente podría funcionar super.eat() this.__proto__.eat.call(esto); // (*) } }; conejo.comer(); // El conejo come.
En la línea (*)
tomamos eat
del prototipo ( animal
) y lo llamamos en el contexto del objeto actual. Tenga en cuenta que .call(this)
es importante aquí, porque un simple this.__proto__.eat()
ejecutaría parent eat
en el contexto del prototipo, no en el objeto actual.
Y en el código anterior realmente funciona según lo previsto: tenemos la alert
correcta.
Ahora agreguemos un objeto más a la cadena. Veremos cómo se rompe la cosa:
dejar animal = { nombre: "Animal", comer() { alert(`${this.name} come.`); } }; deja conejo = { __proto__: animal, comer() { // ...rebota al estilo conejo y llama al método padre (animal) this.__proto__.eat.call(esto); // (*) } }; dejar oreja larga = { __proto__: conejo, comer() { // ...haz algo con orejas largas y llama al método padre (conejo) this.__proto__.eat.call(esto); // (**) } }; oreja larga.comer(); // Error: se excedió el tamaño máximo de la pila de llamadas
¡El código ya no funciona! Podemos ver el error al intentar llamar a longEar.eat()
.
Puede que no sea tan obvio, pero si rastreamos la llamada longEar.eat()
, podremos ver por qué. En ambas líneas (*)
y (**)
el valor de this
es el objeto actual ( longEar
). Eso es esencial: todos los métodos de objetos obtienen el objeto actual como this
, no como un prototipo o algo así.
Entonces, en ambas líneas (*)
y (**)
el valor de this.__proto__
es exactamente el mismo: rabbit
. Ambos llaman rabbit.eat
sin subir la cadena en el bucle sin fin.
Aquí está la imagen de lo que sucede:
Dentro de longEar.eat()
, la línea (**)
llama a rabbit.eat
proporcionándole this=longEar
.
// dentro de longEar.eat() tenemos esto = longEar this.__proto__.eat.call(esto) // (**) // se convierte longEar.__proto__.eat.call(esto) // eso es conejo.comer.llamar(esto);
Luego, en la línea (*)
de rabbit.eat
, nos gustaría pasar la llamada aún más arriba en la cadena, pero this=longEar
, por lo que this.__proto__.eat
es nuevamente rabbit.eat
.
// dentro de Rabbit.eat() también tenemos esto = longEar this.__proto__.eat.call(esto) // (*) // se convierte longEar.__proto__.eat.call(esto) // o (otra vez) conejo.comer.llamar(esto);
…Así que rabbit.eat
se llama a sí mismo en el bucle sin fin, porque no puede ascender más.
El problema no se puede resolver usando this
solo.
[[HomeObject]]
Para proporcionar la solución, JavaScript agrega una propiedad interna especial más para las funciones: [[HomeObject]]
.
Cuando una función se especifica como una clase o un método de objeto, su propiedad [[HomeObject]]
se convierte en ese objeto.
Luego, super
lo usa para resolver el prototipo principal y sus métodos.
Veamos cómo funciona, primero con objetos simples:
dejar animal = { nombre: "Animal", comer() { // animal.comer.[[HomeObject]] == animal alert(`${this.name} come.`); } }; deja conejo = { __proto__: animal, nombre: "Conejo", comer() { // conejo.comer.[[HomeObject]] == conejo super.comer(); } }; dejar oreja larga = { __proto__: conejo, nombre: "Oreja Larga", comer() { // longEar.eat.[[HomeObject]] == longEar super.comer(); } }; // funciona correctamente oreja larga.comer(); // La oreja larga come.
Funciona según lo previsto, gracias a la mecánica [[HomeObject]]
. Un método, como longEar.eat
, conoce su [[HomeObject]]
y toma el método principal de su prototipo. Sin ningún uso de this
.
Como sabemos antes, generalmente las funciones son "libres" y no están vinculadas a objetos en JavaScript. Para que puedan copiarse entre objetos y llamarse con otro this
.
La existencia misma de [[HomeObject]]
viola ese principio, porque los métodos recuerdan sus objetos. [[HomeObject]]
no se puede cambiar, por lo que este vínculo es para siempre.
El único lugar en el idioma donde se usa [[HomeObject]]
es super
. Entonces, si un método no usa super
, aún podemos considerarlo libre y copiar entre objetos. Pero con super
las cosas pueden salir mal.
Aquí está la demostración de un super
resultado incorrecto después de copiar:
dejar animal = { decir Hola() { alerta(`Soy un animal`); } }; // el conejo hereda del animal deja conejo = { __proto__: animal, decir Hola() { super.sayHola(); } }; dejar plantar = { decir Hola() { alerta("Soy una planta"); } }; // el árbol hereda de la planta dejar árbol = { __proto__: planta, decirHola: conejo.decirHola // (*) }; árbol.sayHola(); // Soy un animal (?!?)
Una llamada a tree.sayHi()
muestra "Soy un animal". Definitivamente equivocado.
La razón es sencilla:
En la línea (*)
, el método tree.sayHi
se copió de rabbit
. ¿Quizás sólo queríamos evitar la duplicación de código?
Su [[HomeObject]]
es rabbit
, ya que fue creado en rabbit
. No hay forma de cambiar [[HomeObject]]
.
El código de tree.sayHi()
tiene super.sayHi()
dentro. Sube del rabbit
y toma el método del animal
.
Aquí está el diagrama de lo que sucede:
[[HomeObject]]
está definido para métodos tanto en clases como en objetos simples. Pero para los objetos, los métodos deben especificarse exactamente como method()
, no como "method: function()"
.
La diferencia puede no ser esencial para nosotros, pero es importante para JavaScript.
En el siguiente ejemplo se utiliza una sintaxis que no es un método para comparar. La propiedad [[HomeObject]]
no está establecida y la herencia no funciona:
dejar animal = { comer: función() { // escribir intencionalmente así en lugar de comer() {... //... } }; deja conejo = { __proto__: animal, comer: función() { super.comer(); } }; conejo.comer(); // Error al llamar a super (porque no hay [[HomeObject]])
Para extender una clase: class Child extends Parent
:
Eso significa que Child.prototype.__proto__
será Parent.prototype
, por lo que los métodos se heredan.
Al anular un constructor:
Debemos llamar al constructor principal como super()
en el constructor Child
antes de usar this
.
Al anular otro método:
Podemos usar super.method()
en un método Child
para llamar al método Parent
.
Internos:
Los métodos recuerdan su clase/objeto en la propiedad interna [[HomeObject]]
. Así es como se super
los métodos principales.
Por tanto, no es seguro copiar un método con super
de un objeto a otro.
También:
Las funciones de flecha no tienen su propio this
o super
, por lo que encajan de forma transparente en el contexto circundante.
importancia: 5
Aquí está el código con Rabbit
extendiendo Animal
.
Desafortunadamente, los objetos Rabbit
no se pueden crear. ¿Qué ocurre? Arreglalo.
clase Animal { constructor(nombre) { this.nombre = nombre; } } clase Conejo extiende Animal { constructor(nombre) { this.nombre = nombre; this.creado = Fecha.ahora(); } } let conejo = new Conejo("Conejo Blanco"); // Error: esto no está definido alerta(conejo.nombre);
Esto se debe a que el constructor secundario debe llamar super()
.
Aquí está el código corregido:
clase Animal { constructor(nombre) { this.nombre = nombre; } } clase Conejo extiende Animal { constructor(nombre) { super(nombre); this.creado = Fecha.ahora(); } } let conejo = new Conejo("Conejo Blanco"); // bien ahora alerta(conejo.nombre); // Conejo Blanco
importancia: 5
Tenemos una clase Clock
. A partir de ahora, imprime la hora cada segundo.
reloj de clase { constructor({plantilla}) { this.template = plantilla; } prestar() { let fecha = nueva fecha(); let horas = fecha.getHours(); si (horas < 10) horas = '0' + horas; let mins = fecha.getMinutes(); si (minutos < 10) minutos = '0' + minutos; let segundos = fecha.getSeconds(); si (segundos < 10) segundos = '0' + segundos; dejar salida = this.template .replace('h', horas) .replace('m', minutos) .replace('s', segundos); console.log(salida); } detener() { clearInterval(este.temporizador); } comenzar() { this.render(); this.timer = setInterval(() => this.render(), 1000); } }
Cree una nueva clase ExtendedClock
que herede de Clock
y agregue la precision
del parámetro: el número de ms
entre "tics". Debe ser 1000
(1 segundo) de forma predeterminada.
Su código debe estar en el archivo extended-clock.js
No modifique el clock.js
original. Extiéndelo.
Abra una zona de pruebas para la tarea.
clase ExtendedClock extiende Reloj { constructor(opciones) { súper(opciones); let { precisión = 1000 } = opciones; this.precision = precisión; } comenzar() { this.render(); this.timer = setInterval(() => this.render(), this.precision); } };
Abra la solución en una caja de arena.