Herencia Prototípica en JavaScript
En programación, a menudo queremos tomar un objeto existente y extenderlo para crear nuevos objetos con funcionalidades adicionales.
Por ejemplo, consideremos un objeto usuario
con varias propiedades y métodos. Ahora, queremos crear objetos admin
e invitado
que sean versiones ligeramente modificadas de usuario
. En lugar de duplicar o reimplementar los métodos de usuario
, preferimos reutilizarlos y construir los nuevos objetos sobre la base existente.
La herencia prototípica es una característica del lenguaje JavaScript que nos ayuda a lograr esto.
[[Prototype]]
En JavaScript, los objetos tienen una propiedad especial oculta llamada [[Prototype]], que puede ser null o apuntar a otro objeto llamado “prototipo”:
Cuando intentamos acceder a una propiedad de un objeto, si JavaScript no la encuentra en el objeto, buscará automáticamente en su prototipo. Este mecanismo se llama «herencia prototípica». Estudiaremos numerosos ejemplos de esta herencia y otras características interesantes del lenguaje que se basan en ella.
La propiedad [[Prototype]] es interna y no visible directamente, pero hay varias formas de establecerla.
Una manera es utilizar el nombre especial proto, así:
let animal = {
come: true
};
let conejo = {
salta: true
};
conejo.__proto__ = animal; // establece conejo.[[Prototype]] = animal
Si buscamos una propiedad en conejo
y no la encuentra, JavaScript la buscará automáticamente en animal
.
Por ejemplo:
let animal = {
come: true
};
let conejo = {
salta: true
};
conejo.__proto__ = animal; // (*)
// Ahora podemos encontrar ambas propiedades en conejo:
alert(conejo.come); // verdadero (**)
alert(conejo.salta); // verdadero
En este ejemplo, la línea (*) establece que animal
es el prototipo de conejo
. Luego, cuando intentamos leer la propiedad conejo.come
(**), no se encuentra en conejo
, por lo que JavaScript sigue la referencia [[Prototype]] y la encuentra en animal
(la búsqueda se realiza de abajo hacia arriba).
Podemos decir que «animal es el prototipo de conejo» o que «conejo hereda prototípicamente de animal».
Si animal
tiene muchas propiedades y métodos útiles, estos estarán automáticamente disponibles en conejo
. Estas propiedades se llaman “heredadas”.
Si tenemos un método en animal
, se puede llamar en conejo
:
let animal = {
come: true,
caminar() {
alert("El animal camina");
}
};
let conejo = {
salta: true,
__proto__: animal
};
// caminar es tomado del prototipo
conejo.caminar(); // El animal camina
El método se toma automáticamente del prototipo:
La cadena prototipo puede ser más larga:
let animal = {
come: true,
caminar() {
alert("El animal camina");
}
};
let conejo = {
salta: true,
__proto__: animal
};
let orejasLargas = {
longitudOrejas: 10,
__proto__: conejo
};
// caminar se toma de la cadena prototipo
orejasLargas.caminar(); // El animal camina
alert(orejasLargas.salta); // verdadero (desde conejo)
Si intentamos leer algo de orejasLargas
y no se encuentra, JavaScript buscará en conejo
, y luego en animal
.
Limitaciones:
- No puede haber referencias circulares. JavaScript arrojará un error si intentamos asignar proto en círculo.
- El valor de proto puede ser un objeto o null. Otros tipos son ignorados.
- Un objeto solo puede heredar de un prototipo, no de múltiples.
proto es un getter/setter histórico para [[Prototype]]
Es un error común de principiantes no distinguir entre ambos. La propiedad proto no es lo mismo que la propiedad interna [[Prototype]]. En su lugar, proto es un getter/setter para [[Prototype]]. Veremos situaciones en las que esta diferencia es importante más adelante. Por ahora, solo tengámoslo en cuenta mientras comprendemos JavaScript.
La propiedad proto es antigua y existe por razones históricas. Los navegadores y entornos del lado del servidor continúan soportándola, por lo que su uso es bastante seguro. Sin embargo, se recomienda el uso de las funciones Object.getPrototypeOf y Object.setPrototypeOf para obtener y establecer el prototipo.
La notación proto es más intuitiva, por lo que la utilizaremos en los ejemplos.
Operaciones de Escritura y Eliminación
El prototipo solo se usa para leer propiedades. Las operaciones de escritura/eliminación funcionan directamente con el objeto.
En el siguiente ejemplo, asignamos su propio método caminar
a conejo
:
let animal = {
come: true,
caminar() {
/* este método no será utilizado por conejo */
}
};
let conejo = {
__proto__: animal
};
conejo.caminar = function() {
alert("¡Conejo! ¡Salta, salta!");
};
conejo.caminar(); // ¡Conejo! ¡Salta, salta!
De ahora en adelante, la llamada conejo.caminar()
encuentra el método inmediatamente en el objeto y lo ejecuta, sin usar el prototipo.
Las propiedades de acceso son una excepción, ya que la asignación es manejada por una función setter. Por lo tanto, escribir en una propiedad de este tipo es como llamar a una función.
Por esa razón, admin.nombreCompleto
funciona correctamente en el siguiente código:
let usuario = {
nombre: "John",
apellido: "Smith",
set nombreCompleto(valor) {
[this.nombre, this.apellido] = valor.split(" ");
},
get nombreCompleto() {
return `${this.nombre} ${this.apellido}`;
}
};
let admin = {
__proto__: usuario,
esAdmin: true
};
alert(admin.nombreCompleto); // John Smith (*)
// ¡Dispara el setter!
admin.nombreCompleto = "Alice Cooper"; // (**)
alert(admin.nombreCompleto); // Alice Cooper, estado de admin modificado
alert(usuario.nombreCompleto); // John Smith, estado de usuario protegido
Aquí, en la línea (*), la propiedad admin.nombreCompleto
tiene un getter en el prototipo usuario
, entonces se llama. Y en la línea (**), la propiedad tiene un setter en el prototipo, por lo que se llama.
El valor de “this”
Puede surgir una pregunta interesante: ¿cuál es el valor de this
dentro de set nombreCompleto(valor)
? ¿Dónde están escritas las propiedades this.nombre
y this.apellido
: en usuario
o en admin
?
La respuesta es simple: “this” no se ve afectado por los prototipos en absoluto.
No importa dónde se encuentre el método: en un objeto o su prototipo. En una llamada al método, this
siempre se refiere al objeto antes del punto.
Entonces, la llamada al setter admin.nombreCompleto=
usa a admin
como this
, no a usuario
.
Eso es realmente importante, porque podemos tener un gran objeto con muchos métodos y objetos que hereden de él. Y cuando los objetos heredados ejecutan los métodos heredados, modificarán solo sus propios estados, no el estado del gran objeto.
Por ejemplo, aquí animal
representa un “método de almacenamiento”, y conejo
lo utiliza.
La llamada conejo.dormir()
establece this.durmiendo
en el objeto conejo
:
let animal = {
caminar() {
if (!this.durmiendo) {
alert(`Yo camino`);
}
},
dormir() {
this.durmiendo = true;
}
};
let conejo = {
nombre: "Conejo Blanco",
__proto__: animal
};
// modifica conejo.durmiendo
conejo.dormir();
alert(conejo.durmiendo); // Verdadero
alert(animal.durmiendo); // undefined (no existe tal propiedad en el prototipo)
Si tuviéramos otros objetos (como pajaro
, serpiente
, etc.) heredados de animal
, también tendrí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.
Bucle for…in
El bucle for..in
también itera sobre las propiedades heredadas.
Por ejemplo:
let animal = {
come: true
};
let conejo = {
salta: true,
__proto__: animal
};
// Object.keys solo devuelve claves propias
alert(Object.keys(conejo)); // salta
// for..in recorre las claves propias y heredadas
for (let prop in conejo) alert(prop); // salta, luego come
Si no queremos eso y queremos excluir las propiedades heredadas, hay un método incorporado obj.hasOwnProperty(key)
(donde “Own” significa “Propia”): devuelve true si obj
tiene la propiedad interna (no heredada) llamada key
.
Entonces, podemos filtrar las propiedades heredadas (o hacer algo más con ellas):
let animal = {
come: true
};
let conejo = {
salta: true,
__proto__: animal
};
for (let prop in conejo) {
let esPropia = conejo.hasOwnProperty(prop);
if (esPropia) {
alert(`Es nuestra: ${prop}`); // Es nuestra: salta
} else {
alert(`Es heredada: ${prop}`); // Es heredada: come
}
}
quí tenemos la siguiente cadena de herencia: conejo
hereda de animal
, que hereda de Object.prototype
(porque animal
es un objeto literal {…}, entonces por defecto hereda de Object.prototype
), y luego null
encima de él.
Observa algo curioso. ¿De dónde viene el método conejo.hasOwnProperty
? No lo definimos. Mirando la cadena, podemos ver que el método es proporcionado por Object.prototype.hasOwnProperty
. En otras palabras, se hereda.
Pero, ¿por qué hasOwnProperty
no aparece en el bucle for..in
como come
y salta
, 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 la bandera enumerable: false
. Y for..in
solo enumera las propiedades enumerables. Es por eso que este y el resto de las propiedades de Object.prototype
no están en la lista.
Casi todos los demás métodos de obtención de valores/claves ignoran las propiedades heredadas.
Solo operan en el objeto mismo. Las propiedades del prototipo no se tienen en cuenta.
Resumen
En JavaScript, todos los objetos tienen una propiedad oculta [[Prototype]], que es: otro objeto, o null. Podemos usar obj.__proto__
para acceder a ella (un getter/setter histórico, también hay otras formas que se cubrirán pronto). El objeto al que hace referencia [[Prototype]] se denomina “prototipo”. Si en obj
queremos leer una propiedad o llamar a un método que no existen, entonces JavaScript intenta encontrarlos en el prototipo. Las operaciones de escritura/eliminación actúan directamente sobre el objeto, no usan el prototipo (suponiendo que sea una propiedad de datos, no un setter). Si llamamos a obj.metodo()
, y metodo
se toma del prototipo, this
todavía hace referencia a obj
. Por lo tanto, los métodos siempre funcionan con el objeto actual, incluso si se heredan. El bucle for..in
itera sobre las propiedades propias y heredadas. Todos los demás métodos de obtención de valores/claves solo operan en el objeto mismo.