A idéia de que, no desenvolvimento de qualquer lógica de negócios mais ou menos complexa, seja dada prioridade à composição de objetos, em vez de herança, é popular entre os desenvolvedores de software de vários tipos. Na próxima onda de popularidade do paradigma de programação funcional lançado pelo sucesso do ReactJS, as discussões sobre os benefícios das soluções composicionais chegaram ao front end. Neste post, há um pequeno layout nas prateleiras da teoria da composição de objetos em Javascript, um exemplo específico, sua análise e a resposta para a pergunta de quanto custa a elegância semântica para o usuário (spoiler: muito).
Vasily Kandinsky - "Composição X"Anos de desenvolvimento bem-sucedido de uma abordagem orientada a objetos para o desenvolvimento, principalmente no campo acadêmico, levaram a um desequilíbrio visível na mente do desenvolvedor médio. Portanto, na maioria dos casos, o primeiro pensamento, se necessário, generalizar um comportamento para várias entidades diversas é criar uma classe pai e herdar esse comportamento. Essa abordagem de abuso leva a vários problemas que complicam o design e inibem o desenvolvimento.
Primeiro, a classe base sobrecarregada de lógica se torna
frágil - uma pequena mudança em seus métodos pode afetar fatalmente as classes derivadas. Uma maneira de contornar essa situação é distribuir a lógica entre várias classes, criando uma hierarquia de herança mais complexa. Nesse caso, o desenvolvedor recebe outro problema - a lógica das classes pai é duplicada nos herdeiros conforme necessário, nos casos de interseção entre a funcionalidade das classes pai, mas, o mais importante, não é completa.
mail.mozilla.org/pipermail/es-discuss/2013-June/031614.htmlE, finalmente, criando uma hierarquia bastante profunda, o usuário, ao usar qualquer entidade, é forçado a arrastar todos os seus ancestrais, juntamente com todas as suas dependências, independentemente de usar ou não sua funcionalidade. Esse problema de dependência excessiva do meio ambiente, com a mão leve de Joe Armstrong, criador de Erlang, foi chamado de problema do gorila e da banana:
Eu acho que a falta de reutilização vem nas linguagens orientadas a objetos, não nas linguagens funcionais. Como o problema das linguagens orientadas a objetos é que elas têm todo esse ambiente implícito que carregam consigo. Você queria uma banana, mas o que conseguiu foi um gorila segurando a banana e toda a selva.
A composição dos objetos é chamada para resolver todos esses problemas como uma alternativa à herança de classes. A idéia não é nova, mas não encontra um entendimento completo entre os desenvolvedores. A situação no mundo front-end é um pouco melhor, onde a estrutura dos projetos de software geralmente é bastante simples e não estimula a criação de um esquema complexo de relacionamento orientado a objetos. No entanto, seguir cegamente os convênios da Gangue dos Quatro, recomendando que a composição seja preferida à herança, também pode representar um truque na sabedoria inspiradora dos grandes desenvolvedores.
Transferindo definições de Design Patterns para o mundo dinâmico do Javascript, podemos resumir três tipos de composição de objetos:
agregação ,
concatenação e
delegação . Vale dizer que essa separação e o conceito de composição de objetos em geral têm natureza puramente técnica, enquanto o significado desses termos tem interseções, o que gera confusão. Assim, por exemplo, a herança de classe em Javascript é implementada com base na delegação (herança de protótipo). Portanto, é melhor fazer backup de cada um dos casos com exemplos de código ativo.
A agregação é uma união enumerável de objetos, cada um dos quais pode ser obtido usando um identificador de acesso exclusivo. Exemplos incluem matrizes, árvores, gráficos. Um bom exemplo do mundo do desenvolvimento web é a árvore DOM. A principal qualidade desse tipo de composição e o motivo de sua criação é a capacidade de aplicar convenientemente algum manipulador a cada filho da composição.
Um exemplo sintético é uma matriz de objetos que se revezam definindo um estilo para um elemento visual arbitrário.
const styles = [ { fontSize: '12px', fontFamily: 'Arial' }, { fontFamily: 'Verdana', fontStyle: 'italic', fontWeight: 'bold' }, { fontFamily: 'Tahoma', fontStyle: 'normal'} ];
Cada um dos objetos de estilo pode ser extraído por seu índice sem perda de informações. Além disso, usando Array.prototype.map (), você pode processar todos os valores armazenados de uma determinada maneira.
const getFontFamily = s => s.fontFamily; styles.map(getFontFamily)
A concatenação envolve a expansão da funcionalidade de um objeto existente, adicionando novas propriedades a ele. Assim, por exemplo, redutores de estado no Redux funcionam. Os dados recebidos para atualização são gravados no objeto state, expandindo-o. Os dados no estado atual do objeto, diferentemente da agregação, são perdidos se não forem salvos.
Retornando ao exemplo, aplicando alternadamente as configurações acima ao elemento visual, você pode gerar o resultado final concatenando os parâmetros dos objetos.
const concatenate = (a, s) => ({…a, …s}); styles.reduce(concatenate, {})
Valores de um estilo mais específico acabarão substituindo estados anteriores.
Ao
delegar , como você pode imaginar, um objeto é delegado para outro. Delegados, por exemplo, são protótipos em Javascript. Instâncias de objetos derivados redirecionam chamadas para métodos pai. Se não houver propriedade ou método necessário na instância da matriz, ele redirecionará essa chamada para Array.prototype e, se necessário - além de Object.prototype. Assim, o mecanismo de herança em Javascript é baseado na cadeia de delegação de protótipos, que é tecnicamente uma versão (surpresa) da composição.
A combinação de uma matriz de objetos de estilo por delegação pode ser feita da seguinte maneira.
const delegate = (a, b) => Object.assign(Object.create(a), b); styles.reduceRight(delegate, {})
Como você pode ver, as propriedades delegadas não são acessíveis por enumeração (por exemplo, usando Object.keys ()), mas são acessíveis apenas por acesso explícito. O fato de isso nos dar está no final do post.
Agora, para os detalhes. Um bom exemplo de caso que incentiva um desenvolvedor a usar composição em vez de herança está na
composição de objeto de Michael Rise
em Javascript . Aqui, o autor considera o processo de criação de uma hierarquia de personagens. Primeiro, são necessários dois tipos de personagens - um guerreiro e um mágico, cada um com uma certa reserva de saúde e um nome. Essas propriedades são comuns e podem ser movidas para a classe pai Character.
class Character { constructor(name) { this.name = name; this.health = 100; } }
O guerreiro se distingue pelo fato de saber atacar, enquanto gasta sua resistência e o mago - a capacidade de lançar feitiços, reduzindo a quantidade de mana.
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 - ; } }
Tendo criado as classes Fighter e Mage, os descendentes de Character, o desenvolvedor enfrenta um problema inesperado quando há necessidade de criar a classe Paladin. O novo personagem se distingue pela invejável capacidade de lutar e conjurar. De antemão, você pode ver algumas soluções que diferem na mesma falta de graça.
- Você pode fazer de Paladin um descendente de Personagem e implementar tanto fight () quanto cast () nele do zero. Nesse caso, o princípio DRY é violado grosseiramente, porque cada um dos métodos será duplicado durante a criação e, posteriormente, precisará de sincronização constante com os métodos das classes Mage e Fighter para rastrear alterações.
- Os métodos fight () e cast () podem ser implementados no nível da classe Charater, para que todos os três tipos de personagens os possuam. Essa é uma solução um pouco mais agradável, no entanto, nesse caso, o desenvolvedor deve redefinir o método fight () para o mágico e o método cast () para o guerreiro, substituindo-os por tocos vazios.
Em qualquer uma das opções, mais cedo ou mais tarde é preciso enfrentar os problemas de herança manifestados no início do post. Eles podem ser resolvidos com uma abordagem funcional para a implementação de caracteres. É o suficiente para se afastar não de seus tipos, mas de suas funções. No final das contas, temos dois recursos principais que determinam a capacidade dos personagens - a capacidade de lutar e a capacidade de conjurar. Esses recursos podem ser definidos usando funções de fábrica que expandem o estado que define o caractere (um exemplo de composição é concatenação).
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 - ; } })
Assim, o personagem é determinado pelo conjunto desses recursos e pelas propriedades iniciais, gerais (nome e saúde) e privadas (resistência e mana).
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)); }
Tudo é lindo - o código de ação é reutilizado, você pode adicionar qualquer novo personagem com facilidade, sem tocar nos anteriores e sem aumentar a funcionalidade de qualquer objeto. Para encontrar uma mosca na pomada na solução proposta, basta comparar o desempenho da solução com base na herança (delegação de leitura) e a solução com base na concatenação. Crie um milionésimo exército de instâncias dos personagens criados.
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)); }
E compare os custos de memória e computacionais entre herança e composição necessários para criar objetos.

Em média, uma solução usando uma composição por concatenação requer 100 a 150% mais recursos. Os resultados apresentados foram obtidos no ambiente NodeJS. Você pode ver os resultados para o mecanismo do navegador executando este
teste .
A vantagem da solução baseada na delegação de herança pode ser explicada economizando memória devido à falta de acesso implícito às propriedades do delegado, além de desativar algumas otimizações de mecanismo para delegados dinâmicos. Por sua vez, a solução baseada em concatenação usa o método Object.assign () muito caro, que afeta muito seu desempenho. Curiosamente, o Firefox Quantum mostra resultados diametralmente opostos ao Chromium - a segunda solução funciona muito mais rápido no Gecko.
Obviamente, vale a pena confiar nos resultados dos testes de desempenho apenas ao resolver tarefas bastante trabalhosas associadas à criação de um grande número de objetos de infraestrutura complexa - por exemplo, ao trabalhar com uma árvore virtual de elementos ou ao desenvolver uma biblioteca gráfica. Na maioria dos casos, a beleza estrutural da solução, sua confiabilidade e simplicidade acabam sendo mais importantes, e uma pequena diferença no desempenho não desempenha um papel importante (as operações com elementos DOM ocupam muito mais recursos).
Em conclusão, vale ressaltar que os tipos de composição considerados não são únicos e mutuamente exclusivos. A delegação pode ser implementada usando agregação e herança de classe usando delegação (como é feito em JavaScript). Em sua essência, qualquer combinação de objetos será de uma forma ou de outra de composição e, em última análise, apenas a simplicidade e a flexibilidade da solução resultante são importantes.