Por que devo preferir composição a herança?


109

Eu sempre leio que a composição deve ser preferida à herança. Um post de blog sobre tipos diferentes , por exemplo, defende o uso da composição sobre a herança, mas não vejo como o polimorfismo é alcançado.

Mas tenho a sensação de que, quando as pessoas dizem que preferem composição, elas realmente querem dizer preferem uma combinação de composição e implementação de interface. Como você vai obter polimorfismo sem herança?

Aqui está um exemplo concreto em que eu uso herança. Como isso seria alterado para usar a composição e o que eu ganharia?

Class Shape
{
    string name;
  public:
    void getName();
    virtual void draw()=0;
}

Class Circle: public Shape
{
    void draw(/*draw circle*/);
}

57
Não, quando as pessoas dizem preferir composição eles realmente significam preferem composição, não nunca, nunca usar a herança . Toda a sua pergunta é baseada em uma premissa defeituosa. Use herança quando for apropriado.
Bill the Lizard

2
Você também pode querer dar uma olhada em programmers.stackexchange.com/questions/65179/…
vjones

2
Eu concordo com o projeto de lei, eu vejo o uso de herança uma prática comum no desenvolvimento de GUI.
Prashant Cholachagudda

2
Você quer usar a composição porque um quadrado é apenas a composição de dois triângulos, na verdade, acho que todas as formas, exceto elipse, são apenas composições de triângulos. Polimorfismo é sobre obrigações contratuais e 100% removido da herança. Quando o triângulo get é um monte de esquisitices, porque alguém queria torná-lo capaz de gerar pirâmides, se você herdar do Triangle, conseguirá tudo isso mesmo que nunca gere uma pirâmide 3d a partir do seu hexágono .
Jimmy Hoffa

2
@ BilltheLizard Eu acho que muitas pessoas que dizem isso realmente querem nunca usar herança, mas estão erradas.
immibis 6/11/2015

Respostas:


51

O polimorfismo não implica necessariamente herança. Freqüentemente, a herança é usada como um meio fácil de implementar o comportamento polimórfico, porque é conveniente classificar objetos comportamentais semelhantes como tendo estrutura e comportamento raiz totalmente comuns. Pense em todos os exemplos de códigos de carros e cães que você viu ao longo dos anos.

Mas e os objetos que não são iguais? Modelar um carro e um planeta seria muito diferente e, no entanto, ambos podem implementar o comportamento Move ().

Na verdade, você basicamente respondeu sua própria pergunta quando disse "But I have a feeling that when people say prefer composition, they really mean prefer a combination of composition and interface implementation.". O comportamento comum pode ser fornecido por meio de interfaces e um composto comportamental.

Quanto a qual é melhor, a resposta é um pouco subjetiva e realmente se resume a como você deseja que seu sistema funcione, o que faz sentido contextualmente e arquitetonicamente e como será fácil testar e manter.


Na prática, com que frequência o "Polimorfismo via Interface" aparece e é considerado normal (em vez de ser uma exploração da expressividade de uma língua). Eu apostaria que o polimorfismo via herança foi feito com um design cuidadoso, não uma consequência da linguagem (C ++) descoberta após sua especificação.
Samis 6/03

1
Quem chamaria move () em um planeta e um carro e consideraria o mesmo ?! A questão aqui é em que contexto eles devem ser capazes de se mover? Se ambos são objetos 2D em um jogo 2D simples, eles podem herdar a jogada, se eles são objetos em uma simulação massiva de dados, pode não fazer muito sentido deixá-los herdar da mesma base e, como conseqüência, você deve usar um interface
NikkyD

2
@ SamusArin Ele aparece em todos os lugares e é considerado perfeitamente normal em idiomas que suportam interfaces. O que você quer dizer com "exploração da expressividade de uma linguagem"? É para isso que existem interfaces .
Andres F.

@AndresF. Acabei de encontrar o "Polimorfismo via Interface" em um exemplo ao ler "Análise Orientada a Objetos e Design com Aplicações", que demonstrava o padrão Observador. Eu então percebi minha resposta.
Samis

@AndresF. Acho que minha visão estava um pouco cega em relação a isso por causa do uso do polimorfismo em meu último (primeiro) projeto. Eu tinha 5 tipos de registro que todos derivavam da mesma base. De qualquer forma, obrigado pela iluminação.
samis 7/03/2017

79

Preferir composição não é apenas polimorfismo. Embora isso faça parte, e você está certo de que (pelo menos em linguagens de tipo nominal) o que as pessoas realmente querem dizer é "preferem uma combinação de composição e implementação de interface". Mas, as razões para preferir a composição (em muitas circunstâncias) são profundas.

Polimorfismo é sobre uma coisa que se comporta de várias maneiras. Portanto, os genéricos / modelos são um recurso "polimórfico", na medida em que permitem que um único pedaço de código altere seu comportamento com os tipos. De fato, esse tipo de polimorfismo é realmente o melhor comportamento e geralmente é chamado de polimorfismo paramétrico, porque a variação é definida por um parâmetro.

Muitos idiomas fornecem uma forma de polimorfismo chamado "sobrecarga" ou polimorfismo ad hoc, em que vários procedimentos com o mesmo nome são definidos de maneira ad hoc e onde um é escolhido pelo idioma (talvez o mais específico). Esse é o tipo de polimorfismo menos bem comportado, já que nada conecta o comportamento dos dois procedimentos, exceto a convenção desenvolvida.

Um terceiro tipo de polimorfismo é o subtipo de polimorfismo . Aqui, um procedimento definido em um determinado tipo também pode funcionar em uma família inteira de "subtipos" desse tipo. Quando você implementa uma interface ou estende uma classe, geralmente declara sua intenção de criar um subtipo. Os subtipos verdadeiros são regidos pelo Princípio da Substituição de Liskov, que diz que se você pode provar algo sobre todos os objetos em um supertipo, pode provar isso sobre todas as instâncias em um subtipo. A vida fica perigosa, já que em linguagens como C ++ e Java, as pessoas geralmente têm suposições não-impostas e muitas vezes não documentadas sobre classes que podem ou não ser verdadeiras sobre suas subclasses. Ou seja, o código é escrito como se fosse mais provável do que realmente é, o que produz uma série de problemas quando você subtipo descuidado.

A herança é realmente independente do polimorfismo. Dada alguma coisa "T" que tem uma referência a si mesma, a herança acontece quando você cria uma nova coisa "S" a partir de "T" substituindo a referência de "T" a si mesma por uma referência a "S". Essa definição é intencionalmente vaga, já que a herança pode ocorrer em muitas situações, mas a mais comum é a subclasse de um objeto que tem o efeito de substituir o thisponteiro chamado por funções virtuais pelo thisponteiro do subtipo.

A herança é uma coisa perigosa, como todas as coisas muito poderosas que a herança tem o poder de causar estragos. Por exemplo, suponha que você substitua um método ao herdar de alguma classe: tudo está bem até que algum outro método dessa classe assuma que o método que você herdou se comporte de uma certa maneira, afinal, foi assim que o autor da classe original a projetou. . Você pode se proteger parcialmente contra isso declarando todos os métodos chamados por outro de seus métodos como privados ou não virtuais (final), a menos que sejam projetados para serem substituídos. Mesmo isso nem sempre é bom o suficiente. Às vezes, você pode ver algo assim (em pseudo Java, espero que seja legível para usuários de C ++ e C #)

interface UsefulThingsInterface {
    void doThings();
    void doMoreThings();
}

...

class WayOfDoingUsefulThings implements UsefulThingsInterface{
     private foo stuff;
     public final int getStuff();
     void doThings(){
       //modifies stuff, such that ...
       ...
     }
     ...
     void doMoreThings(){
       //ignores stuff
       ...
     }
 }

você acha isso adorável e tem seu próprio jeito de fazer "coisas", mas usa a herança para adquirir a capacidade de fazer "mais",

class MyUsefulThings extends WayOfDoingUsefulThings{
     void doThings {
        //my way
     }
}

E tudo está bem e bem. WayOfDoingUsefulThingsfoi projetado de tal maneira que a substituição de um método não altera a semântica de nenhum outro ... exceto espere, não, não foi. Parece que sim, mas doThingsmudou o estado mutável que importava. Portanto, mesmo que não tenha chamado funções de substituição,

 void dealWithStuff(WayOfDoingUsefulThings bar){
     bar.doThings()
     use(bar.getStuff());
 }

agora faz algo diferente do esperado quando você passa a MyUsefulThings. O que é pior, você pode nem saber que WayOfDoingUsefulThingsfez essas promessas. Talvez dealWithStuffvenha da mesma biblioteca WayOfDoingUsefulThingse getStuff()nem seja exportada pela biblioteca (pense nas classes de amigos em C ++). Pior ainda, você derrotou as verificações estáticas da linguagem sem perceber: dealWithStufftomou uma decisão WayOfDoingUsefulThingsapenas para garantir que ela tivesse uma getStuff()função que se comportasse de uma certa maneira.

Usando composição

class MyUsefulThings implements UsefulThingsInterface{
     private way = new WayOfDoingUsefulThings()
     void doThings() {
        //my way
     }
     void doMoreThings() {
        this.way.doMoreThings();
     }
}

devolve a segurança do tipo estático. Em geral, a composição é mais fácil de usar e mais segura que a herança ao implementar a subtipagem. Ele também permite que você substitua os métodos finais, o que significa que você deve se sentir à vontade para declarar tudo final / não virtual, exceto nas interfaces a grande maioria das vezes.

Em um mundo melhor, os idiomas inseriam automaticamente o clichê com uma delegationpalavra - chave. A maioria não, então a desvantagem são as classes maiores. No entanto, você pode fazer com que o seu IDE grave a instância de delegação para você.

Agora, a vida não é apenas sobre polimorfismo. Você não pode precisar subtipo o tempo todo. O objetivo do polimorfismo é geralmente a reutilização de código, mas não é a única maneira de atingir esse objetivo. Muitas vezes, faz sentido usar a composição, sem subtipo de polimorfismo, como uma maneira de gerenciar a funcionalidade.

Além disso, a herança comportamental tem seus usos. É uma das idéias mais poderosas da ciência da computação. É justo que, na maioria das vezes, bons aplicativos de POO possam ser gravados usando apenas usando herança e composições de interface. Os dois princípios

  1. Proibir herança ou design
  2. Preferir composição

são um bom guia pelos motivos acima e não incorrem em custos substanciais.


4
Boa resposta. Eu resumiria que tentar obter a reutilização de código com herança é um caminho errado. A herança é uma restrição muito forte (imho adicionando "poder" é uma analogia incorreta!) E cria uma forte dependência entre classes herdadas. Muitas dependências = código ruim :) Então herança (aka "se comporta como") normalmente brilha para interfaces unificadas (= escondendo a complexidade das classes herdadas), para qualquer outra coisa pensar duas vezes ou usar composição ...
MaR

2
Esta é uma boa resposta. +1. Pelo menos aos meus olhos, parece que a complicação extra pode ser um custo substancial, e isso me deixou pessoalmente um pouco hesitante em tirar um grande proveito da composição preferida. Especialmente quando você está tentando criar muitos códigos compatíveis com testes de unidade por meio de interfaces, composição e DI (eu sei que isso está adicionando outras coisas), parece muito fácil fazer alguém passar por vários arquivos diferentes para procurar alguns detalhes. Por que isso não é mencionado com mais frequência, mesmo quando se trata apenas da composição sobre o princípio do design de herança?
Panzercrisis

27

A razão pela qual as pessoas dizem isso é que os programadores iniciantes de OOP, recém-saídos de suas palestras sobre polimorfismo por herança, tendem a escrever grandes aulas com muitos métodos polimórficos e, em algum momento no caminho, acabam com uma bagunça inatingível.

Um exemplo típico vem do mundo do desenvolvimento de jogos. Suponha que você tenha uma classe base para todas as suas entidades de jogo - a nave espacial do jogador, monstros, balas, etc .; cada tipo de entidade possui sua própria subclasse. A abordagem herança usaria alguns métodos polimórficas, por exemplo update_controls(), update_physics(), draw(), etc., e aplicá-las para cada subclasse. No entanto, isso significa que você está acoplando funcionalidades não relacionadas: é irrelevante a aparência de um objeto para movê-lo e você não precisa saber nada sobre sua IA para desenhá-lo. Em vez disso, a abordagem composicional define várias classes base (ou interfaces), por exemplo EntityBrain(subclasses implementam AI ou entrada do jogador), EntityPhysics(subclasses implementam física do movimento) e EntityPainter(subclasses cuidam do desenho) e uma classe não polimórficaEntityque contém uma instância de cada. Dessa forma, você pode combinar qualquer aparência com qualquer modelo de física e qualquer IA, e como você os mantém separados, seu código também será muito mais limpo. Além disso, problemas como "Eu quero um monstro que se parece com o monstro do balão no nível 1, mas se comporta como o palhaço louco no nível 15" desaparecem: você simplesmente pega os componentes adequados e os cola.

Observe que a abordagem composicional ainda usa herança dentro de cada componente; embora, idealmente, você use apenas interfaces e suas implementações aqui.

"Separação de preocupações" é a frase-chave aqui: representar a física, implementar uma IA e desenhar uma entidade são três preocupações; combiná-las em uma entidade é a quarta. Com a abordagem composicional, cada preocupação é modelada como uma classe.


1
Tudo isso é bom, e preconizado no artigo vinculado na pergunta original. Eu adoraria (sempre que você tiver tempo) se você pudesse fornecer um exemplo simples envolvendo todas essas entidades, mantendo o polimorfismo (em C ++). Ou aponte-me para um recurso. Obrigado.
10238 MustafaM

Sei que a sua resposta é muito antiga, mas fiz exatamente a mesma coisa que você mencionou no meu jogo e agora busco a composição sobre a abordagem de herança.
Sneh

"Eu quero um monstro que se pareça com ..." como isso faz sentido? Uma interface não dá qualquer aplicação, você teria que copiar o código aparência e comportamento de uma forma ou de outra
NikkyD

1
@NikkyD Você toma MonsterVisuals balloonVisualse MonsterBehaviour crazyClownBehavioure instanciar um Monster(balloonVisuals, crazyClownBehaviour), ao lado dos Monster(balloonVisuals, balloonBehaviour)e Monster(crazyClownVisuals, crazyClownBehaviour)instâncias que foram instanciados no nível 1 e nível 15
Caleth

13

O exemplo que você deu é aquele em que a herança é a escolha natural. Eu não acho que alguém afirme que a composição é sempre uma escolha melhor do que a herança - é apenas uma diretriz que significa que muitas vezes é melhor montar vários objetos relativamente simples do que criar muitos objetos altamente especializados.

A delegação é um exemplo de uma maneira de usar a composição em vez da herança. A delegação permite modificar o comportamento de uma classe sem subclassificar. Considere uma classe que fornece uma conexão de rede, NetStream. Pode ser natural subclassificar o NetStream para implementar um protocolo de rede comum, para que você possa criar o FTPStream e o HTTPStream. Mas, em vez de criar uma subclasse HTTPStream muito específica para um único objetivo, por exemplo, UpdateMyWebServiceHTTPStream, geralmente é melhor usar uma instância antiga simples do HTTPStream junto com um delegado que saiba o que fazer com os dados que recebe desse objeto. Um dos motivos é que é melhor evitar a proliferação de classes que precisam ser mantidas, mas que você nunca poderá reutilizar.


11

Você verá muito este ciclo no discurso de desenvolvimento de software:

  1. Algum recurso ou padrão (vamos chamá-lo de "Padrão X") é descoberto como útil para algum propósito específico. As postagens do blog são escritas exaltando as virtudes do Padrão X.

  2. O hype leva algumas pessoas a pensar que você deve usar o Padrão X sempre que possível .

  3. Outras pessoas ficam aborrecidas ao ver o Padrão X usado em contextos onde não é apropriado e escrevem postagens no blog informando que você nem sempre deve usar o Padrão X e que isso é prejudicial em alguns contextos.

  4. A reação faz com que algumas pessoas acreditem que o Padrão X é sempre prejudicial e nunca deve ser usado.

Você verá esse ciclo de hype / folga acontecer com quase qualquer recurso de GOTOpadrões para SQL para NoSQL e, sim, herança. O antídoto é sempre considerar o contexto .

Tendo Circledescerá do Shapeé exatamente como a herança é suposto ser usado em linguagens OO apoio herança.

A regra prática "prefere composição a herança" é realmente enganosa sem contexto. Você deve preferir herança quando a herança é mais apropriada, mas prefere composição quando a composição é mais apropriada. A frase é direcionada às pessoas no estágio 2 do ciclo de hype, que pensam que a herança deve ser usada em qualquer lugar. Mas o ciclo avançou e hoje parece fazer com que algumas pessoas pensem que a herança é de alguma forma ruim por si só.

Pense nisso como um martelo versus uma chave de fenda. Você prefere uma chave de fenda a um martelo? A questão não faz sentido. Você deve usar a ferramenta apropriada para o trabalho, e tudo depende da tarefa que você precisa executar.

Ao utilizar nosso site, você reconhece que leu e compreendeu nossa Política de Cookies e nossa Política de Privacidade.
Licensed under cc by-sa 3.0 with attribution required.