Encadenando prototipos de forma clásica

Usar Object.create() para crear objetos no tiene en principio ninguna clara ventaja frente al uso de las funciones constructoras como hemos visto en el tema anterior. Pero si que puede mejorar la eficiencia cuando lo utilizamos para el encadenamiento de prototipos. En el siguiente ejemplo tenemos objetos que generamos con funciones constructoras y luego encadenamos prototipos para que adquieran las propiedades y métodos de los que heredan. Se trata, a modo de ejemplo, de crear unos perfiles para almacenar datos de personas.

Ejemplo:

 

Cadena de prototipos

El constructor Persona() crea instancias de una persona guardando su nombre. Se agrega un método con el prototipo para ver() cómo es, iterando por las propiedades para mostrarlas en el cuadro de mensajes. Creamos nuevos constructores para almacenar los perfiles. Así un Empleado() tendrá una propiedad para saber en que trabaja. O para un Estudiante() necesitaremos saber que estudia. Hasta ahora estos son constructores. Empezamos a recopilar datos. Por ejemplo, la frase "Juan trabaja de obrero" nos da el perfil de un Empleado. Como un empleado es una persona entonces hacemos Empleado.prototype = new Persona("Juan"), encadenando el prototipo de Empleado con el de Persona. Luego con var fulano = new Empleado("Obrero") estamos creando una nueva instancia de un empleado que hereda por encadenamiento del prototipo de Persona.

En la captura de pantalla del Developer Tools de Chrome puede ver la situación de la variable fulano. Tiene la propiedad trabaja: "Obrero". Su prototipo es Persona donde encontramos nombre: "Juan". Y a su vez en el prototipo de Persona encontramos el método ver(). La cadena llega aún más lejos, pues Persona también tiene un prototipo del objeto intrínseco (built-in) Object, heredando de este también sus propiedades y métodos.

El ejemplo de la instancia mengano es similar al anterior: "Pedro estudia informática" nos da el perfil de un Estudiante. Más interesante es el ejemplo de la variable zutano que registra "Antonio trabaja de camarero y estudia periodismo", reuniendo los perfiles de Empleado y Estudiante al mismo tiempo. Llevamos a cabo esto de la misma forma: encadenando prototipos. Como un empleado es una persona hacemos Empleado.prototype = new Persona("Antonio"). Luego, como este empleado es también un estudiante hacemos Estudiante.prototype = new Empleado("Camarero"). Finalmente podemos crear la instancia del Estudiante con sus prototipos ya encadenados: var zutano = new Estudiante("Periodismo").

Se pueden encadenar prototipos de forma indefinida (hasta donde los recursos los permitan). Pero podemos liarnos con esa cadena si es muy larga.

Encadenado prototipos con Object.create()

Ejecutamos un ejemplo similar usando Object.create() pero en un closure diferente. Digo esto porque los nombres de las variables son los mismos (Persona, Empleado, fulano, mengano, etc.), pero no son las mismas variables al estar ubicadas en distintos closures.

Ejemplo:

 

Cadena de prototipos En este ejemplo agregamos una propiedad más a cada constructor y un nuevo perfil Deportista. La clase base sigue siendo Persona con el mismo código que teníamos antes. Pero en las subclases Empleado, Estudiante, ... adjudicamos el prototipo de la clase base usando Object.create(Persona.prototype). Esto encadena los prototipos de forma más fácil que como hacíamos en el primer ejemplo. Ahora estamos definiendo los constructores y a la vez creando la estructura implícita. Así por ejemplo el constructor Empleado tiene los argumentos trabaja, empresa, nombre. Si se pasa el nombre de la persona llamaremos al constructor Persona para instanciarlo. Esto lo hacemos con Persona.call(this, nombre). Es como si hiciéramos new Persona(nombre) sobre la referencia que se está instanciando en el constructor Empleado. Puede ver más información sobre call y apply. En la captura de pantalla puede ver la variable fulano que instancia una persona que responde a la frase "Juan trabaja de obrero en la empresa ACME". Esa instancia la construimos con var fulano = new Empleado("Obrero", "ACME", "Juan") sin tener que estar tocando nada de prototipos en la instanciación como hacíamos en el ejemplo anterior. El prototipo inicial de Persona contiene el método ver() que, al igual que en el ejemplo anterior, todos comparten. Pero las propiedades se van acumulando en la instancia, no en los prototipos.

Para la herencia múltiple donde se comparten varias subclases podemos crear una nueva persona con var zutano = new Persona(). Luego aplicamos call en cada subclase Empleado, Estudiante, ... sobre la instancia zutano pasando también el resto de valores de propiedades.

O alternativamente podemos preparar una función como crearPersonalidadMultiple(). El primer argumento es el nombre de la persona. El segundo es un array de objetos. Cada objeto tiene dos campos. El primero, proto, es uno de los constructores de los perfiles Empleado, Estudiante, .... El segundo argumento nos sirve para pasar un array de valores para el constructor. Se observa ahora la facilidad para construir la instancia zutano2 de un empleado, estudiante y deportista.

Este ejemplo muestra la facilidad para aplicar herencia usando Object.create() para compartir el prototipo. Junto con el uso de call, apply, funciones constructoras y funciones accesorias para otros cometidos como herencia múltiple, conseguimos objetos más compactos y con mayor facilidad para crear instancias.

Modificando propiedades en una cadena de prototipos

¿Porqué debemos evitar encadenar prototipos de la forma clásica?. Supongamos que posteriormete a creada la instancia fulano modificamos su nombre. También hacemos que el método ver() se comporte de una manera diferente que para el resto.

Ejemplo:

 

Esto se hace con un trozo de código que responde al evento del botón anterior.

Cadena de prototipos

Esta captura de pantalla muestra el estado de la instancia fulano tras accionar el botón anterior. En la forma de encadenamiento clásica se crea una nueva propiedad nombre para la instancia fulano. Ahora tenemos nombre: "MODIFICADO" en la instancia y se sigue manteniendo nombre: "Juan" en el prototipo Persona. Esto podría no ser una buena cosa porqué estamos duplicando valores de propiedades.

Si hacemos lo mismo para el segundo ejemplo:

Ejemplo:

 

Cadena de prototipos

Ahora no se crea una nueva propiedad, sobrescribiendo el valor de la única propiedad nombre que existe para la instancia fulano. En cuanto al nuevo método se comporta igual que antes, creando un nuevo ver pues el anterior estaba ubicado en el prototipo del constructor Persona.

Leyendo propiedades en una cadena de prototipos

Este apartado está relacionado con el rendimiento en la lectura de propiedades en prototipos encadenados. En el primer ejemplo con la forma clásica de encadenar prototipos, puede ser obvio pensar que acceder a las propiedades más profundas en la cadena tendría un coste a considerar. Si intentamos leer una propiedad el navegador debe recorrer toda la cadena de prototipos hasta alcanzarla. O si no existe llegará al último objeto que a su vez apunta a uno nulo y devolverá undefined con el sentido de que no encontró esa propiedad.

Preparé un ejemplo para comparar cuanto costaría recorrer una cadena de más de 25 prototipos encadenados. Lo que me sorprendió es que el comportamiento no era igual en todos los navegadores consultados. Firefox y Opera no tenían ningún coste asociado mientras que para Chrome o Safari ese coste si era relevante. Y para Chrome era además proporcional a la profundidad de la cadena. Y aquí me debería parar porque por ahora no he encontrado más explicación a este tema. ¿Es posible que Firefox y Opera usen alguna forma de caché de las propiedades para no tener que realizar esa iteración por la cadena? No me gusta publicar algo de lo que no estoy seguro o desconozco. Pero al mismo tiempo me gustaría compartir las pruebas realizadas. Al final opté por exponer las pruebas que también se pueden ejecutar aquí:

Ejemplo:

Número de clases (De 25 a 200)
 
 
 

Código completo de este ejemplo

Rendimiento en cadena de prototipos

Generamos pruebas con 25 a 200 prototipos encadenados. Luego extraemos el valor de la propiedad más profunda. Para el encadenamiento clásico será valor0 que estará ubicado en lo más hondo de la cadena de prototipos. Para el encadenamiento con Object.create() extraemos también el mismo valor, pero es indiferente, pues todos los valores están en el mismo nivel. Luego por cada test hacemos un bucle de 1 millón de iteraciones para conseguir un tiempo representable. Estos tiempos no deben analizarse de forma absoluta sino comparada entre navegadores.

Probamos con los navegadores Chrome 23.0, Safari 5.1.17, Opera 12.11 y Firefox 17.0.1. Con el método Object.create() todos los navegadores tiene un coste mínimo que está entre los 4 ms de Firefox, 10 ms para Chrome y Safari y 11 ms de Opera. Realmente parte de este coste es el del propio bucle (del orden de varios milisegundos), por lo que podemos decir que recuperar un valor de una propiedad en prototipos encadenados con el método Object.create() no tiene un coste significativo.

Para la cadena de prototipos generada con la forma clásica de encadenamiento, tanto Firefox como Opera consiguen resultados muy bajos. El máximo de cualquiera de las pruebas no supera 12 ms en Opera y 9 ms en Firefox. En cambio en Safari y Chrome estos tiempos se disparan tal como se observa en la gráfica adjunta, donde no hemos puesto los tiempos de los otros dos navegadores al ser valores comparativamente insignificantes.

He buscado documentación para explicar este diferente comportamiento pero no he podido encontrar nada. De alguna forma Firefox y Opera gestionan en alguna especie de caché de memoria los valores de las propiedades para evitar tener que recorrer la cadena de prototipos. En todo caso al menos para Chrome y Safari es preferible encadenar prototipos con Object.create() pues evitamos el coste de recuperar una propiedad profunda en la cadena.