Continuamos esta serie sobre objetos en Javascript. Si en el capítulo anterior definimos un objeto y sus componentes, hoy hablaremos de una de las cosas que más controversia genera, ya que entre que unos los explican de una forma y otras malversan sus casos de uso, acabamos con un cacao importante.
Primero decir que los conceptos “prototipar objetos”, “función prototipada” o “prototipar sobre una función” no existen como tal por mucho que se repita en cursos y tutoriales. Y es que el primer problema que las desarrolladoras se encuentran cuando quieren aprender estos conceptos, es que no hay una clara estandarización sobre la nomenclatura.
Hoy despejaremos dudas y hablaremos sobre los casos de uso reales de estos mecanismos, pero ya os adelanto de que son bastante limitados. En la gran mayoría de casos de uso en los que estaréis hoy involucrados, las técnicas de herencia por prototipos no deberían de usarse y mucho menos en el mundo del frontend.
¿Qué es un prototipo?
Esta pregunta la he hago en casi todas las entrevistas para puestos de desarrolladores Javascript que requieren cierta experiencia. Confieso que la respuesta correcta o incorrecta no modifica el devenir de la entrevista a menos que el entrevistado haya reclamado ser un experto en la materia, por el cual considero que una persona que se erige a sí misma como experta, tiene que saber que es un prototype, lo use o no.
Las respuestas que suelo obtener a esta pregunta son muy variopintas lo cual confirma lo que comentaba antes de que no hay un estándar en la nomenclatura. Al final la persona hace un ejercicio de evocación para situarse en algún momento pasado donde tuvo que usar prototipos y de ahí generar una respuesta de por dónde pueden ir los tiros, con más o menos acierto.
Un prototipo es un objeto. Nada más y nada menos. Pero se le suele llamar prototipo en el momento en el que pasa a estar delegado en uno o más objetos. Esto quiere decir que no hay diferencias en sintaxis entre un prototipo y un objeto normal, sino que depende del uso que le estemos dando se le connota con el apelativo de Prototype.
const person = { name: "Ana" };
Lo que acabamos de definir es un objeto normal. En ¿qué casos podríamos hablar de person
como un prototype? En el momento en el que esté delegado sobre otro:
const ana = Object.create(person);
ana.name === 'Ana' // true
La función Object.create crea un objeto nuevo, delegando sobre este el objeto (prototipo) que le especifiquemos. O dicho de forma directa: Object.create crea un objeto vacío cuya propiedad de SOLO LECTURA guarda una referencia a person
:
ana.__proto__ === person // true
He puesto lo de solo lectura en mayúsculas porque todavía hay personas que evidentemente no están suscritos a esta newsletter y que cogen la propiedad
__proto__
y la igualan a cosas con total impunidad. Ignorando el aviso en rojo bien grande que hay en la documentación.
Lamentablemente este mal uso del lenguaje, se sigue enseñando en cursos en la actualidad.
Bueno vale, pero y esto ¿para qué sirve? y ¿por qué si hago ana.name
funciona? si obviamente ana
no ha definido ninguna propiedad. La respuesta está en otro concepto muy malinterpretado: La cadena de prototipos.
Objetos en cadena
Hemos dicho que un objeto puede tener una propiedad __proto__
que guarde la referencia a otro objeto. Y ese objeto a la vez puede tener una propiedad __proto__
que guarde la referencia a otro, y así.
¿Veis por donde voy? ¿no?. No pasa nada: ¡Drawing time!
Y todavía más bonito:
Una imagen vale más que mil descripciones rarunas sobre qué son los prototipos en cadena.
Vale, entonces los objetos están encadenados a otros objetos mediante la propiedad __proto__
, eso querrá decir que de una forma u otra desde un objeto podré acceder a las propiedades de otro, ¿no?. Exacto Lisa.
Ese mecanismo está implícitamente implementado en cómo se acceden a las propiedades de los objetos. El procedimiento es el siguiente:
Dado un objeto A, accedemos a una propiedad ‘name’ a través de la notación punto:
A.name
.Internamente Javascript intentará primero buscar esta propiedad en el propio objeto A.
Si la encuentra, devolverá su valor. Si no la encuentra, irá a buscarla al objeto enlazado en la propiedad
__proto__
.Y ahora iteraremos sobre el paso 3 hasta que la propiedad se encuentre o hasta que
__proto__
seanull
, en cuyo caso devolveráundefined
ya que no se ha conseguido encontrar la propiedad ‘name’.
Veámoslo ahora con un ejemplo:
const person = {
name: "Pedro",
};
const admin = Object.create(person);
const client = Object.create(admin);
client.id = 2;
admin.age = 29;
client.age === 29 // true. Propiedad encontrada en admin
client.name === "Pedro" // true. Propiedad encontrada en person
client.id === 2 // true. Propiedad encontrada en client
Hemos establecido una cadena entre person → admin → client y después aprovechándonos del mecanismo que hemos descrito arriba, hemos ido accediendo las diferentes propiedades definidas en los objetos de la cadena.
Prevalencia en la cadena
¿Qué ocurre ahora si dentro de un objeto que forma parte de una cadena, intentamos sobrescribir el valor de una propiedad?
const person = {
name: "Pedro",
};
const admin = Object.create(person);
const client = Object.create(admin);
client.name = "Pablo";
Lo que sucede es que dentro del objeto client
se creará una nueva propiedad ‘name’ con el valor ‘Pablo’. De tal forma que la próxima vez que intentemos hacer client.name
, encontrará primero la propiedad ‘name’ en el propio objeto client
, por lo que devolverá ‘Pablo’ y mantendrá la propiedad original de person.name
con su valor original.
const person = {
name: "Pedro",
};
const admin = Object.create(person);
const client = Object.create(admin);
client.name = "Pablo";
client.name === "Pablo" // true
person.name === "Pedro" // true
Así podemos compartir las propiedades de los objetos en cadena hasta que decidamos sobrescribir alguna para un objeto en particular. Entonces tendremos esa propiedad definida a nivel de nuestro objeto sin cambiar los valores de las propiedades en la cadena.
Importante que esto solo funciona cuando reasignamos una propiedad a otra cosa. Si por ejemplo dentro de
person
tuviésemos una propiedadportfolio
que guardase la referencia a un objeto, tendríamos que cambiar toda la propiedad por otra cosa. No pudiendo hacer por ejemploclient.portfolio.id = 2
, porque estaríamos cambiando la referencia aportfolio
deperson
y podríamos tener problemas.
Cuando hablamos de las propiedades de un objeto en contraposición con las propiedades de los objetos en la cadena, nos referimos a las propiedades propias (own properties) y estas son las únicas enumerables — recuerda del artículo anterior que estas propiedades son las que se ven afectadas por los mecanismos de iteración, como el que ocurre dentro de un console.log
cuando intentamos imprimir objetos —. Así, mira lo que pasa cuando intentamos imprimir por consola el objeto client del ejemplo anterior:
console.log(client); // { name: "Pablo" }
Solo nos muestra las own properties, quedando ocultas las propiedades definidas por los objetos en cadena. De hecho, puede darse el caso de que el objeto no tenga ninguna propiedad propia y al imprimirlo nos devuelva un objeto vacío.
Por eso hay que tener cuidado con utilidades que intentan discernir entre objetos vacíos o no, mirando el número de propiedades — ya sea usando Object.keys o Object.getOwnPropertyNames — ya que podrían incurrir en falsos negativos al no tener en cuenta las propiedades de los objetos en cadena. Hay que tener cuidado.
Finalmente, todos los objetos tienen por defecto un prototipo delegado, este es: Object.prototype
y la única forma de crear un objeto sin prototipo en cadena es usando Object.create(null)
.
Delegación
La delegación de prototipos es el mecanismo por el cual se implementa la herencia en Javascript. Incluso las clases están basadas en prototypes de ahí que el uso de clases esté desaconsejado como veremos más adelante.
En términos de composición, la concatenación siempre nos ofrece mucha más flexibilidad que una delegación. Sin embargo, esta última es más eficiente a nivel de memoria al compartir instancias entre objetos. Así la relación por la que este mecanismo de composición se define es: many to one o muchos a uno: Un objeto — o prototype, recuerda que es lo mismo — puede ser delegado sobre muchos objetos. Pero un objeto solo puede tener delegado un único prototype.
De esta lectura sacamos que no hay delegación o herencia múltiple a diferencia de la relación many to many o muchos a muchos que se establece con una composición por concatenación.
const person = {
getName() {
return this.name;
},
homepage: 'http://people.es',
};
const paco = Object.create(person);
const ana = Object.create(person);
paco.name = 'Paco';
ana.name = 'Ana';
console.log(ana.getName()); // 'Ana'
console.log(paco.homepage); // 'http://people.es'
Recuerda que los objetos pueden almacenar cualquier tipo de valor en sus propiedades y que cuando delegamos lo hacemos sobre todas las propiedades, por tanto, puedo compartir ya sea propiedades primitivas o no primitivas — como funciones —.
En el ejemplo anterior estamos delegando el prototipo de person sobre ana y paco. Delegar es prestar no copiar. Ambos, ana
y paco
guardan en su propiedad __proto__
una referencia al objeto original person
. Cualquier cambio en person
al hablar de instancias, afectaría a todos los objetos donde person esté delegado pudiendo solo ana
y paco
sobrescribir propiedades mediante los mecanismos que hemos descrito en las secciones anteriores.
Esto evidentemente viene con una optimización espacial — de espacio en memoria, no de naves espaciales — mucho mayor que la concatenación (donde literalmente se copiaban primitivas y referencias) pero también implica un uso más responsable sobre todo cuando intentamos mutar referencias guardadas en los prototipos padres que están delegados sobre cientos de instancias. Los bugs pueden aparecer en cadena al quedar anulada la seguridad de la instancia.
const portfolio = {
currency: {
euros: 1000
}
};
const ana = Object.create(portfolio);
const irene = Object.create(portfolio);
ana.currency.euros = 0;
irene.currency.euros === 1000 // false. UPS!
Así vemos como dos objetos que operan simultáneamente sobre la referencia de un prototipo en cadena pueden afectarse mutuamente provocando que ana
e irene
compartan el estado — en este caso dinero — y que los cambios de una afecten a la otra. Esto de forma habitual no es lo que queremos hacer, por eso la delegación está desaconsejada por no asegurar la seguridad de la instancia — esto es, mantener el estado de la instancia en la instancia —.
Por eso es un error muy común para programadores que migran de otros lenguajes el ver las clases — o funciones constructoras — como clases en herencia tradicional donde estas actúan como blueprints y que si pueden ser cambiadas en runtime se hace mediante procedimientos de reflexión explicitas y no por defecto de forma implícita para el programador.
Delegación como optimización
Sin embargo, me parece un error demonizar funcionalidades y mucho más sin entenderlas. Y es que la delegación tiene un caso de uso concreto donde tiene sentido y que es importante conocer por si se nos plantea. Hay veces en que tener un estado compartido entre múltiples instancias es lo que queremos. Por ejemplo, en el ejemplo fatídico anterior donde ana
e irene
podrían arruinarse mutuamente cobra sentido si son inversoras trabajando sobre un mismo fondo de inversión. No tendría sentido en ese caso que ambas tuvieran acceso a copias diferentes del mismo fondo, si no que necesitan tener delegado el mismo fondo sobre ellas para poder operar sobre él.
Otro caso de uso ya lo hemos dejado ver más atrás. Si necesitamos desesperadamente optimizar la memoria del usuario, este mecanismo de composición será probablemente la clave. Un ejemplo real de esto podría ser una aplicación 2D o 3D con millones de objetos colisionando unos con otros sobre el viewport, donde tener instancias distintas de estos objetos comprometería seriamente a la memoria.
No obstante, con delegación ahorraríamos mucho espacio al poder compartir todas las funciones, texturas, etc. y cambiar solo la información que necesitemos — como la x
y la y
—.
A este nivel la delegación ya sea por Object.create o por extends viene con una optimización en los runtimes más utilizados como V8.
Delegación para polyfills
Un lugar donde es bastante común recaer en la delegación de prototypes son los polyfills ya que es muy fácil agregar funcionalidad y nuevas API a navegadores no compatibles modificando los prototypes base de las instancias. Así por ejemplo si quiero agregar la función map a todos los Arrays ya creados y por crear en navegadores que no lo implementen por defecto, pues solo tengo que hacer:
Array.prototype.map = function () {
// implementar map
}
Hay que huir por favor de la enorme tentación de meter cosas que no sean polyfills en los prototypes nativos. Si lo haces estás incurriendo en un delito de prototype pollution, castigado con hasta 2 años sin tener acceso a un teclado para programar, multas de hasta 36 millones de pesetas e inhabilitación especial de hasta 5 años.
— si lo has leído con esta voz, tienes ya una edad xd —
Deprecar la delegación
Fuera de los casos de arriba, yo huiría seriamente de la delegación y uso de prototypes limitando el uso de Object.create y me centraría en mecanismos más flexibles. Recuerda que optimizar por defecto es contraproducente en la mayoría de casos y todavía no me he encontrado en mi trabajo un claro ejemplo — fuera de la experimentación — donde haya que usar delegación para resolver algún problema.
Herencia de clases o instancias es un mecanismo de composición, pero no el único y tampoco el mejor, presenta problemas importantes de acople por definición — todas las clases hijas u objetos hijos, están acoplados al prototipo o clase padre —.
Es más, muchos profesionales están apuntando en la misma dirección y sin irme muy lejos, React promueve enfoques de composición sobre herencia en todos los casos:
At Facebook, we use React in thousands of components, and we haven’t found any use cases where we would recommend create component inheritance hierarchies.
En Facebook, usamos React en miles de componentes y nunca nos hemos encontrado ningún caso de uso donde recomendaríamos crear jerarquías heredadas de componentes.
En cambio, si usas AngularJS pues no te va a quedar más narices que usar Clases y delegación, puesto que todo el framework está construido sobre Clases. Si es tu caso, espero que el artículo de hoy te haya ayudado a interiorizar los conceptos.
Conclusión
Probablemente muchos hayáis llegado aquí sin tener muy claro qué son los prototypes por la sencilla de razón de que no los usáis. Si es así, no es problema, estáis en el camino correcto ya que como he comentado el uso de la delegación es y debería de ser muy de nicho.
No obstante, si estáis en la aventura de aprender Javascript o de masterizarlo, necesitaréis entenderlos, aunque sea para saber cuándo y por qué no usarlos.
Si alguien en algún momento te dice: hay que hacer delegación aquí, o hay que usar una función prototipada o cualquier otra conjugación extraña con el verbo prototipar, duda. Y hazte preguntas de sí estáis en el caso de uso correcto, porque spoiler: probablemente no.
Nada más por hoy :).