En programación, a menudo queremos tomar algo y ampliarlo.
Por ejemplo, tenemos un objeto user
con sus propiedades y métodos, y queremos convertirlo en admin
e guest
como variantes ligeramente modificadas del mismo. Nos gustaría reutilizar lo que tenemos en user
, no copiar/reimplementar sus métodos, simplemente construir un nuevo objeto encima.
La herencia prototípica es una característica del lenguaje que ayuda en eso.
En JavaScript, los objetos tienen una propiedad oculta especial [[Prototype]]
(como se nombra en la especificación), que es null
o hace referencia a otro objeto. Ese objeto se llama "un prototipo":
Cuando leemos una propiedad del object
y falta, JavaScript la toma automáticamente del prototipo. En programación, esto se llama "herencia prototípica". Y pronto estudiaremos muchos ejemplos de dicha herencia, así como también características de lenguaje más interesantes basadas en ella.
La propiedad [[Prototype]]
es interna y está oculta, pero hay muchas formas de configurarla.
Uno de ellos es utilizar el nombre especial __proto__
, así:
dejar animal = { come: cierto }; deja conejo = { saltos: verdadero }; conejo.__proto__ = animal; // establece conejo.[[Prototipo]] = animal
Ahora, si leemos una propiedad de rabbit
y falta, JavaScript la tomará automáticamente de animal
.
Por ejemplo:
dejar animal = { come: cierto }; deja conejo = { saltos: verdadero }; conejo.__proto__ = animal; // (*) // ahora podemos encontrar ambas propiedades en Rabbit: alerta (conejo.come); // verdadero (**) alerta (conejo.jumps); // verdadero
Aquí la línea (*)
establece que animal
sea el prototipo de rabbit
.
Luego, cuando alert
intenta leer la propiedad rabbit.eats
(**)
, no está en rabbit
, por lo que JavaScript sigue la referencia [[Prototype]]
y la encuentra en animal
(mira desde abajo hacia arriba):
Aquí podemos decir que “ animal
es el prototipo del rabbit
” o “ rabbit
hereda prototípicamente del animal
”.
Entonces, si animal
tiene muchas propiedades y métodos útiles, automáticamente estarán disponibles en rabbit
. Estas propiedades se denominan "heredadas".
Si tenemos un método en animal
, se puede invocar en rabbit
:
dejar animal = { come: cierto, caminar() { alerta("Paseo de animales"); } }; deja conejo = { saltos: verdadero, __proto__: animal }; // el paseo se toma del prototipo conejo.walk(); // Paseo de animales
El método se toma automáticamente del prototipo, así:
La cadena del prototipo puede ser más larga:
dejar animal = { come: cierto, caminar() { alerta("Paseo de animales"); } }; deja conejo = { saltos: verdadero, __proto__: animal }; dejar oreja larga = { longitud de la oreja: 10, __proto__: conejo }; // el paseo se toma de la cadena del prototipo longEar.walk(); // Paseo de animales alerta(longEar.jumps); // verdadero (de conejo)
Ahora, si leemos algo de longEar
y falta, JavaScript lo buscará en rabbit
y luego en animal
.
Sólo hay dos limitaciones:
Las referencias no pueden dar vueltas. JavaScript arrojará un error si intentamos asignar __proto__
en un círculo.
El valor de __proto__
puede ser un objeto o null
. Otros tipos se ignoran.
También puede ser obvio, pero aún así: solo puede haber un [[Prototype]]
. Un objeto no puede heredar de otros dos.
__proto__
es un captador/definidor histórico para [[Prototype]]
Es un error común de los desarrolladores novatos no saber la diferencia entre estos dos.
Tenga en cuenta que __proto__
no es lo mismo que la propiedad interna [[Prototype]]
. Es un captador/definidor para [[Prototype]]
. Más adelante veremos situaciones en las que es importante; por ahora, tengámoslo en cuenta a medida que desarrollamos nuestra comprensión del lenguaje JavaScript.
La propiedad __proto__
está un poco desactualizada. Existe por razones históricas, el JavaScript moderno sugiere que deberíamos usar las funciones Object.getPrototypeOf/Object.setPrototypeOf
en lugar de obtener/establecer el prototipo. También cubriremos estas funciones más adelante.
Según la especificación, __proto__
solo debe ser compatible con navegadores. De hecho, todos los entornos, incluido el del lado del servidor, admiten __proto__
, por lo que estamos bastante seguros al usarlo.
Como la notación __proto__
es un poco más intuitivamente obvia, la usamos en los ejemplos.
El prototipo sólo se utiliza para leer propiedades.
Las operaciones de escritura/eliminación funcionan directamente con el objeto.
En el siguiente ejemplo, asignamos su propio método walk
al rabbit
:
dejar animal = { come: cierto, caminar() { /* este método no será utilizado por el conejo */ } }; deja conejo = { __proto__: animal }; conejo.paseo = función() { alert("¡Conejo! ¡Rebota-rebota!"); }; conejo.walk(); // ¡Conejo! ¡Rebote-rebote!
De ahora en adelante, la llamada rabbit.walk()
encuentra el método inmediatamente en el objeto y lo ejecuta, sin utilizar el prototipo:
Las propiedades de acceso son una excepción, ya que la asignación la maneja una función de establecimiento. Entonces, escribir en dicha propiedad es en realidad lo mismo que llamar a una función.
Por esa razón admin.fullName
funciona correctamente en el siguiente código:
dejar usuario = { nombre: "Juan", apellido: "Smith", establecer nombre completo (valor) { [este.nombre, este.apellido] = valor.split(" "); }, obtener nombre completo() { return `${este.nombre} ${este.apellido}`; } }; dejar administrador = { __proto__: usuario, isAdmin: verdadero }; alerta (admin.nombre completo); // Juan Smith (*) // ¡el colocador dispara! admin.fullName = "Alice Cooper"; // (**) alerta(admin.nombrecompleto); // Alice Cooper, estado de administrador modificado alerta(usuario.nombrecompleto); // John Smith, estado de usuario protegido
Aquí en la línea (*)
la propiedad admin.fullName
tiene un captador en el user
prototipo, por eso se llama. Y en la línea (**)
la propiedad tiene un setter en el prototipo, por eso se llama.
Puede surgir una pregunta interesante en el ejemplo anterior: ¿cuál es el valor de this
set fullName(value)
? ¿Dónde están escritas las propiedades this.name
y this.surname
: en user
o admin
?
La respuesta es sencilla: los prototipos no afectan en absoluto a this
.
No importa dónde se encuentre el método: en un objeto o en su prototipo. En una llamada a un método, this
es siempre el objeto antes del punto.
Entonces, la llamada del configurador admin.fullName=
usa admin
como this
, no user
.
En realidad, eso es algo muy importante, porque podemos tener un objeto grande con muchos métodos y objetos que heredan de él. Y cuando los objetos heredados ejecuten los métodos heredados, modificarán sólo sus propios estados, no el estado del objeto grande.
Por ejemplo, aquí animal
representa un “almacenamiento de métodos” y rabbit
lo utiliza.
La llamada rabbit.sleep()
establece this.isSleeping
en el objeto rabbit
:
// el animal tiene métodos dejar animal = { caminar() { si (!this.isSleeping) { alerta(`yo camino`); } }, dormir() { this.isSleeping = verdadero; } }; deja conejo = { nombre: "Conejo Blanco", __proto__: animal }; // modifica conejo.isSleeping conejo.dormir(); alerta (conejo.está durmiendo); // verdadero alerta(animal.está durmiendo); // indefinido (no existe tal propiedad en el prototipo)
La imagen resultante:
Si tuviéramos otros objetos, como bird
, snake
, etc., heredados de animal
, también obtendrían acceso a los métodos de animal
. Pero this
en cada llamada al método sería el objeto correspondiente, evaluado en el momento de la llamada (antes del punto), no animal
. Entonces, cuando escribimos datos en this
, se almacenan en estos objetos.
Como resultado, los métodos se comparten, pero el estado del objeto no.
El bucle for..in
también itera sobre propiedades heredadas.
Por ejemplo:
dejar animal = { come: cierto }; deja conejo = { saltos: verdadero, __proto__: animal }; // Object.keys solo devuelve claves propias alerta(Objeto.claves(conejo)); // salta // for..in recorre las claves propias y heredadas for(let prop in conejo) alert(prop); // salta, luego come
Si eso no es lo que queremos y nos gustaría excluir las propiedades heredadas, hay un método integrado obj.hasOwnProperty(key): devuelve true
si obj
tiene su propia propiedad (no heredada) llamada key
.
Entonces podemos filtrar las propiedades heredadas (o hacer algo más con ellas):
dejar animal = { come: cierto }; deja conejo = { saltos: verdadero, __proto__: animal }; for(let prop en conejo) { let isOwn = conejo.hasOwnProperty(prop); si (es propio) { alert(`Nuestro: ${prop}`); // Nuestro: saltos } demás { alert(`Heredado: ${prop}`); // Heredado: come } }
Aquí tenemos la siguiente cadena de herencia: rabbit
hereda de animal
, que hereda de Object.prototype
(porque animal
es un objeto literal {...}
, por lo que es de forma predeterminada), y luego null
encima:
Tenga en cuenta que hay una cosa curiosa. ¿De dónde viene el método rabbit.hasOwnProperty
? No lo definimos. Al observar la cadena, podemos ver que el método lo proporciona Object.prototype.hasOwnProperty
. En otras palabras, se hereda.
…Pero ¿por qué hasOwnProperty
no aparece en el bucle for..in
como lo hacen eats
y jumps
, si for..in
enumera las propiedades heredadas?
La respuesta es simple: no es enumerable. Al igual que todas las demás propiedades de Object.prototype
, tiene una bandera enumerable:false
. Y for..in
solo enumera propiedades enumerables. Es por eso que esta y el resto de las propiedades de Object.prototype
no aparecen en la lista.
Casi todos los demás métodos de obtención de claves/valores ignoran las propiedades heredadas
Casi todos los demás métodos de obtención de claves/valores, como Object.keys
, Object.values
, etc., ignoran las propiedades heredadas.
Sólo operan sobre el objeto mismo. No se tienen en cuenta las propiedades del prototipo.
En JavaScript, todos los objetos tienen una propiedad [[Prototype]]
oculta que es otro objeto o null
.
Podemos usar obj.__proto__
para acceder a él (un captador/definidor histórico, hay otras formas que se cubrirán pronto).
El objeto al que hace referencia [[Prototype]]
se denomina "prototipo".
Si queremos leer una propiedad de obj
o llamar a un método y no existe, JavaScript intenta encontrarlo en el prototipo.
Las operaciones de escritura/eliminación actúan directamente sobre el objeto, no utilizan el prototipo (suponiendo que sea una propiedad de datos, no un definidor).
Si llamamos obj.method()
y el method
se toma del prototipo, this
todavía hace referencia obj
. Por tanto, los métodos siempre funcionan con el objeto actual incluso si se heredan.
El bucle for..in
itera sobre sus propiedades y las heredadas. Todos los demás métodos de obtención de clave/valor solo operan en el objeto mismo.
importancia: 5
Aquí está el código que crea un par de objetos y luego los modifica.
¿Qué valores se muestran en el proceso?
dejar animal = { saltos: nulo }; deja conejo = { __proto__: animal, saltos: verdadero }; alerta (conejo.jumps); // ? (1) eliminar conejo.jumps; alerta (conejo.jumps); // ? (2) eliminar animal.jumps; alerta (conejo.jumps); // ? (3)
Debería haber 3 respuestas.
true
, tomado de rabbit
.
null
, tomado de animal
.
undefined
, ya no existe tal propiedad.
importancia: 5
La tarea tiene dos partes.
Dados los siguientes objetos:
dejar cabeza = { vasos: 1 }; dejar tabla = { bolígrafo: 3 }; dejar cama = { hoja: 1, almohada: 2 }; dejar bolsillos = { dinero: 2000 };
Utilice __proto__
para asignar prototipos de manera que cualquier búsqueda de propiedades siga la ruta: pockets
→ bed
→ table
→ head
. Por ejemplo, pockets.pen
debe ser 3
(que se encuentra en table
) y bed.glasses
debe ser 1
(que se encuentra en head
).
Responda la pregunta: ¿es más rápido conseguir glasses
como pockets.glasses
o head.glasses
? Comparar si es necesario.
Agreguemos __proto__
:
dejar cabeza = { vasos: 1 }; dejar tabla = { bolígrafo: 3, __proto__: cabeza }; dejar cama = { hoja: 1, almohada: 2, __proto__: tabla }; dejar bolsillos = { dinero: 2000, __proto__: cama }; alerta( bolsillos.bolígrafo ); // 3 alerta (lentes de cama); // 1 alerta( tabla.dinero ); // indefinido
En los motores modernos, en cuanto al rendimiento, no hay diferencia si tomamos una propiedad de un objeto o de su prototipo. Recuerdan dónde se encontró la propiedad y la reutilizan en la siguiente solicitud.
Por ejemplo, en el caso de pockets.glasses
, recuerdan dónde encontraron glasses
(en head
) y la próxima vez buscarán allí mismo. También son lo suficientemente inteligentes como para actualizar los cachés internos si algo cambia, de modo que la optimización sea segura.
importancia: 5
Tenemos rabbit
que hereda de animal
.
Si llamamos a rabbit.eat()
, ¿qué objeto recibe la propiedad full
: animal
o rabbit
?
dejar animal = { comer() { this.full = verdadero; } }; deja conejo = { __proto__: animal }; conejo.comer();
La respuesta: rabbit
.
Esto se debe a que this
es un objeto antes del punto, por lo que rabbit.eat()
modifica rabbit
.
La búsqueda y ejecución de propiedades son dos cosas diferentes.
El método rabbit.eat
se encuentra primero en el prototipo y luego se ejecuta con this=rabbit
.
importancia: 5
Tenemos dos hámsters: speedy
y lazy
que heredan del objeto hamster
general.
Cuando alimentamos a uno de ellos, el otro también se llena. ¿Por qué? ¿Cómo podemos solucionarlo?
dejar hámster = { estómago: [], comer (comida) { this.stomach.push(comida); } }; vamos rápido = { __proto__: hámster }; deja perezoso = { __proto__: hámster }; // Este encontró la comida speedy.eat("manzana"); alerta( speedy.stomach ); // manzana // Este también lo tiene, ¿por qué? arreglar por favor. alerta (perezoso.estómago); // manzana
Veamos detenidamente lo que sucede en la llamada speedy.eat("apple")
.
El método speedy.eat
se encuentra en el prototipo ( =hamster
), luego se ejecuta con this=speedy
(el objeto antes del punto).
Entonces this.stomach.push()
necesita encontrar la propiedad stomach
y llamar push
sobre ella. Busca stomach
en this
( =speedy
), pero no encuentra nada.
Luego sigue la cadena del prototipo y encuentra stomach
del hamster
.
Luego llama push
, añadiendo la comida al estómago del prototipo .
¡Así que todos los hámsteres comparten un mismo estómago!
Tanto para lazy.stomach.push(...)
como speedy.stomach.push()
, la propiedad stomach
se encuentra en el prototipo (ya que no está en el objeto en sí), luego los nuevos datos se insertan en él.
Tenga en cuenta que tal cosa no sucede en el caso de una tarea simple this.stomach=
:
dejar hámster = { estómago: [], comer (comida) { // asigna a this.stomach en lugar de this.stomach.push this.stomach = [comida]; } }; vamos rápido = { __proto__: hámster }; deja perezoso = { __proto__: hámster }; // El veloz encontró la comida speedy.eat("manzana"); alerta( speedy.stomach ); // manzana // El estómago del perezoso está vacío alerta (perezoso.estómago); // <nada>
Ahora todo funciona bien, porque this.stomach=
no realiza una búsqueda de stomach
. El valor se escribe directamente en this
objeto.
También podemos evitar totalmente el problema asegurándonos de que cada hámster tenga su propio estómago:
dejar hámster = { estómago: [], comer (comida) { this.stomach.push(comida); } }; vamos rápido = { __proto__: hámster, estómago: [] }; deja perezoso = { __proto__: hámster, estómago: [] }; // El veloz encontró la comida speedy.eat("manzana"); alerta( speedy.stomach ); // manzana // El estómago del perezoso está vacío alerta (perezoso.estómago); // <nada>
Como solución común, todas las propiedades que describen el estado de un objeto en particular, como stomach
arriba, deben escribirse en ese objeto. Eso previene tales problemas.