Todos já ouvimos aquele famoso ditado do GoF (Gang of Four) sobre preferir composição ao invés de herança, entretanto, na prática, isso acaba mais virando um: herança é ruim, nunca use herança, sempre utilize exclusivamente composição para tudo.
Apesar disso, ela está mais presente em nossos códigos do que parece quando pensamos sobre o assunto, afinal, embora nós nunca a escolhemos para projetar os nossos códigos, e temos até certa aversão ao seu uso dependendo de quão fundo você foi neste tópico, ela se faz presente em diversos códigos essenciais para que a nossas aplicações existam, afinal diversos frameworks de utilizam dela para que eles funcionem, então não é incomum precisarmos herdar uma classe de um framework por exemplo.
Ao refletir neste tópico, isso me fez chegar a questão do nosso título: se preferimos composição, então por que os frameworks usam herança? Será que podemos estar subestimando a sua utilidade? Ou será que podemos ser criativos e projetar algo interessante através dela? Sendo isso que iremos investigar no artigo de hoje.
Disclaimer
Eu conheço os diversos problemas da herança tal como o alto acoplamento, problema do diamante, problema da classe base frágil, e etc, entretanto este não é um artigo para discutir os seus problemas num geral, e sim as suas utilidades, por isso se quiserem um artigo sobre isso também não deixem de pedir nos comentários o nosso take sobre "why inheritance is evil".
Também não é a intenção deste artigo dizermos que você sempre deve utilizar herança ao invés de composição, ou até que discordamos do ditado, na verdade, é por concordar demais com ele, que resolvi realizar esta investigação atrás de um cenário onde o "prefira" se aplica de fato ao invés de ser um "sempre", afinal se é "prefira" então significa que deve ter algum caso onde herança pode ser mais útil ou pelo menos equivalente.
Por que preferimos composição ao invés de herança?
Em resumo, é porque, geralmente, o trade-off é melhor quando usamos composição, utilizamos herança por dois motivos:
- Reúso de código;
- Programação incremental;
Reúso é bem auto-explicativo, se temos uma classe A com os métodos foo
e bar
, podemos usar ela de base para criar a classe B com os métodos foo
, bar
, e bazz
, ex:
class A
{
public function foo(): void
{
echo "Foo";
}
public function bar(): void
{
echo "Bar";
}
}
class B extends A
{
public function bazz(): void
{
echo "Bazz";
}
}
Assim não precisamos ficar copiando e colando todo o código comum da nossa aplicação toda vez que tivermos uma classe parecida com outra, podemos simplesmente herdar da original.
Essa habilidade de reúsar o código é bem útil para aplicar uma abordagem que chamamos de programação incremental, onde ao invés de começar com uma classe gigante que faz tudo, nós começamos com uma pequena, e se precisarmos criar variações dela, nós podemos implementa-las como classes filhas dessa original, incrementando de pouco em pouco a cada nova iteração. Assim como fizemos com a classe A que tinha dois métodos, onde incrementamos ela com mais um método lá na classe B.
Herança também permite a gente modificar o código de um método da classe mãe na filha caso seja necessário, mas como sabemos que boa parte do tempo, se quisermos respeitar o LSP (Liskov Substitution Principle), não devemos fazer isso, ou perderemos a capacidade de trocar classes dentro de uma mesma hierarquia (que é uma das maiores vantagens de se colocar os adicionais em classes separadas ao invés de tudo na principal), então normalmente só temos uma situação onde se adiciona coisas a mãe na filha, e não modificamos nada que veio da mãe, assim a filha é só a mãe com coisas extras, ao invés de ser a mãe só que diferente em alguns pontos.
Dado que vamos respeitar o LSP, poderíamos ter uma hierarquia assim:
<?php
class Text implements Stringable
{
public function __construct(private string $value) {}
public function __toString(): string
{
return $this->value;
}
}
class AllCapsText extends Text
{
public function __toString(): string
{
return strtoupper(parent::__toString());
}
}
class TrimmedText extends Text
{
public function __toString(): string
{
return trim(parent::__toString());
}
}
echo new Text(' Hello World') . PHP_EOL; // Hello World
echo new AllCapsText(' Hello World') . PHP_EOL; // HELLO WORLD
echo new TrimmedText(' Hello World') . PHP_EOL; // Hello World
Dessa forma, qualquer código que aceita Text vai aceitar suas filhas também, entretanto, observe esse código feito com composição:
<?php
class Text implements Stringable
{
public function __construct(private string $value) {}
public function __toString(): string
{
return $this->value;
}
}
class AllCapsText implements Stringable
{
public function __construct(private Stringable $text) {}
public function __toString(): string
{
return strtoupper($this->text->__toString());
}
}
class TrimmedText implements Stringable
{
public function __construct(private Stringable $text) {}
public function __toString(): string
{
return trim($this->text->__toString());
}
}
$text = new Text(' Hello World');
$allCaps = new AllCapsText($text);
$trimmed = new TrimmedText($text);
echo $text . PHP_EOL; // Hello World
echo $allCaps . PHP_EOL; // HELLO WORLD
echo $trimmed . PHP_EOL; // Hello World
Obtivemos exatamente o mesmo resultado com praticamente o mesmo esforço, apesar disso, a abordagem com composição possui duas vantagens:
- Tem menor acoplamento;
- Você pode compor cada uma das classes;
Então, como cada classe é uma classe independente sem acoplamento direto com a original, podemos nos beneficiar do baixo acoplamento e evoluir cada uma de forma independente sem ter medo de quebrar ninguém de forma não planejada, além disso, apesar do efeito ser o mesmo para quando só queremos usar versões diferentes da classe base, a vantagem aqui é que se a gente quiser combinar as mutações, podemos só fazer isso:
echo new TrimmedText(
new AllCapsText(
new Text(' Hello World')
)
); // HELLO WORLD
E usando herança, não temos como combinar as filhas, nós temos que criar uma nova filha que tem a capacidade de duas, por exemplo uma classe AllCapsTrimmedText
, o que limita a flexibilidade da habilidade de se incrementar, mantendo o reúso de código, só que tendo que escrever bem mais código de boilerplate em troca.
Em resumo, podemos visualizar melhor os trade-offs de cada abordagem na seguinte tabela:
Aspecto | Peso | Herança | Composição |
---|---|---|---|
Acoplamento | Muito Importante (3) | Alto (1) | Baixo (3) |
Flexibilidade | Muito Importante (3) | Médio (2) | Alto (3) |
Reúso | Importante (2) | Alto (3) | Alto (3) |
Extensibilidade | Importante (2) | Alto (3) | Médio (2) |
Boilerplate | Pouco Importante (1) | Baixo (3) | Alto (1) |
Média Ponderada | 4.8 | 5.8 |
Acoplamento
Esse é um aspecto que é muito importante na prática (por isso seu peso é 3), pois tem um impacto enorme na capacidade de se dar manutenção e evoluir qualquer sistema.
A composição possui baixo acoplamento devido a poder evitar uma relação direta com outras classes através de interfaces, sendo assim, mudanças ficam encapsuladas num objeto, e não se propagam para os outros objetos que dependem dele tão facilmente.
A herança, pelo contrário, cria uma relação direta entre mãe e filha, sendo uma das relações com maior grau de acoplamento entre objetos. O que se reflete pelo próprio objetivo da herança que seria o reúso de código, ou seja, quando você utiliza herança é justamente quando você quer que mudanças na classe base se propagem nas filhas, então, por natureza, ela exige um cenário de alto acoplamento.
Flexibilidade
Esse também é outro aspecto que damos muita importancia na prática, afinal é o aspecto mais útil de quando digitamos código, pois mais útil do que não precisar copiar as mesmas linhas de código de um módulo para criar variações dele, é conseguir juntar vários módulos diferentes num módulo só, assim economizando as linhas de todos esses módulos ao mesmo tempo.
Até porque as variações de uma classe podem ir surgindo com o tempo, mas elas vão crescendo de forma linear, entretanto as combinações entre vários módulos podem ir crescendo exponencialmente a cada nova variação introduzida.
Nesse aspecto, a composição, similar a herança no último tópico, tem como objetivo principal ser o ato de combinar vários módulos em um (afinal você compõe eles), então por natureza esse é o aspecto mais forte dessa abordagem.
No caso da herança, enquanto ela oferece certo grau de flexibilidade ao permitir que nós adicionemos variações de uma mesma classe ao herdar ela, não conseguimos misturar essas variações umas com as outras, sendo um grau menos flexível que a composição.
Reúso
Esse é um aspecto importante, afinal seguir o DRY tem se provado como uma decisão sábia ao longo de toda a história do desenvolvimento de software, entretanto, ele não é um aspecto que deve ser buscado a custa de outros, inclusive reusar código em situações onde a coesão entre os módulos for baixa acaba sendo mais um problema do que uma solução, visto que pela falta de coesão entre eles, cada módulo vai acabar evoluindo de forma independente ao longo do tempo, sendo assim, depois de um periodo de tempo, a tendência é que eles já não vão compartilhar mais nada entre si, tornando o ato de criar uma abstração para compartilhar o código um empecilho em evoluir cada um deles de forma independente.
Aqui, ambos herança e acoplamento são ferramentas ótimas para lidar com esse aspecto, não a toa que existe toda essa discussão em torno de quando se usar cada um, sendo a herança melhor em reduzir o boilerplatena hora de reusar código e extender o mesmo, e a composição melhor em criar combinações a partir de códigos já existentes.
Extensibilidade
Esse é um aspecto bem importante, afinal, como apontado no aspecto de flexibilidade, é bem importante que os módulos do sistema possam ser reutilizamos na criação de novos módulos que sejam derivações dos originais, apesar disso, a extensibilidade por si é só um pedaço da flexibilidade, por isso sua importância não chega a ser a mesma da flexibilidade.
Aqui encontramos o primeiro trade-off onde herança começa a ganhar mais relevância, pois dependendo do cenário em que vamos acabar precisando estender os comportamentos na nova classe, alguns deles são impossíveis para a composição sem que nós violemos o encapsulamento, enquanto na herança temos um mecanismo específico desenhado para resolver esse problema na forma do modificador de visibilidade protected.
Esses cenários podem ser resumidos na seguinte tabela:
Cenário | Herança | Composição | Observações |
---|---|---|---|
Adicionar/Modificar + Sem Dependências | ✅ Fácil | ✅ Fácil | Sem dependências, ambas as abordagens são viáveis. |
Adicionar/Modificar + Depende do Filho | ✅ Fácil | ✅ Fácil | Ambas as abordagens lidam bem com dependências específicas do filho. |
Adicionar/Modificar + Depende do Comportamento do Pai (Público) | ✅ Fácil | ✅ Fácil | Métodos públicos funcionam igualmente em herança e composição. |
Adicionar/Modificar + Depende do Comportamento do Pai (Protegido) | ✅ Fácil | ⚠️ Comprometido | A composição exigiria tornar métodos protegidos públicos. |
Adicionar/Modificar + Depende dos Dados do Pai | ✅ Fácil | ❌ Quebra o Encapsulamento | A composição força o uso de getters, quebrando o encapsulamento. |
Boilerplate
Aqui é o aspecto menos importante daquela tabela, pois enquanto é algo que torna a experiência de escrever código muito mais prazerosa, visto que não vamos perder tempo escrevendo código que não está contribuindo para a resolução do problema, ou seja, evitando de escrever código "por obrigação", sendo o mais prático de se escrever a curto prazo, é um benefício que a gente trocaria facilmente sempre que a troca nos der outros benefícios mais relevantes tais como os outros mais bem rankeados nesta comparação.
Essa troca entre praticidade e benefícios de longo prazo é a decisão que a gente toma constantemente quando decidimos desacoplar nosso código por exemplo.
Apesar disso é aqui que se encontra o trade-off mais favorável para a herança, pois a composição exige um monte de boilerplate em troca de todos os benefícios que ela oferece.
Em uma situação onde a classe base só tem um método, e queremos estender um único método, como vimos anteriormente, o código requerido por cada abordagem é mais ou menos o mesmo, entretanto quanto mais métodos nós adicionamos na classe base, mais a composição sofre, pois ela necessita que cada método não incrementado da classe base seja copiado no novo objeto só para chamar internamente o método do objeto original. Ex:
class Foo
{
public function methodA(): void {}
public function methodB(): void {}
}
class Bar
{
public function __construct(private Foo $foo) {}
public function methodA(): void
{
return $this->foo->methodA();
}
public function methodB(): void
{
$this->methodC();
return $this->foo->methodB();
}
public function methodC(): void {}
}
Enquanto a herança elimina completamente o boilerplate do código, mantendo só as partes que interessa para gente, ou seja, as que contém código novo:
class Foo
{
public function methodA(): void {}
public function methodB(): void {}
}
class Bar extends Foo
{
public function methodB(): void
{
$this->methodC();
parent::methodB();
}
public function methodC(): void {}
}
Resultado
Sendo assim, o ditado se prova pois, apesar de ambos terem uma média final similar, os cenários onde a composição tem vantagem sobre a herança são os cenários que preferimos ter quando escrevemos os nossos códigos, por isso, via de regra, vamos acabar preferindo por composição mesmo.
Infelizmente, o alto acoplamento é algo que segura demais a herança quando vamos fazer essas comparações, tanto é que se removermos a importância do acoplamento, reduzindo ela a 0, a média ponderada vai mudar para um cenário que mostra a herança como mais favorável (por uma diferença de 0.2 mais ou menos).
O que os frameworks tem a ver com isso?
Ao analisar a nossa tabela, podemos concluir que o cenário ideal para herança seria quando nossas prioridades estão invertidas, por isso que ele é tão raro, sendo algo praticamente teórico, ou seja, nós precisamos desenhar um cenário onde desejamos um acoplamento alto ou ele não faz diferença para a situação, e que reúso de código seja o aspecto que damos mais importância ao invés de ser só algo secundário que seria bom ter. E adivinha qual cenário da vida real seria esse?
Exatamente meu caro leitor, os frameworks são o exemplo perfeito que atinge todos esses requisitos extremamente específicos onde a herança é algo favorável. Pelo menos é esse o caso quando falamos de frameworks opinativos tal como o Laravel ou Ruby on Rails.
Reúso
Frameworks existem para facilitar a nossa vida na hora de desenvolver uma aplicação, sendo assim, o seu princípal objetivo é prover abstrações já prontas de modo que esse código que vai ser comum a todos os projetos que necessitem dos processos que o framework busca automatizar ou facilitar possa ser reutilizado diretamente dele pelos desenvolvedores desse projeto.
Podemos notar isso através dessas duas definições obtidas da Wikipedia:
Uma estrutura é um termo genérico que geralmente se refere a uma estrutura de suporte essencial sobre a qual outras coisas são construídas em cima.
~ Wikipedia - Definição genéricaUm framework em desenvolvimento de software, é uma abstração que une códigos comuns entre vários projetos de software provendo uma funcionalidade genérica.
~ Wikipedia - Definição em desenvolvimento de software
Resumindo, exatamente a nossa definição, sendo uma dependência externa que reúne várias abstrações diferentes comuns a diversos projetos de software com domínios semelhantes, de modo a prover uma base genérica para se escrever uma aplicação daquele tipo em cima.
Sendo assim, a princípal prioridade de um framework é escrever código que possa ser compartilhado e reúsado, que como vimos é uma área onde herança tende a ter mais vantagens, mesmo que composição possa ser situacionalmente melhor.
Além disso, permitir que os usuários de um framework possam estender suas capacidades, visto que cada usuário terá necessidades diferentes para ele, e, portanto, potencialmente, poderia necessitar de modificações em como ele funciona, a capacidade de sobrescrever certas funções e fornecer pontos de estensão, são coisas essenciais para bons frameworks.
Herança trás seus maiores benefícios justamente nesses dois casos, sobrescrever comportamentos como é comum usando polimorfismo de subtipo (que é o que você ganha ao usar herança), e criação de pontos de estenção por meio de template method.
Acoplamento
Apesar de mostrarmos que por sua própria natureza frameworks serem o tipo de código que mais se aproveita dos benefícios da herança, ainda assim temos frameworks inteiros baseados em composição, o que poderia fazer o argumento do alto acoplamento causado pela herança ganhar força novamente, para ela, no melhor dos casos, ser só uma alternativa escrita com uma funcionalidade pior.
Entretanto, mesmo que frameworks disponibilizem interfaces, módulos isolados baseados em composição, e etc, que façam parecer que o código é menos intrusivo, e mais desacoplado. A verdade é que acoplamento não importa muito num cenário onde você decide usar um framework, pelo menos não para quem escreve o código do framework pensando num benefício para quem usa.
Tão importante quanto a força do acoplamento é a direção em que ele acontece, estar desacoplado não significa que magicamente os dois módulos sempre vão estar isolados um do outro, na verdade, significa que o código que depende de membros abstratos está se defendendo de quem usa ele.
Essa noção é muito importante, pois isso significa que mesmo que os frameworks usem composição, e interfaces, expondo elas para manter tudo desacoplado e etc, na verdade, o que está acontecendo é que o código do framework está se protegendo do seu código, quando o que queremos ao ter um baixo acoplamento no código é justamente o contrário, proteger o nosso código do resto.
Se os desenvolvedores de framework fazem isso, interessante para eles que estão se previnindo de mudanças nos códigos dos seus usuários, mas isso adiciona pouquíssima vantagem para o objetivo de um framework que é facilitar a vida de seus usuários. Uma vez que eles que tem que se defender do framework, e não o contrário.
Outro motivo pelo qual o acoplamento importa pouco ou as vezes não importa quando estamos falando de frameworks é que, por definição, eles forçam os seus usuários a jogarem nas suas regras, então por definição usar um framework é decidir se acoplar a ele, e reagir as suas mudanças, afinal se você não usa uma arquitetura como a clean architecture ou similares, a estrutura do seu código é a própria estrutura do framework.
Logo, é uma premissa que para usar, você está aceitando estar acoplado a ele em menor ou maior grau, até porque a única maneira de não se acoplar é escrevendo seu código explicitamente para evitar o framework, o que é uma abordagem mais trabalhosa e faz você não colher todos os benefícios de usar um em primeiro lugar, ou simplesmente não utilizar ele, afinal a ideia é usar código pronto que não é você que controla.
Dessa forma, como o framework em si não tem como tornar o seu código desacoplado dele, visto que própria premissa dele é ser uma dependência do seu projeto, o acoplamento realmente tem pouquíssimo valor nessa situação em específico, o que nos trás uma situação exatamente como descrevemos anteriormente, onde temos alto acoplamento, e reúso é uma prioridade.
Coesão
Curiosamente, um ponto que continua o raciocínio por trás do acoplamento não ser tão relevante, é que, dependendo do caso, o fato de ter menos acoplamento pode até ser pior.
Uma vez que a maior flexibilidade trazida por ele, é uma vantagem quando você que é o dono desse código, mas pode ser uma desvantagem para um código já estável e coeso como é o caso dos frameworks.
Isso porque a ideia de um framework é forçar a estrutura dele nos seus usuários em maior ou menor grau, além disso, seus módulos tendem a ser altamente coesos, e já terem sido escritos para funcionarem bem entre si dentro dos casos esperados pelos seus autores.
Sendo assim, uma abordagem que oferece mais flexibilidade tal como a composição poderia fazer com que seja possível a criação de uma combinação inválida dentro das premissas do framework.
Outro "problema" é que reforçar uma consistência na forma de fazer as coisas, sendo uma noção vinda até pela própria definição de framework de ser uma base para se construir em cima, é um atributo bem relevante para um framework, e uma abordagem cujo os benefícios são menos controle por parte do framework (mais flexível), e menos dependência direta dele (menos acoplado) iria diretamente contra a promoção deste aspecto.
Uma maior flexibilidade pode ser uma vantagem para frameworks baseados em configuração (Spring, Synfony, etc), mas particularmente um problema para frameworks baseados em convenções, visto que vai tornar o código menos consistente o que pode ser um desafio para os autores na hora de forçarem certas convenções a se repetirem ao longo do código.
Fechando esse ponto da consistência, herança pode ser uma vantagem aqui, pois ela oferece uma forma de inversão de controle na forma do padrão template method que é mais rígido que a versão de composição que seria o strategy, com o benefício de ser mais conveniente de implementar os pontos de extensão (o que é um pró no caso de frameworks), e dar mais controle para o framework nesse caso, enquanto retém benefícios semelhantes a versão baseada em composição da aplicação desse conceito.
Conclusão
Sinceramente, quando eu comecei a pesquisa para este artigo, pensei que seria bem simples, iria falar mal do conceito, mostrar que template method era pelo menos algo que dava para ser utilizado e que era legal, e fechar por ali, no entanto, quanto mais eu lia sobre o assunto, mais ideias eu tinha sobre como talvez herança não fosse tão inútil quanto o senso comum faz parecer, o que resultou no texto de hoje sendo um dos que eu mais me diverti escrevendo.
E você? Se surpreendeu com o quanto a discussão poderia ser mais interessante do que simplesmente dizer que composição é melhor e ponto? Já havia pensando tão profundamente sobre a utilidade da herança? Mudou algo em como você encara esse princípio? Conta para gente aqui nos comentários.
Links que podem te interessar
- Refactoring Guru sobre Template Method;
- Template Method Pattern Revised;
- Composition vs Inheritance: How to Choose?;
- Why extends is evil;
- Inheritance is NOT evil;
- Why inheritance never made any sense;
- When to prefer inheritance to composition;
- If Inheritance is so bad, why does everyone use it?;
- Is Inheritance That Evil?;
- TEMPLATE METHOD & STRATEGY: Inheritance vs. Delegation;
Ass. Suporte Cansado
Top comments (0)