Herança entrega reúso e identidade no mesmo pacote. Quase sempre você só precisa de um dos dois, e é aí que compor sai mais barato.
O que é herança
Herança é declarar que uma classe é um tipo de outra.
class Animal {
nome: string;
respirar(): string {
return `${this.nome} respirando`;
}
}
// Cachorro não escreveu respirar() e mesmo assim tem.
class Cachorro extends Animal {
latir(): string {
return `${this.nome} latindo`;
}
}O extends dessa linha faz duas coisas ao mesmo tempo, e é importante separar quais:
- Reúso:
Cachorroganha os campos e os métodos deAnimalsem reescrever nada. - Identidade:
Cachorropode ser usado em qualquer lugar que espera umAnimal. É a relação de subtipo.
Herança entrega as duas juntas, num pacote só. E daí vem boa parte dos problemas: quase sempre você quer uma das duas e leva as duas.
Tem uma terceira coisa que vem junto e ninguém pede: a subclasse enxerga o que é protected e passa a depender de como o pai funciona por dentro. O GoF chama isso de reúso caixa-branca: você reusa vendo as tripas.
O que é composição
Composição é declarar que uma classe tem um objeto de outra, guardado como campo, e usa esse objeto para fazer o trabalho.
class Motor {
ligar(): string {
return "motor ligado";
}
}
class Carro {
private motor: Motor;
constructor(motor: Motor) {
this.motor = motor;
}
// Delegação explícita: o Carro decide o que expor e o que esconder.
ligar(): string {
return this.motor.ligar();
}
}Carro não é um Motor. Não pode ser usado onde se espera um Motor, e não herdou nada. Ele só usa.
O que muda em relação à herança:
- Só a interface pública. O
Carroenxerga doMotoro que qualquer um enxergaria. É o reúso caixa-preta. - Você escolhe o que expor. Nada vaza por acidente; o que aparece na fachada do
Carroé o que ele escreveu ali. - Dá para trocar em runtime. O motor entra pelo construtor, e pode ser outro amanhã.
- Dá para ter vários. Um
Carrotem motor, rodas e câmbio. Pais, você só tem um.
Injetar é compor
Vale dizer com todas as letras, porque nem sempre é óbvio: quando você injeta uma dependência, você está compondo. O objeto entra pelo construtor e vira um campo: a classe passa a ter aquilo. A injeção é só o jeito de o objeto chegar; a relação é composição.
Ou seja, se você já trabalha com caso de uso recebendo as dependências pelo construtor, você já escreve composição o dia inteiro, mesmo sem chamar assim.
A pergunta: capacidade ou identidade?
Na prática, o que eu faço é separar duas situações que parecem a mesma e não são.
Quando eu preciso de uma funcionalidade. Um caso de uso de criar conta precisa enviar e-mail. O que eu quero na real é adicionar uma propriedade que a classe não possuía, e para isso eu não preciso herdar nada. O caso de uso não vira um serviço de e-mail; ele passa a ter um.
Quando a classe é alguma coisa. Esse mesmo caso de uso é um caso de uso. Ele depende do contrato que obriga a existir um exec(). Aí sim eu quero a relação de tipo, porque quem chama precisa poder tratar todos os casos de uso do mesmo jeito.
É a separação da seção lá em cima, em uso: extends entrega reúso e identidade no mesmo pacote, e essas duas perguntas quase nunca aparecem juntas. Capacidade se resolve compondo. Identidade se resolve com contrato.
E "herdar o contrato" não é bem herdar
Um detalhe que muda o custo da decisão: quando o contrato de caso de uso é uma interface, implementar não é herdar implementação nenhuma. Você ganha a identidade e não ganha tripa junto: nada de método protegido, nada de dependência de como o pai funciona por dentro.
Se o contrato fosse uma classe abstrata com código dentro, aí sim voltariam os dois pacotes juntos, com o acoplamento que vem no meio.
E tem um custo que só aparece depois: a vaga de herança é uma só. Se você gastar o extends para ganhar uma capacidade, ela acabou, e quando o contrato de caso de uso chegar, não sobra lugar para ele.
O custo da herança é uma afirmação
Herdar tem um custo, e ele não é técnico: ao escrever extends, você está definindo que aquela classe é como a classe pai. Quando a gente herda alguma coisa, o que a gente quer dizer é que aquela classe faz parte daquele conjunto.
E afirmação de conjunto cobra. Como o post sobre LSP registra, tudo que é demonstrável sobre o pai passa a valer para o filho, e essa promessa vale para a classe inteira, para todo mundo que enxergar aquele tipo, para sempre.
Compor é bem diferente. Quando a gente compõe, não define nada de forma geral: só pega algo emprestado. O caso de uso pega emprestado o envio de e-mail e usa ali dentro. Ele não disse nada sobre o que ele é, não entrou em conjunto nenhum, não assumiu obrigação com ninguém.
Herança é uma afirmação. Composição é um empréstimo.
Por isso a conta não é a mesma. Emprestar não obriga a nada: se o objeto emprestado mudar de comportamento amanhã, quem compôs decide o que fazer com isso. Quem afirmou está preso à afirmação.
Como fica
Um caso de uso de criar conta que precisa mandar e-mail de boas-vindas.
Antes: herda o serviço de e-mail para ganhar enviar()
class ServicoDeEmail {
enviar(para: string, assunto: string): void {
console.log(`[email] para=${para} assunto=${assunto}`);
}
}
// Herdou só para reusar enviar(). Mas levou a identidade junto:
// agora CriarConta É um ServicoDeEmail, o que não é verdade.
class CriarConta extends ServicoDeEmail {
exec(email: string): string {
this.enviar(email, "Bem-vinda");
return `Conta criada: ${email}`;
}
}Três coisas quebraram de uma vez. O público do pai vazou para a fachada: quem tem um CriarConta pode chamar criarConta.enviar() de fora, e isso não devia ser parte do caso de uso. Trocar o provedor de e-mail virou trocar a superclasse. E a vaga de herança foi gasta: quando o contrato de caso de uso chegar, não sobra extends para ele.
Depois: implementa o contrato, injeta a capacidade
// Identidade: o que faz esta classe SER um caso de uso.
interface CasoDeUso<I, O> {
exec(input: I): O;
}
// Capacidade: o que a classe precisa TER para fazer o trabalho.
interface EnviadorDeEmail {
enviar(para: string, assunto: string): void;
}
class CriarConta implements CasoDeUso<string, string> {
private email: EnviadorDeEmail;
constructor(email: EnviadorDeEmail) {
this.email = email;
}
exec(email: string): string {
this.email.enviar(email, "Bem-vinda");
return `Conta criada: ${email}`;
}
}A fachada do CriarConta agora tem exatamente um método: exec. O e-mail é um campo, não parte do que ele é. Trocar o provedor é passar outro objeto no construtor, e no teste, um que não manda e-mail nenhum.
▸ Exemplo completo e executável
Em outras linguagens: o mesmo exemplo em Python e Java, com saída idêntica.
Princípios relacionados
- Liskov Substitution (LSP): o LSP é a conta que a herança cobra. Ao escrever
extends, você promete que tudo que vale para o pai vale para o filho; compor não cobra esse preço porque não promete identidade nenhuma. - Interface Segregation (ISP): é lá que aparece a ideia de que capacidades cruzadas não formam uma árvore, e sim um grid. Herança só sabe expressar árvore; é por isso que capacidade se resolve compondo.
- Dependency Inversion (DIP): injetar é compor. O DIP acrescenta o que deve entrar pelo construtor: o contrato, não a implementação.