Por que devo evitar herança múltipla em C ++?


Respostas:


259

A herança múltipla (abreviada como MI) cheira , o que significa que , geralmente , isso foi feito por motivos ruins e será revertido para o mantenedor.

Resumo

  1. Considere a composição de recursos, em vez de herança
  2. Desconfie do Diamante do Pavor
  3. Considere a herança de várias interfaces em vez de objetos
  4. Às vezes, herança múltipla é a coisa certa. Se for, use-o.
  5. Esteja preparado para defender sua arquitetura de herança múltipla nas revisões de código

1. Talvez composição?

Isso é verdadeiro para herança e, portanto, é ainda mais verdadeiro para herança múltipla.

Seu objeto realmente precisa herdar de outro? A Carnão precisa herdar de um Enginepara trabalhar, nem de um Wheel. A Cartem um Enginee quatro Wheel.

Se você usa herança múltipla para resolver esses problemas, em vez de composição, você fez algo errado.

2. O diamante do pavor

Normalmente, você tem uma classe A, em seguida, Be Cambos herdam A. E (não me pergunte por que) alguém decide que Ddeve herdar ambos de Be C.

Eu encontrei esse tipo de problema duas vezes em oito oito anos e é divertido ver isso por causa de:

  1. Quanto erro foi desde o início (em ambos os casos, Dnão deveria ter herdado de ambos Be C), porque essa era uma arquitetura ruim (de fato, Cnão deveria ter existido ...)
  2. Quanto os mantenedores estavam pagando por isso, porque em C ++, a classe pai Aestava presente duas vezes na classe neto De, portanto, atualizar um campo pai A::fieldsignificava atualizá-lo duas vezes (através B::fielde C::field) ou ter algo errado e travar silenciosamente mais tarde (insira um ponteiro B::fielde exclua C::field...)

Usar a palavra-chave virtual em C ++ para qualificar a herança evita o layout duplo descrito acima, se não é isso que você deseja, mas, de qualquer forma, na minha experiência, você provavelmente está fazendo algo errado ...

Na hierarquia de objetos, você deve tentar manter a hierarquia como uma árvore (um nó tem UM pai), não como um gráfico.

Mais sobre o diamante (editar 2017-05-03)

O verdadeiro problema com o Diamond of Dread em C ++ ( assumindo que o design seja sólido - revise seu código! ), É que você precisa fazer uma escolha :

  • É desejável que a classe Aexista duas vezes no seu layout e o que isso significa? Se sim, então, por todos os meios, herda duas vezes.
  • se deveria existir apenas uma vez, herda virtualmente.

Essa opção é inerente ao problema e, em C ++, ao contrário de outras linguagens, você pode realmente fazê-lo sem dogmas forçando seu design no nível da linguagem.

Mas, como todos os poderes, com esse poder vem a responsabilidade: revise seu projeto.

3. Interfaces

Herança múltipla de zero ou uma classe concreta e zero ou mais interfaces geralmente está OK, porque você não encontrará o Diamond of Dread descrito acima. De fato, é assim que as coisas são feitas em Java.

Normalmente, o que você quer dizer quando C herda Ae Bé que os usuários podem usar Ccomo se fosse um Ae / ou como se fosse um B.

No C ++, uma interface é uma classe abstrata que possui:

  1. todo seu método declarado virtual puro (com o sufixo = 0) (removido o 03/05/2017)
  2. nenhuma variável membro

A herança múltipla de zero a um objeto real e zero ou mais interfaces não são consideradas "fedorentas" (pelo menos, não tanto).

Mais sobre a Interface abstrata do C ++ (editar 2017-05-03)

Primeiro, o padrão NVI pode ser usado para produzir uma interface, porque o critério real é não ter estado (ou seja, nenhuma variável membro, exceto this). O objetivo da sua interface abstrata é publicar um contrato ("você pode me chamar assim e assim"), nada mais, nada menos. A limitação de ter apenas o método virtual abstrato deve ser uma opção de design, não uma obrigação.

Segundo, em C ++, faz sentido herdar virtualmente de interfaces abstratas (mesmo com o custo / indireção adicional). Caso contrário, e a herança da interface aparece várias vezes na sua hierarquia, você terá ambiguidades.

Em terceiro lugar, orientação a objetos é grande, mas não é a única verdade Out There TM em C ++. Use as ferramentas certas e lembre-se sempre de que existem outros paradigmas em C ++ que oferecem diferentes tipos de soluções.

4. Você realmente precisa de herança múltipla?

Às vezes sim.

Normalmente, a sua Cclasse é herdada de Ae Be Ae Bsão dois objetos independentes (ou seja, não na mesma hierarquia, nada em comum, conceitos diferentes, etc.).

Por exemplo, você poderia ter um sistema Nodescom coordenadas X, Y, Z, capaz de fazer muitos cálculos geométricos (talvez um ponto, parte de objetos geométricos) e cada Nó é um Agente Automatizado, capaz de se comunicar com outros agentes.

Talvez você já tenha acesso a duas bibliotecas, cada uma com seu próprio espaço para nome (outra razão para usar espaços para nome ... Mas você usa espaço para nome, não é?), Um sendo geoe outro sendoai

Então você tem sua própria own::Nodederivada de ai::Agente geo::Point.

Este é o momento em que você deve se perguntar se não deve usar a composição. Se own::Noderealmente é realmente a ai::Agente a geo::Point, a composição não funciona.

Você precisará de herança múltipla, own::Nodecomunicando-se com outros agentes de acordo com a posição deles em um espaço 3D.

(Você notará isso ai::Agente geo::Pointé completamente, totalmente, totalmente NÃO RETIRADO ... Isso reduz drasticamente o risco de herança múltipla)

Outros casos (editar 2017-05-03)

Existem outros casos:

  • usando herança (provavelmente privada) como detalhes de implementação
  • alguns idiomas C ++, como políticas, podem usar herança múltipla (quando cada parte precisa se comunicar com as outras this)
  • a herança virtual de std :: exception (a herança virtual é necessária para exceções? )
  • etc.

Às vezes você pode usar a composição, e às vezes o MI é melhor. O ponto é: você tem uma escolha. Faça com responsabilidade (e revise seu código).

5. Então, devo fazer herança múltipla?

Na maioria das vezes, na minha experiência, não. O MI não é a ferramenta certa, mesmo que pareça funcionar, porque pode ser usada pelo preguiçoso para agrupar recursos sem perceber as consequências (como criar um Cartanto um Enginequanto um Wheel).

Mas as vezes sim. E nesse momento, nada funcionará melhor que o MI.

Mas como o MI é fedorento, esteja preparado para defender sua arquitetura nas revisões de código (e defendê-lo é uma coisa boa, porque se você não conseguir defendê-lo, não deverá fazê-lo).


4
Eu diria que nem sempre é necessário e tão raramente útil que, mesmo que seja um ajuste perfeito, a economia em relação à delegação não compensará a confusão dos programadores que não estão acostumados a usá-lo, mas, de outra forma, um bom resumo.
Bill K

2
Eu acrescentaria ao ponto 5, que o código que usa MI deve ter um comentário ao lado explicando o motivo, para que você não precise explicá-lo verbalmente em uma revisão de código. Sem isso, alguém que não é seu revisor pode questionar seu bom senso quando vê seu código e você pode não ter oportunidade de defendê-lo.
Tim Abell

" porque você não encontrará o diamante do pavor descrito acima. " Por que não?
precisa

1
Eu uso o MI para o padrão de observador.
Calmarius

13
@ BillK: Claro que não é necessário. Se você tiver um idioma completo de Turning sem MI, poderá fazer qualquer coisa que um idioma com MI possa fazer. Então, sim, não é necessário. Sempre. Dito isto, pode ser uma ferramenta muito útil, e o chamado Dreaded Diamond ... Eu nunca entendi porque a palavra "temido" está lá. Na verdade, é bastante fácil argumentar e geralmente não é um problema.
Thomas Eding

145

De uma entrevista com Bjarne Stroustrup :

As pessoas dizem corretamente que você não precisa de herança múltipla, porque qualquer coisa que você possa fazer com herança múltipla também pode fazer com herança única. Você acabou de usar o truque de delegação que mencionei. Além disso, você não precisa de nenhuma herança, porque tudo o que você faz com uma única herança também pode fazer sem herança, encaminhando através de uma classe. Na verdade, você também não precisa de nenhuma classe, porque pode fazer tudo isso com ponteiros e estruturas de dados. Mas porque você iria querer fazer aquilo? Quando é conveniente usar as instalações de idiomas? Quando você prefere uma solução alternativa? Já vi casos em que a herança múltipla é útil e até casos em que a herança múltipla bastante complicada é útil. Geralmente, eu prefiro usar os recursos oferecidos pelo idioma para solucionar soluções


6
Existem alguns recursos do C ++ que eu perdi em C # e Java, embora tê-los tornaria o design mais difícil / complicado. Por exemplo, as garantias de imutabilidade de const- eu tive que escrever soluções alternativas desajeitadas (geralmente usando interfaces e composição) quando uma classe realmente precisava ter variantes mutáveis ​​e imutáveis. No entanto, nunca perdi uma herança múltipla e nunca senti que precisei escrever uma solução alternativa devido à falta desse recurso. Essa é a diferença. Em todos os casos que já vi, não usar o MI é a melhor opção de design, não uma solução alternativa.
BlueRaja # Danny Pflughoeft

24
+1: resposta perfeita: se você não quiser usá-lo, não o use.
Thomas Eding

38

Não há razão para evitá-lo e pode ser muito útil em situações. Você precisa estar ciente dos possíveis problemas.

O maior deles é o diamante da morte:

class GrandParent;
class Parent1 : public GrandParent;
class Parent2 : public GrandParent;
class Child : public Parent1, public Parent2;

Agora você tem duas "cópias" do GrandParent no Child.

O C ++ pensou nisso e permite que você faça uma herança virtual para contornar os problemas.

class GrandParent;
class Parent1 : public virtual GrandParent;
class Parent2 : public virtual GrandParent;
class Child : public Parent1, public Parent2;

Sempre revise seu design, verifique se você não está usando herança para economizar na reutilização de dados. Se você pode representar a mesma coisa com a composição (e normalmente você pode), essa é uma abordagem muito melhor.


21
E se você proibir herança e apenas a composição uso em vez disso, você sempre tem dois GrandParentem Child. Existe um medo do IM, porque as pessoas simplesmente pensam que podem não entender as regras de linguagem. Mas quem não pode obter essas regras simples também não pode escrever um programa não trivial.
curiousguy

1
Isso parece duro, as regras C ++ em geral são muito elaboradas. Tudo bem, mesmo que as regras individuais sejam simples, existem muitas, que tornam a coisa toda não simples, pelo menos para um ser humano seguir.
Zebrafish

11

Veja w: Herança Múltipla .

A herança múltipla recebeu críticas e, como tal, não é implementada em muitos idiomas. As críticas incluem:

  • Maior complexidade
  • A ambiguidade semântica frequentemente é resumida como o problema do diamante .
  • Não poder herdar explicitamente várias vezes de uma única classe
  • Ordem da herança alterando a semântica da classe.

A herança múltipla em linguagens com construtores de estilo C ++ / Java exacerba o problema de herança de construtores e encadeamento de construtores, criando problemas de manutenção e extensibilidade nessas linguagens. Objetos em relacionamentos de herança com métodos de construção bastante variados são difíceis de implementar sob o paradigma de encadeamento do construtor.

Maneira moderna de resolver isso para usar a interface (classe abstrata pura) como a interface COM e Java.

Eu posso fazer outras coisas no lugar disso?

Sim você pode. Eu vou roubar do GoF .

  • Programa para uma interface, não uma implementação
  • Preferir composição sobre herança

8

A herança pública é um relacionamento IS-A e, às vezes, uma classe é um tipo de várias classes diferentes, e às vezes é importante refletir isso.

"Mixins" também são algumas vezes úteis. Geralmente são classes pequenas, geralmente não herdadas de nada, fornecendo funcionalidade útil.

Desde que a hierarquia de herança seja razoavelmente superficial (como quase sempre deve ser) e bem gerenciada, é improvável que você tenha a temida herança de diamante. O diamante não é um problema em todos os idiomas que usam herança múltipla, mas o tratamento feito pelo C ++ é freqüentemente estranho e às vezes intrigante.

Embora eu tenha encontrado casos em que a herança múltipla é muito útil, eles são realmente bastante raros. Provavelmente, porque eu prefiro usar outros métodos de design quando realmente não preciso de herança múltipla. Prefiro evitar construções de linguagem confusas, e é fácil construir casos de herança em que é necessário ler muito bem o manual para descobrir o que está acontecendo.


6

Você não deve "evitar" a herança múltipla, mas deve estar ciente de problemas que possam surgir, como o 'problema dos diamantes' ( http://en.wikipedia.org/wiki/Diamond_problem ) e tratar com cuidado o poder que lhe é dado , como deveria com todos os poderes.


1
+1: assim como você não deve evitar indicadores; você só precisa estar ciente de suas ramificações. As pessoas precisam parar de falar frases (como "MI é mau", "não otimizar", "ir para o mal") que aprenderam na internet só porque alguns hippies disseram que isso era ruim para sua higiene. A pior parte é que eles nunca tentaram usar essas coisas e apenas as consideram ditas na Bíblia.
Thomas Eding

1
Concordo. Não "evite", mas saiba a melhor maneira de lidar com o problema. Talvez eu prefira usar características composicionais, mas o C ++ não tem isso. Talvez eu prefira usar a delegação automatizada 'manipula', mas o C ++ não tem isso. Então, eu uso a ferramenta geral e direta do MI para vários propósitos diferentes, talvez simultaneamente.
JDługosz

3

Correndo o risco de ficar um pouco abstrato, acho esclarecedor pensar em herança dentro do quadro da teoria das categorias.

Se pensarmos em todas as nossas classes e flechas entre elas, denotando relações de herança, algo assim

A --> B

significa que class Bderiva de class A. Observe que, dado

A --> B, B --> C

dizemos que C deriva de B, que deriva de A, então também se diz que C deriva de A, assim

A --> C

Além disso, dizemos que, para toda classe Aque Aderiva trivialmente A, nosso modelo de herança cumpre a definição de categoria. Em linguagem mais tradicional, temos uma categoria Classcom objetos de todas as classes e morfismos nas relações de herança.

Isso é um pouco de configuração, mas com isso vamos dar uma olhada no nosso Diamond of Doom:

C --> D
^     ^
|     |
A --> B

É um diagrama sombrio, mas serve. Então Dherda de todos A, Be C. Além disso, e chegando mais perto de abordar a questão do OP, Dtambém herda de qualquer superclasse de A. Podemos desenhar um diagrama

C --> D --> R
^     ^
|     |
A --> B
^ 
|
Q

Agora, os problemas associados ao Diamante da Morte aqui são quando Ce Bcompartilham alguns nomes de propriedades / métodos e as coisas ficam ambíguas; no entanto, se passarmos para um comportamento compartilhado A, a ambiguidade desaparecerá.

Coloque em termos categóricos, queremos A, Be Cseja tal que se Be Cherdar a partir de Qentão Apossam ser reescritos como subclasse de Q. Isso faz Aalgo chamado pushout .

Há também uma construção simétrica Dchamada de recuo . Esta é essencialmente a classe útil mais geral que você pode construir, que herda de ambos Be C. Ou seja, se você tiver outra classe Rherdada multiplicada de Be C, então Dé uma classe em que Rpode ser reescrita como subclasse de D.

Garantir que as pontas do diamante sejam recuos e empurrões nos oferece uma boa maneira de lidar genericamente com problemas de conflito ou manutenção de nomes que possam surgir de outra forma.

Nota A resposta de Paercebal inspirou isso, pois suas advertências estão implícitas no modelo acima, uma vez que trabalhamos na categoria completa Classe de todas as classes possíveis.

Eu queria generalizar seu argumento para algo que mostra como os relacionamentos de herança múltipla complicados podem ser poderosos e não problemáticos.

TL; DR Pense nos relacionamentos de herança em seu programa como formando uma categoria. Para evitar problemas no Diamond of Doom, faça pushouts de classes com herança múltipla e simetricamente, criando uma classe pai comum que é um retrocesso.


3

Nós usamos Eiffel. Temos um excelente MI. Não se preocupe. Sem problemas. Facilmente gerenciado. Há momentos para NÃO usar o MI. No entanto, é mais útil do que as pessoas percebem porque são: A) em uma linguagem perigosa que não a administra bem -OR- B) satisfeita com a maneira como elas trabalham com o MI há anos e anos -OR- C) outros motivos ( muito numeroso para listar, tenho certeza - veja as respostas acima).

Para nós, o uso do Eiffel, o MI é tão natural quanto qualquer outra coisa e outra boa ferramenta na caixa de ferramentas. Francamente, não estamos preocupados com o fato de ninguém mais estar usando Eiffel. Não se preocupe. Estamos felizes com o que temos e convidamos você a dar uma olhada.

Enquanto você estiver procurando: Observe especialmente a segurança do vácuo e a erradicação da desreferenciação de ponteiro nulo. Enquanto todos dançamos ao redor do MI, seus indicadores estão se perdendo! :-)


2

Toda linguagem de programação tem um tratamento ligeiramente diferente da programação orientada a objetos com prós e contras. A versão do C ++ enfatiza diretamente o desempenho e tem a desvantagem de que é perturbadoramente fácil escrever código inválido - e isso é verdade para a herança múltipla. Como conseqüência, há uma tendência de afastar os programadores desse recurso.

Outras pessoas abordaram a questão de que herança múltipla não serve. Mas vimos alguns comentários que implicam mais ou menos que o motivo para evitá-lo é porque não é seguro. Bem, sim e não.

Como costuma acontecer no C ++, se você seguir uma diretriz básica, poderá usá-la com segurança sem precisar "olhar por cima do ombro" constantemente. A idéia principal é distinguir um tipo especial de definição de classe chamada "mix-in"; A classe é um mix-in se todas as suas funções de membro forem virtuais (ou virtuais puras). Você poderá herdar de uma única classe principal e quantos "mix-ins" quiser - mas você deve herdar mixins com a palavra-chave "virtual". por exemplo

class CounterMixin {
    int count;
public:
    CounterMixin() : count( 0 ) {}
    virtual ~CounterMixin() {}
    virtual void increment() { count += 1; }
    virtual int getCount() { return count; }
};

class Foo : public Bar, virtual public CounterMixin { ..... };

Minha sugestão é que, se você pretende usar uma classe como uma classe combinada, também adota uma convenção de nomenclatura para facilitar a qualquer pessoa que estiver lendo o código ver o que está acontecendo e verificar se está seguindo as regras da diretriz básica. . E você descobrirá que funciona muito melhor se os seus mix-ins também tiverem construtores padrão, apenas por causa da maneira como as classes base virtuais funcionam. E lembre-se de tornar todos os destruidores virtuais também.

Observe que meu uso da palavra "mix-in" aqui não é o mesmo que a classe de modelo parametrizada (consulte este link para uma boa explicação), mas acho que é um uso justo da terminologia.

Agora, não quero dar a impressão de que essa é a única maneira de usar a herança múltipla com segurança. É apenas uma maneira fácil de verificar.


2

Você deve usá-lo com cuidado, existem alguns casos, como o problema do diamante , em que as coisas podem complicar-se.

texto alternativo
(fonte: learncpp.com )


12
Este é um mau exemplo, porque uma copiadora deve ser composta por um scanner e uma impressora, e não herdar deles.
Svante

2
De qualquer forma, a Printernem deveria ser a PoweredDevice. A Printeré para impressão, não para gerenciamento de energia. A implementação de uma impressora específica pode ter que fazer algum gerenciamento de energia, mas esses comandos de gerenciamento de energia não devem ser expostos diretamente aos usuários da impressora. Não consigo imaginar nenhum uso real dessa hierarquia no mundo real.
Curiousguy


1

Além do padrão de diamante, a herança múltipla tende a dificultar a compreensão do modelo de objeto, o que, por sua vez, aumenta os custos de manutenção.

A composição é intrinsecamente fácil de entender, compreender e explicar. Pode ser entediante escrever código, mas um bom IDE (já faz alguns anos desde que trabalhei com o Visual Studio, mas certamente os Java IDEs têm ótimas ferramentas de automação de atalhos de composição) deve superar esse obstáculo.

Além disso, em termos de manutenção, o "problema do diamante" surge também em instâncias de herança não literais. Por exemplo, se você tem A e B e sua classe C os estende, e A tem o método 'makeJuice' que produz suco de laranja e você o extende para fazer suco de laranja com um toque de limão: o que acontece quando o designer de ' B 'adiciona um método' makeJuice 'que gera corrente elétrica? 'A' e 'B' podem ser "pais" compatíveis no momento , mas isso não significa que sempre serão assim!

No geral, a máxima tendência para evitar a herança, e principalmente a herança múltipla, é sólida. Como todas as máximas, há exceções, mas você precisa garantir que haja um sinal de néon verde piscando apontando para todas as exceções que você codificar (e treinar seu cérebro para que, sempre que vir essas árvores de herança, você desenhe em seu próprio néon verde piscante) ) e verifique se tudo faz sentido de vez em quando.


what happens when the designer for 'B' adds a 'makeJuice' method which generates and electrical current?Uhhh, você obtém um erro de compilação, é claro (se o uso for ambíguo).
Thomas Eding

1

O principal problema com o MI de objetos concretos é que raramente você tem um objeto que legitimamente deveria "Ser um A e ser um B", portanto, raramente é a solução correta por motivos lógicos. Com muito mais frequência, você tem um objeto C que obedece "C pode atuar como um A ou um B", que pode ser alcançado por herança e composição da interface. Mas não se engane - a herança de várias interfaces ainda é MI, apenas um subconjunto dela.

Para C ++, em particular, a principal fraqueza do recurso não é a EXISTÊNCIA real de herança múltipla, mas algumas construções que ele permite quase sempre estão malformadas. Por exemplo, herdando várias cópias do mesmo objeto, como:

class B : public A, public A {};

está mal formado POR DEFINIÇÃO. Traduzido para o inglês, este é "B é um A e um A". Então, mesmo na linguagem humana, há uma ambiguidade severa. Você quis dizer "B tem 2 Como" ou apenas "B é um A" ?. Permitir esse código patológico e, pior ainda, torná-lo um exemplo de uso, não favoreceu o C ++ quando se tratava de manter o recurso em idiomas sucessores.


0

Você pode usar a composição em preferência à herança.

O sentimento geral é que a composição é melhor e é muito bem discutida.


4
-1: The general feeling is that composition is better, and it's very well discussed.Isso não significa que a composição seja melhor.
Thomas Eding

Exceto que é, na verdade, melhor.
Ali Afshar

12
Exceto nos casos em que não é melhor.
Thomas Eding

0

são necessários 4/8 bytes por classe envolvida. (Um ponteiro por classe).

Isso pode nunca ser uma preocupação, mas se um dia você tiver uma estrutura de microdados instanciada bilhões de vezes, será.


1
Certeza sobre isso? Normalmente, qualquer herança séria exigirá um ponteiro em cada membro da classe, mas isso necessariamente se ajusta às classes herdadas? Mais exatamente, quantas vezes você já teve bilhões de estruturas de dados pequeninas?
22630 David Thornley

3
@DavidThornley " Quantas vezes você já teve bilhões de estruturas de dados pequeninas? " Quando você está inventando argumentos contra o MI.
curiousguy
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.