Hoy hablamos sobre composición de objetos como estrategia para compartir comportamiento entre objetos de dominio, un tema sobre el que he hablado bastante y creo que es esencial entender para llevar tus aplicaciones Javascript al siguiente nivel.
El sistema de objetos de Javascript está basado en instancias, esto significa que podemos modelar software directamente con objetos en lugar de con blueprints (clases). Por ejemplo si tenemos esta instancia en nuestra aplicación para representar a un usuario:
Podemos darle comportamiento simplemente agregando funciones como propiedades a ese objeto:
Fácil y rápido. El problema es que esto no escala bien. Si mañana queremos representar a la usuaria Irene, tendremos que copiar y pegar las funciones anteriores en la nueva instancia, terminando con dos objetos con comportamientos idénticos.
Si por ejemplo las funciones que copiamos tenian bugs, los estaríamos copiando también :(
Además este tipo de objetos violan de facto el principio de única responsabilidad, al mezclar en una única estructura las propiedades de dominio (name, lastname, age) junto con la implementación de su comportamiento.
Una forma sencilla de solucionar este problema es utilizar MetaObjects, ya que se rigen por el princpio de separar las propiedades de dominio de su comportamiento. Así, podriamos definir el MetaObject Person:
Que simplemente define el comportamiento para un objeto teórico con al menos 3 propiedades: name, lastname y age.
De este modo — y habiendo sacado el comportamiento de los objetos de dominio — estos quedan reducidos a representar la información del modelo de nuestra aplicación:
Entonces Person es un MetaObject (o un mixin) que define un comportamiento que puede ser “mixeado” dentro de un objeto de dominio para enriquecerlo con funcionalidad.
Así y a fin de unir nuestra funcionalidad (definida en Person) con los objetos de dominio, podemos recurrir a una composición por concatenación usando Object.assign:
Object.assign(kike, Person);
Object.assign(irene, Person);
Por consiguiente:
kike.fullName() // ‘Kike Parra’
irene.updateAge(30);
irene.age // 30
Inicialización
Si bien es cierto que ir creando los objetos de nuestra aplicación con literal objects {}
es rápido y comprensible, no es demasiado práctico si contamos con cientos y cientos de instancias. Por este motivo urge un mecanismo que nos permita no ya crear objetos sino inicializarlos.
El método por excelencia para crear e inicializar instancias en Javascript ha sido siempre “la función constructora”:
Una función constructor es una función que dado un input nos inicializa y crea una nueva instancia. De esta manera ya no tenemos que ir definiendo nuestras usuarias directamente como literal objects, si no que podemos ir “construyendolos”.
Para poder usarlas, tenemos que recurrir a la palabra reservada “new
”:
const kike = new User(‘Kike’, ‘Parra’, 47)
Y de nuevo podríamos otorgarle la funcionalidad de una Persona, con una concatenación:
Object.assign(kike, Person);
Ahora bien, una función constructora no solamente nos da la posibilidad de inicializar instancias de dominio, sino que podemos dejar definido el comportamiento que van a tener las futuras instancias, y asi no tener que “mixear” el comportamiento después.
Por ello las funciones constructoras actuan como “blueprints” o planos que definen las propiedades y métodos que las futuras instancias tendrán.
Las funciones constructoras vienen con una propiedad llamada “prototype
” que nos permite definir el MetaObject de las instancias. Así cuando hacemos new, no solamente estamos inicializando la instancia, si no que también “mixeamos” el prototype definido sobre ella.
Sería como si internamente al hacer new el prototype fuese concatenado con la instancia final.
Encima, como el prototype
es un objeto, podemos usar concatenación también:
Object.assign(User.prototype, Person);
De esta forma las futuras instancias de User tendran las propiedades de dominio name, lastname y age, y además el comportamiento de Person.
Ahora y para entender bien los constructores tenemos que ¿esta propiedad prototype
.
¿Qué hay dentro de prototype?
Además de las funciones de comportamiento que definamos, dentro de prototype encontramos por defecto una propiedad llamada “constructor
” que contiene una referencia la función constructora.
Por tanto:
User.prototype.constructor === User // true
Está propiedad se puede modificar por lo que no es una buena estrategia usarla para checkear tipos.
Antes hemos comentado que cuando creamos una instancia a partir de un constructor, el prototípo de este es delegado sobre la misma, ya que cuando hacemos “new
” sobre una función constructora como User, obtenemos un nuevo objeto que posee una propiedad de solo lectura (__proto__
) que guarda la referencia a User.prototype
:
const kike = new User(‘Kike’, ‘Coba’);
kike.__proto__ === User.prototype // true
kike.__proto__.constructor === User // true
La clave aquí está en que cuando delegamos prototipos no se copian valores, sino referencias y es por tanto lo que interamente usa Javascript para que operadores como instanceof
funcionen. El problema es que estas propiedades se pueden cambiar en runtime por nosotros o por alguna librería externa — lo cual es problemático —:
const irene = new User(‘Irene’, ‘Parra’);
irene instanceof User // true
User.prototype = {}; // reasigno el prototype de User
irene instanceof User // false (Ups!)
¿Qué ha pasado? Pues que hemos asignado el prototype
de User a un objeto nuevo, pero la instancia de “irene” ha dejado su propiedad de solo lectura __proto__
apuntando al User.prototype
antiguo por lo que los checkeos basados en referencias se anulan. Por consiguiente, ni constructor
ni instanceof
son fiables a la hora de hacer este tipo de comprobaciones.
Para no perder referencias a
constructor
o al objeto original de prototype, es preferible agregar comportamiento al objeto existente. HacerUser.prototye = {}
por lo general es una mala práctica a menos que se haga justo después de la declaración del constructor, para evitar contaminar instancias existentes.
A excepción de estos problemas a la hora de hacer checkeos de tipos parece que las funciones constructoras son un mecanismo interesante para inicializar y construir instancias. El problema viene que para usarlos tenemos que hacer junto con el operador new
, que como veremos, presenta limitaciones.
Problemas con new
Como hemos comentado “new
” nos permite invocar funciones constructoras que crean e inicializan objetos en la misma fase. Este es la carácteristica más importante de este operador pero también su mayor desventaja. Por ejemplo imagina que tenemos esta constructora:
Si ahora queremos crear nuevas instancias de Point solo cuando x
e y
sean distintas de 0 y que en caso contrario devolver la referencia a una instancia previamente creada y llamada por ejemplo “origin” — la cual representa el punto (0,0) del eje de coordenadas —, pues no podemos hacerlo porque en el momento de inicializar (esto es darle valor a x
e y
) la instancia ya ha sido creada.
La idea esencial aquí es entender que new nos obliga a construir e inicializar la instancia en una sola fase y en ciertas situaciones queremos tener el control de construir o no basándonos en información de inicialización.
Una alternativa sería lanzar excepciones si no se cumplen unos requerimientos de construcción, pero no es muy amigable que un constructor levante una excepción.
Afortunadamente existen estrategias para separar la construcción de la inicialización, sin embargo como veremos no es perfecto.
Object.create para delegar prototypes
Existe una utilidad dentro de Javascript que nos permite crear objetos vacios con un prototípo delegado sin necesidad de usar la constructora. Esta utlidad es Object.create y tiene la siguiente anotación:
Object.create :: Object → Object
Dado un objeto A devuelve un nuevo objeto B con el objeto A delegado, es decir con la referencia al objeto A en la propiead __proto__
que se encuentra en el Objeto B.
A efectos prácticos hace la misma operación que “new” pero no llama a la función constructora, por tanto no inicializa el objeto:
Veamos ahora que pasa cuando usamos instanceof
:
irene instanceof User // true
irene
es considerada una instancia de User aún sabiendo que no ha sido construida usando el constructor User.
Y es que según lo que hemos he estado viendo, el que sea construido de una forma u otra da igual, siempre y cuando el valor de la propiedad __proto__
sea una referencia al prototipo de la constructora.
Por tanto con esto hemos conseguido separar la construcción de la inicialización pero hemos perdido la habilidad de inicializar y por tanto ninguna de sus propiedades han sido establecidas:
irene.name === undefined // true
Para ahora poder inicializar la instancia, contamos con dos estrategias.
Patrón inicializador
Una forma sencilla de recuperar la habilidad de inicializar es haciendo uso del archiconocido Patrón inicializador. Este consiste básicamente en proporcionar al prototype
una función init para realizar la inicialización posterior a la creación de la instancia:
Usar la función constructora como una función normal
Otra alternativa más avanzada sería reutilizar la función constructora pero aplicarla de forma tardía sobre la instancia jugando con el contexto:
La función constructora es una función normal que espera que
this
guarde una referencia a la instancia. Haciendo uso de utilidades como call, apply o bind podemos modificar ese contexto y así recuperar el poder inicializador de un constructor.
Vale, muy bien. Hemos conseguido separar en 2 fases la creación de la inicialización pero seguimos necesitando la instancia para inicializarla.
¿Qué ocurre si no quiero tener ni siquiera instancia en base a la inicialización (para no representar un estado no válido, por ejemplo) o cambiar la instancia completamente según ciertas condiciones? La respuesta a estas preguntas la encontraremos en el patrón factoría.
Factory pattern
Cualquier función en Javascript puede devolver una instancia nueva sin pasar por una función constructora, ya sea creándolo con un literal object {}
o haciendo uso de Object.create
(sin argumentos). Con esta premisa podemos definir funciones que se encarguen de crear objetos en base a ciertas condiciones. Estas funciones se conocen como factorías y tienen multiples ventajas, entre las que destaco la habilidad de agregar una facade sobre la construcción ya sea mediante el operador “new
” u otros mecanismos.
Introduzcamos este concepto en nuestro ejemplo de crear un nuevo Point solo si x
e y
son distintos de 0:
De este modo hemos ganado el control de decidir el cómo nuestras intancias son creadas. Esta técnia es muy usada por ejemplo cuando queremos tener un pool de objetos de tal forma que dentro de la factoría vamos llevando un registro de las instancias que estan activas, cuando una deja de servir en lugar de destruirla la volvemos a meter en el pool y así el siguiente que necesite una instancia podemos asignársela sin tener que crear una nueva.
Reduciendo el uso de new
Si cualquier función puede devolver una nueva instancia sin necesidad de usar new, ¿para que nos sirve entonces?.
Cuando creamos objetos usando new decimos que estamos usando la forma prototípica de creación de objetos, esto quiere decir que el efecto de construir no es otro que crear un objeto vacio y delegar el prototype del constructor sobre el. La clave aquí está en la palabra delegar, ya que cuando delegamos lo que hacemos es copiar referencias y no valores. Esto es: las instancias creadas a partir de un constructor (usando new
o Object.create
) tienen en su propiedad __proto__
una referencia a la misma instancia que el prototipo del constructor.
Es como si delego mi dinero sobre un inversor. Este invierte mi dinero en lo fondos que el crea conveniente. No copio mi dinero (ojalá) si no que literalmente delego la referencia a el sobre mi inversor.
En otras palabras, el comportamiento y propiedades definidas en el prototípo de una función constructora se comparte entre todas las instancias, convirtiendo a “new”
una forma de crear e inicializar objetos más eficiente — en cuanto a memoria se refiere — que crear objetos por factorías. Veámos un ejemplo:
Cada vez que creamos un User usando la factoría createUser estamos creando una nueva función fullName.
Para pocas instancias y no muchas funciones, este problema puede ser irrelevante, pero en aplicaciones complejas usar factorías de esta forma viene con un desperdicio de memoria. Porque aunque irene.fullName
y kike.fullName
sean funciones idénticas, en memoria habrá reservado un espacio para cada una.
En cambio si realizamos esta creación de la forma prototípica podremos reutilizar la misma referencia a las funciones entre todas las instancias:
No obstante el uso de new como hemos visto viene con la problemática de que no podemos controlar la creación y la inicialización (aun pudiendo separarlas), un precio que los desarrolladores rara vez estamos dispuetos a pagar.
¿Estaría bien que pudiese existir una forma de obtener el beneficio de usar new sin perder el control de la instanciación y poder usar factorías en lugar de recurrir a este operador, verdad?.
La respuesta la podemos encontrar a continuación como colofón a este artículo ya que une las estrategias más importantes que hemos introducido hoy.
Factorías y mixins
Al prinpio del artículo hemos visto como podemos separar el comportamiento de las información de dominio haciendo uso de MetaObjects, unos objetos que simplemente aunan comportamiento y que después son concatenados a objetos de dominio.
También hemos mencionado que esta concatenación se basa en la copia de referencias y no de valores, por lo que podriamos aprovecharnos de esto para reutilizar comportamiento sin tener que desperdiciar memoria. Así podríamos obtener:
Una forma de inicializar literal Objects.
Una forma de construirlos basados en cierta lógica.
Separación de responsabilidades, al tener el comportamiento y los datos en sitios distntos.
Reutilización de funciones.
Para ello necesitamos:
Una factoría que cree objetos en función de info inicial.
Una factoría que dada una lógica devuelva nuevas instancias.
Un meta object que defina el comportamiento de futuros objetos.
Un mecanismo para poder unirlo todo.
Mucha cafeína.
Vamos a ir paso a paso con el ejemplo de Point que hemos introducido antes.
Factoría Point
Ya los hemos visto: cualquier función que dado un input devuelva un nuevo objeto:
Ahora tenemos una función que dado valores para x e y, nos devuelve un nuevo objeto con las propiedades x e y.
Factoría createPoint
Ahora y en aras de añadir una facade que nos permita agregar lógica de construcción, vamos a crea una factoría que nos devuelva nuevos puntos en el caso en que x e y no sean 0, ya que entonces devolveremos siempre una instancia al punto de origen (que como solo hay un origen, solo hay una instancia que lo representa):
El Meta Object
Finalmente necesitamos un objeto que defina comportamiento. Este objeto no tiene una relación directa con Point pero si que espera funcionar sobre un objeto de dominio que al menos tenga una propiedad x
y una propiedad y
. Por tanto aunque no exista una relación entre el Meta Object y Point si que existe una dependencia implicita al esperar el primero un Point o algo que se le parezca (esto es, tenga las mismas propiedades):
Y para rematar con el Object.assign
ya tendriamos nuestra composición eficiente en memoria y sin rastro de new. Por contra decir que como podemos ver tenemos que hacer manualmente la composición por cada instancia, esto es muy flexible ya que podemos decidir que instancias mixean Point2DProto y cuales no, pero es más largo de escribir.
Una opción podría ser llevarnos el assign a la factoría, pero perderiamos la flexibilidad de por ejemplo mixear Point2DProto a un Point3D en el momento de su creación.
Adicionalmente y si sabemos que todos nuestros objetos Point van a tener la funcionalidad definida en Point2DProto podemos recurrir a una delegación de ese prototype en el momento de la creación:
Así todos los puntos creados con esta factoría ya tendrán la funcionalidad definida en Point2DProto. Esto sería similar a hacer una herencia tradicional, por lo que a efectos taxonómicos podriamos decir que un Point es un Point2D.
Breve apunte sobre las clases
Supongo que a lo largo de todo el artículo te habrás estado preguntado: si, pero yo trabajo con clases, ¿qué pasa con ellas?
Dado que las clases son azúcar sintáctico sobre las funciones constructoras —que espero que ya sepas que son— todos los ejemplos que hemos visto hoy son extrapolables a las clases. Podemos definir funcionalidad en metaobject y después mixearla sobre clases o instancias creadas con new.
Recuerda que tanto para funciones constructoras como para clases, el uso de new es obligatorio, por lo que puedes aplicar las técnicas comentadas más arriba para reducir sus problemas así como usarlos única y exclusivamente dentro de factorías.
Conclusión
Gracias por leer hasta aquí. Quizá un artículo un poco largo, pero para posts rápidos y sin sabor ya tenemos medium.
Espero que ahora tengas un conocimiento más profundo sobre como técnicas de composición sencillas pueden hacer que tu código se vuelva más robusto y escalable. Además espero que hayas visto que hay vida más allá de la herencia de clases (la técnica por escelencia de composición) y te hayas animado a ponerlo en práctica en tu trabajo.
Comúnmente lidiamos día a día con mecanismos bastante complicados dentro del ecosistema de Javascript (async/await, proxys, promises…) y rara vez dedicamos el tiempo necesario para interiorizar y entender los básicos. Te invito a que repliques todos los ejemplos vistos aquí, hagas pruebas y llegues a tus propias conclusiones.
Nada más por hoy. Si teneís cualquier comentario/duda/sugerencia podeís dejar un comentario por aquí o en las redes.
Buena semana :)
Muy bueno Pablo! Se agradecen mucho este tipo de artículos tan bien explicados :)
Wow ! Excelente aporte :).... muchas gracias por compartirlo