Precio de composición en el mundo javascript

La idea de que en el desarrollo de una lógica comercial más o menos compleja, se debe dar prioridad a la composición de los objetos, en lugar de la herencia, es popular entre los desarrolladores de software de varios tipos. En la próxima ola de popularidad del paradigma de programación funcional lanzado por el éxito de ReactJS, se habló sobre los beneficios de las soluciones compositivas. En esta publicación hay un pequeño diseño en los estantes de la teoría de la composición de objetos en Javascript, un ejemplo específico, su análisis y la respuesta a la pregunta de cuánto cuesta la elegancia semántica para el usuario (spoiler: mucho).

V. Kandinsky - Composición X
Vasily Kandinsky - "Composición X"

Años de desarrollo exitoso de un enfoque de desarrollo orientado a objetos, principalmente en la esfera académica, han llevado a un desequilibrio notable en la mente del desarrollador promedio. Entonces, en la mayoría de los casos, el primer pensamiento, si es necesario, para generalizar un comportamiento para varias entidades diversas es crear una clase padre y heredar este comportamiento. Este enfoque de abuso conduce a varios problemas que complican el diseño e inhiben el desarrollo.

Primero, la clase base sobrecargada de lógica se vuelve frágil : un pequeño cambio en sus métodos puede afectar fatalmente las clases derivadas. Una forma de sortear esta situación es distribuir la lógica en varias clases, creando una jerarquía de herencia más compleja. En este caso, el desarrollador tiene otro problema: la lógica de las clases principales se duplica en los herederos según sea necesario, en casos de intersección entre la funcionalidad de las clases principales, pero, lo que es más importante, no está completa.

imagen
mail.mozilla.org/pipermail/es-discuss/2013-June/031614.html

Y finalmente, creando una jerarquía bastante profunda, el usuario, cuando usa cualquier entidad, se ve obligado a arrastrar a todos sus antepasados ​​junto con todas sus dependencias, independientemente de si va a usar su funcionalidad o no. Este problema de dependencia excesiva del medio ambiente con una mano ligera de Joe Armstrong, creador de Erlang, se llamó el problema del gorila y el plátano:

Creo que la falta de reutilización viene en lenguajes orientados a objetos, no en lenguajes funcionales. Debido a que el problema con los lenguajes orientados a objetos es que tienen todo este entorno implícito que llevan consigo. Querías un plátano pero lo que obtuviste fue un gorila sosteniendo el plátano y toda la jungla.

La composición de los objetos está llamada a resolver todos estos problemas como una alternativa a la herencia de clases. La idea no es nueva en absoluto, pero no encuentra una comprensión completa entre los desarrolladores. La situación en el mundo front-end es ligeramente mejor, donde la estructura de los proyectos de software es a menudo bastante simple y no estimula la creación de un esquema complejo de relación orientada a objetos. Sin embargo, seguir ciegamente los convenios de la Banda de los Cuatro, recomendando preferir la composición sobre la herencia, también puede jugar un truco en la sabiduría inspiradora de los grandes desarrolladores.

Transfiriendo definiciones de "Patrones de diseño" al mundo dinámico de Javascript, podemos resumir tres tipos de composición de objetos: agregación , concatenación y delegación . Vale la pena decir que esta separación y el concepto de composición de objetos en general tiene una naturaleza puramente técnica, mientras que el significado de estos términos tiene intersecciones, lo que introduce confusión. Entonces, por ejemplo, la herencia de clases en Javascript se implementa sobre la base de la delegación (herencia prototipo). Por lo tanto, cada uno de los casos es mejor hacer copias de seguridad con ejemplos de código en vivo.

La agregación es una unión enumerable de objetos, cada uno de los cuales se puede obtener utilizando un identificador de acceso único. Los ejemplos incluyen matrices, árboles, gráficos. Un buen ejemplo del mundo del desarrollo web es el árbol DOM. La principal cualidad de este tipo de composición y la razón de su creación es la capacidad de aplicar convenientemente algún controlador a cada elemento secundario de la composición.

Un ejemplo sintético es una matriz de objetos que se turnan para configurar un estilo para un elemento visual arbitrario.

const styles = [  { fontSize: '12px', fontFamily: 'Arial' },  { fontFamily: 'Verdana', fontStyle: 'italic', fontWeight: 'bold' },  { fontFamily: 'Tahoma', fontStyle: 'normal'} ]; 

Cada uno de los objetos de estilo puede extraerse por su índice sin pérdida de información. Además, utilizando Array.prototype.map (), puede procesar todos los valores almacenados de una manera determinada.

 const getFontFamily = s => s.fontFamily; styles.map(getFontFamily) //["Arial","Verdana","Tahoma"] 

La concatenación implica expandir la funcionalidad de un objeto existente al agregarle nuevas propiedades. Así, por ejemplo, los reductores de estado en Redux funcionan. Los datos recibidos para la actualización se escriben en el objeto de estado, expandiéndolo. Los datos sobre el estado actual del objeto, a diferencia de la agregación, se pierden si no se guardan.

Volviendo al ejemplo, aplicando alternativamente la configuración anterior al elemento visual, puede generar el resultado final concatenando los parámetros de los objetos.

 const concatenate = (a, s) => ({…a, …s}); styles.reduce(concatenate, {}) //{fontSize:"12px",fontFamily:"Tahoma",fontStyle:"normal",fontWeight:"bold"} 

Los valores de un estilo más específico eventualmente sobrescribirán los estados anteriores.

Al delegar , como puede suponer, un objeto se delega a otro. Los delegados, por ejemplo, son prototipos en Javascript. Las instancias de objetos derivados redirigen las llamadas a los métodos principales. Si no hay ninguna propiedad o método requerido en la instancia de la matriz, redirigirá esta llamada a Array.prototype y, si es necesario, más allá de Object.prototype. Por lo tanto, el mecanismo de herencia en Javascript se basa en la cadena de delegación prototipo, que técnicamente es una versión (sorpresa) de la composición.

La combinación de una matriz de objetos de estilo por delegación se puede hacer de la siguiente manera.

 const delegate = (a, b) => Object.assign(Object.create(a), b); styles.reduceRight(delegate, {}) //{"fontSize":"12px","fontFamily":"Arial"} styles.reduceRight(delegate, {}).fontWeight //bold 

Como puede ver, las propiedades de delegado no son accesibles por enumeración (por ejemplo, usando Object.keys ()), pero solo son accesibles por acceso explícito. El hecho de que esto nos da está al final del post.

Ahora a los detalles. Un buen ejemplo de un caso que alienta a un desarrollador a usar la composición en lugar de la herencia es en la composición de objetos de Michael Rise en Javascript . Aquí, el autor considera el proceso de crear una jerarquía de personajes de rol. Inicialmente, se requieren dos tipos de personajes: un guerrero y un mago, cada uno de los cuales tiene una reserva de salud y un nombre. Estas propiedades son comunes y se pueden mover a la clase principal Carácter.

 class Character { constructor(name) { this.name = name; this.health = 100; } } 

El guerrero se distingue por el hecho de que sabe cómo atacar, mientras gasta su resistencia, y el mago, la capacidad de lanzar hechizos, lo que reduce la cantidad de maná.

 class Fighter extends Character { constructor(name) { super(name); this.stamina = 100; } fight() { console.log(`${this.name} takes a mighty swing!`); this.stamina -  ; } } class Mage extends Character { constructor(name) { super(name); this.mana = 100; } cast() { console.log(`${this.name} casts a fireball!`); this.mana -  ; } } 

Después de crear las clases Fighter y Mage, los descendientes de Character, el desarrollador se enfrenta a un problema inesperado cuando es necesario crear la clase Paladin. El nuevo personaje se distingue por la capacidad envidiable de luchar y conjurar. De improviso puede ver un par de soluciones que difieren en la misma falta de gracia.

  1. Puedes hacer que Paladin sea un descendiente de Character e implementar ambos fight () y cast () desde cero. En este caso, se viola gravemente el principio DRY, ya que cada uno de los métodos se duplicará durante la creación y posteriormente necesitará una sincronización constante con los métodos de las clases Mage y Fighter para rastrear los cambios.
  2. Los métodos fight () y cast () se pueden implementar a nivel de la clase Charater para que los tres tipos de personajes los posean. Esta es una solución un poco más agradable, sin embargo, en este caso, el desarrollador debe redefinir el método fight () para el mago y el método cast () para el guerrero, reemplazándolos con trozos vacíos.

En cualquiera de las opciones, tarde o temprano uno tiene que enfrentar los problemas de herencia expresados ​​al comienzo de la publicación. Se pueden resolver con un enfoque funcional para la implementación de personajes. Es suficiente para alejarse no de sus tipos, sino de sus funciones. En resumen, tenemos dos características clave que determinan la capacidad de los personajes: la capacidad de luchar y la capacidad de conjurar. Estas características se pueden configurar utilizando funciones de fábrica que expanden el estado que define el carácter (un ejemplo de composición es la concatenación).

 const canCast = (state) => ({ cast: (spell) => { console.log(`${state.name} casts ${spell}!`); state.mana -  ; } }) const canFight = (state) => ({ fight: () => { console.log(`${state.name} slashes at the foe!`); state.stamina -  ; } }) 

Por lo tanto, el personaje está determinado por el conjunto de estas características y las propiedades iniciales, tanto generales (nombre y salud) como privadas (resistencia y maná).

 const fighter = (name) => { let state = { name, health: 100, stamina: 100 } return Object.assign(state, canFight(state)); } const mage = (name) => { let state = { name, health: 100, mana: 100 } return Object.assign(state, canCast(state)); } const paladin = (name) => { let state = { name, health: 100, mana: 100, stamina: 100 } return Object.assign(state, canCast(state), canFight(state)); } 

Todo es hermoso: el código de acción se reutiliza, puede agregar cualquier personaje nuevo con facilidad, sin tocar los anteriores y sin inflar la funcionalidad de ningún objeto. Para encontrar una mosca en el ungüento en la solución propuesta, es suficiente comparar el rendimiento de la solución basada en la herencia (delegación de lectura) y la solución basada en la concatenación. Crea un ejército millonésimo de instancias de los personajes creados.

 var inheritanceArmy = []; for (var i = 0; i < 1000000; i++) { inheritanceArmy.push(new Fighter('Fighter' + i)); inheritanceArmy.push(new Mage('Mage' + i)); } var compositionArmy = []; for (var i = 0; i < 1000000; i++) { compositionArmy.push(fighter('Fighter' + i)); compositionArmy.push(mage('Mage' + i)); } 

Y compare la memoria y los costos computacionales entre la herencia y la composición necesaria para crear objetos.

imagen

En promedio, una solución que utiliza una composición por concatenación requiere 100-150% más de recursos. Los resultados presentados se obtuvieron en el entorno NodeJS, puede ver los resultados para el motor del navegador ejecutando esta prueba .

La ventaja de la solución basada en la delegación de herencia se puede explicar al ahorrar memoria debido a la falta de acceso implícito a las propiedades de delegado, así como al deshabilitar algunas optimizaciones de motor para delegados dinámicos. A su vez, la solución basada en concatenación utiliza el costoso método Object.assign (), que afecta en gran medida su rendimiento. Curiosamente, Firefox Quantum muestra resultados de cromo diametralmente opuestos: la segunda solución funciona mucho más rápido en Gecko.

Por supuesto, vale la pena confiar en los resultados de las pruebas de rendimiento solo al resolver tareas bastante laboriosas relacionadas con la creación de una gran cantidad de objetos de infraestructura compleja, por ejemplo, cuando se trabaja con un árbol virtual de elementos o se desarrolla una biblioteca gráfica. En la mayoría de los casos, la belleza estructural de una solución, su confiabilidad y simplicidad resultan ser más importantes, y una pequeña diferencia en el rendimiento no juega un papel importante (las operaciones con elementos DOM requerirán muchos más recursos).

En conclusión, vale la pena señalar que los tipos de composición considerados no son únicos y mutuamente excluyentes. La delegación se puede implementar mediante la agregación y la herencia de clases mediante la delegación (como se hace en JavaScript). En esencia, cualquier combinación de objetos será una forma u otra de composición y, en última instancia, solo importa la simplicidad y flexibilidad de la solución resultante.

Source: https://habr.com/ru/post/438404/


All Articles