Qual é o problema exato da herança múltipla?


121

Eu posso ver pessoas perguntando o tempo todo se a herança múltipla deve ser incluída na próxima versão do C # ou Java. As pessoas de C ++, que têm a sorte de ter essa capacidade, dizem que isso é como dar a alguém uma corda para se enforcar.

Qual é o problema da herança múltipla? Existem amostras de concreto?


54
Eu apenas mencionaria que o C ++ é ótimo para fornecer corda suficiente para você se enforcar.
tloach

1
Para uma alternativa para herança múltipla que os endereços (e, resolve IMHO) muitos dos mesmos problemas, olhe para Traços ( iam.unibe.ch/~scg/Research/Traits )
Bevan

52
Pensei que o C ++ lhe desse corda suficiente para dar um tiro no próprio pé.
KeithB 25/10/08

6
Essa pergunta parece assumir que há um problema com o MI em geral, enquanto eu encontrei muitos idiomas em que o MI está em uso casual. Certamente há problemas com o manuseio de MI em certos idiomas, mas não sei que o MI em geral tem problemas significativos.
David Thornley

Respostas:


86

O problema mais óbvio é a substituição da função.

Digamos que tenha duas classes Ae B, ambas definam um método doSomething. Agora você define uma terceira classe C, que herda de ambos Ae B, mas não substitui o doSomethingmétodo.

Quando o compilador propaga esse código ...

C c = new C();
c.doSomething();

... qual implementação do método ele deve usar? Sem mais esclarecimentos, é impossível para o compilador resolver a ambiguidade.

Além de substituir, o outro grande problema com herança múltipla é o layout dos objetos físicos na memória.

Idiomas como C ++, Java e C # criam um layout fixo baseado em endereço para cada tipo de objeto. Algo assim:

class A:
    at offset 0 ... "abc" ... 4 byte int field
    at offset 4 ... "xyz" ... 8 byte double field
    at offset 12 ... "speak" ... 4 byte function pointer

class B:
    at offset 0 ... "foo" ... 2 byte short field
    at offset 2 ... 2 bytes of alignment padding
    at offset 4 ... "bar" ... 4 byte array pointer
    at offset 8 ... "baz" ... 4 byte function pointer

Quando o compilador gera código de máquina (ou código de código), ele usa esses deslocamentos numéricos para acessar cada método ou campo.

A herança múltipla torna muito complicado.

Se a classe Cherda de ambos Ae B, o compilador deve decidir se deseja layout dos dados em ABordem ou em BAordem.

Mas agora imagine que você está chamando métodos em um Bobjeto. É realmente apenas um B? Ou é realmente um Cobjeto chamado polimorficamente, por meio de sua Binterface? Dependendo da identidade real do objeto, o layout físico será diferente e é impossível saber o deslocamento da função a ser chamada no local da chamada.

A maneira de lidar com esse tipo de sistema é abandonar a abordagem de layout fixo, permitindo que cada objeto seja consultado por seu layout antes de tentar invocar as funções ou acessar seus campos.

Então ... para encurtar a história ... é uma dor de cabeça para os autores de compiladores apoiarem herança múltipla. Então, quando alguém como Guido van Rossum cria python, ou quando Anders Hejlsberg cria c #, eles sabem que o suporte à herança múltipla tornará as implementações do compilador significativamente mais complexas e, presumivelmente, elas não acham que o benefício vale o custo.


62
Ehm, Python suporta MI
Nemanja Trifunovic

26
Esses argumentos não são muito convincentes - o layout fixo não é complicado na maioria dos idiomas; no C ++, é complicado porque a memória não é opaca e, portanto, você pode ter alguma dificuldade com as suposições aritméticas dos ponteiros. Nas linguagens em que as definições de classe são estáticas (como em java, C # e C ++), vários conflitos de nomes de herança podem ser proibidos em tempo de compilação (e o C # faz isso de qualquer maneira com as interfaces!).
Eamon Nerbonne

10
O OP só queria entender as questões, e eu as expliquei sem editorializar pessoalmente sobre o assunto. Acabei de dizer que os designers de linguagem e os implementadores de compilador "presumivelmente não acham que o benefício vale o custo".
benjismith 24/09/09

12
" O problema mais óbvio é a substituição de funções. " Isso não tem nada a ver com a substituição de funções. É um problema simples de ambiguidade.
curiousguy

10
Esta resposta tem algumas informações erradas sobre Guido e Python, já que o Python suporta MI. "Decidi que, desde que eu fosse apoiar a herança, eu também poderia apoiar uma versão simplória da herança múltipla". - Guido van Rossum python-history.blogspot.com/2009/02/… - Além disso, a resolução de ambiguidade é bastante comum em compiladores (variáveis ​​podem ser locais para bloquear, local para função, local para função de fechamento, membros de objetos, membros de classe, globais, etc.), não vejo como um escopo extra faria diferença.
Marcus

46

Os problemas que vocês mencionam não são tão difíceis de resolver. De fato, por exemplo, Eiffel faz isso perfeitamente bem! (e sem introduzir escolhas arbitrárias ou o que seja)

Por exemplo, se você herda de A e B, ambos com o método foo (), é claro que não deseja uma escolha arbitrária em sua classe C herdando ambos A e B. Você deve redefinir foo para que fique claro o que será usado se c.foo () for chamado ou você deve renomear um dos métodos em C. (pode se tornar bar ())

Também acho que a herança múltipla geralmente é bastante útil. Se você olhar para as bibliotecas de Eiffel, verá que ele é usado em todo o lugar e, pessoalmente, eu perdi o recurso quando tive que voltar à programação em Java.


26
Concordo. O principal motivo pelo qual as pessoas odeiam o MI é o mesmo que o JavaScript ou a digitação estática: a maioria das pessoas só utilizou implementações muito ruins - ou o utilizou muito mal. Julgar MI por C ++ é como julgar OOP por PHP ou julgar automóveis por Pintos.
Jörg W Mittag

2
@curiousguy: O MI apresenta mais um conjunto de complicações para se preocupar, assim como muitos dos "recursos" do C ++. Só porque é inequívoco, não facilita o trabalho ou a depuração. Removendo essa cadeia desde que saiu do tópico e você a interrompeu de qualquer maneira.
Guvante 29/03

4
@ Guvante O único problema com o MI em qualquer idioma são os programadores de merda que pensam que podem ler um tutorial e de repente conhecer um idioma.
Miles Rout

2
Eu argumentaria que os recursos de linguagem não se destinam apenas a reduzir o tempo de codificação. Eles também aumentam a expressividade de uma linguagem e aumentam o desempenho.
Miles Rout

4
Além disso, os erros ocorrem apenas no MI quando os idiotas o usam incorretamente.
Rout de milhas

27

O problema do diamante :

uma ambiguidade que surge quando duas classes B e C herdam de A e a classe D herda de B e C. Se houver um método em A que B e C tenham substituído , e D não o substitua, qual versão do método D herda: a de B ou a de C?

... É chamado de "problema do diamante" devido à forma do diagrama de herança de classe nessa situação. Nesse caso, a classe A está na parte superior, B e C separadamente abaixo dela, e D une as duas na parte inferior para formar uma forma de diamante ...


4
que tem uma solução conhecida como herança virtual. É apenas um problema se você fizer errado.
Ian Goldby

1
@IanGoldby: A herança virtual é um mecanismo para solucionar parte do problema, se não for necessário permitir upcasts e downcasts de preservação de identidade entre todos os tipos dos quais uma instância é derivada ou para a qual é substituível . Dado X: B; Y: B; e Z: X, Y; suponha que someZ seja uma instância de Z. Com herança virtual, (B) (X) someZ e (B) (Y) someZ são objetos distintos; dado um, um poderia obter o outro através de um downcast e upcast, mas e se alguém tem um someZe quer lançá-lo para Objecte depois B? Qual Bserá?
Supercat

2
@supercat Talvez, mas problemas como esse são amplamente teóricos e, em qualquer caso, podem ser sinalizados pelo compilador. O importante é estar ciente de qual problema você está tentando resolver e, em seguida, usar a melhor ferramenta, ignorando o dogma de pessoas que preferem não se preocupar em entender 'por quê?'
Ian Goldby

@IanGoldby: Problemas como esse só podem ser sinalizados pelo compilador se ele tiver acesso simultâneo a todas as classes em questão. Em algumas estruturas, qualquer alteração em uma classe base sempre exigirá uma recompilação de todas as classes derivadas, mas a capacidade de usar versões mais recentes das classes base sem precisar recompilar as classes derivadas (para as quais talvez não haja código-fonte) é um recurso útil para estruturas que podem fornecê-lo. Além disso, os problemas não são apenas teóricos. Muitas classes em .NET contar com o fato de que um elenco de qualquer tipo de referência para Objecte de volta para esse tipo ...
supercat

3
@IanGoldby: Justo. Meu argumento era que os implementadores de Java e .NET não estavam apenas "preguiçosos" ao decidir não dar suporte ao MI generalizado; apoiar o MI generalizado teria impedido que sua estrutura sustentasse vários axiomas cuja validade é mais útil para muitos usuários do que o MI seria.
Supercat

21

A herança múltipla é uma daquelas coisas que não é usada com freqüência e pode ser mal utilizada, mas às vezes é necessária.

Eu nunca entendi não adicionar um recurso, apenas porque ele pode ser mal utilizado, quando não há boas alternativas. As interfaces não são uma alternativa à herança múltipla. Por um lado, eles não permitem impor pré-condições ou pós-condições. Assim como qualquer outra ferramenta, você precisa saber quando é apropriado usá-lo e como usá-lo.


Você pode explicar por que eles não permitem impor condições pré e pós?
Yttrill

2
@Yttrill porque as interfaces não podem ter implementações de métodos. Onde você coloca o assert?
curiousguy

1
@curiousguy: você usa um idioma com uma sintaxe adequada que permite colocar as condições pré e pós diretamente na interface: não é necessário "afirmar". Exemplo de Felix: fun div (num: int, den: int quando den! = 0): int expect result == 0 implica num == 0;
Yttrill

@Yttrill OK, mas algumas linguagens, como Java, não suportam MI ou "pré e pós-condições diretamente na interface".
curiousguy

Geralmente, não é usado porque não está disponível e não sabemos como usá-lo bem. Se você der uma olhada em algum código Scala, verá como as coisas começam a ser comuns e podem ser refatoradas para traços (Ok, não é MI, mas prova meu ponto).
Santiagobasulto

16

digamos que você tenha objetos A e B que são herdados por C. A e B implementam foo () e C não. Eu chamo C.foo (). Qual implementação é escolhida? Existem outras questões, mas esse tipo de coisa é grande.


1
Mas esse não é realmente um exemplo concreto. Se A e B tiverem uma função, é muito provável que C também precise de sua própria implementação. Caso contrário, ele ainda pode chamar A :: foo () em sua própria função foo ().
Peter Kühne

@Quantum: E se isso não acontecer? É fácil ver o problema com um nível de herança, mas se você tiver muitos níveis e tiver alguma função aleatória que esteja em algum lugar duas vezes, isso se tornará um problema muito difícil.
tloach

Além disso, o ponto não é que você não pode chamar o método A ou B especificando qual deles deseja, o ponto é que, se você não especificar, não haverá uma boa maneira de escolher um. Não sei ao certo como o C ++ lida com isso, mas se alguém souber, poderia mencionar isso?
tloach

2
@ abordagem - se C não resolver a ambiguidade, o compilador poderá detectar esse erro e retornar um erro em tempo de compilação.
Eamon Nerbonne

@Earmon - Devido ao polimorfismo, se foo () for virtual, o compilador pode nem saber em tempo de compilação que isso será um problema.
tloach

5

O principal problema da herança múltipla está bem resumido no exemplo da abordagem. Ao herdar de várias classes base que implementam a mesma função ou campo, o compilador precisa tomar uma decisão sobre qual implementação herdar.

Isso fica pior quando você herda de várias classes que herdam da mesma classe base. (herança de diamante, se você desenhar a árvore de herança, obtém uma forma de diamante)

Esses problemas não são realmente problemáticos para um compilador superar. Mas a escolha que o compilador deve fazer aqui é bastante arbitrária, isso torna o código muito menos intuitivo.

Acho que, ao fazer um bom projeto de OO, nunca preciso de herança múltipla. Nos casos em que preciso, geralmente descubro que tenho usado a herança para reutilizar a funcionalidade, enquanto a herança é apropriada apenas para relações "é-a".

Existem outras técnicas, como mixins, que resolvem os mesmos problemas e não têm os problemas que a herança múltipla possui.


4
O compilado não precisa fazer uma escolha arbitrária - ele pode simplesmente errar. Em C #, qual é o tipo de ([..bool..]? "test": 1)?
Eamon Nerbonne

4
No C ++, o compilador nunca faz escolhas arbitrárias: é um erro definir uma classe na qual o compilador precisaria fazer uma escolha arbitrária.
curiousguy

5

Eu não acho que o problema do diamante seja um problema, eu consideraria esse sofisma, nada mais.

O pior problema, do meu ponto de vista, com herança múltipla é a RAD - vítimas e pessoas que afirmam ser desenvolvedores, mas na realidade estão presas a meio conhecimento (na melhor das hipóteses).

Pessoalmente, eu ficaria muito feliz se finalmente pudesse fazer algo no Windows Forms como este (não é o código correto, mas deve lhe dar a idéia):

public sealed class CustomerEditView : Form, MVCView<Customer>

Este é o principal problema que tenho em não ter herança múltipla. Você PODE fazer algo semelhante com interfaces, mas existe o que eu chamo de "código ***", é esse doloroso e repetitivo *** que você precisa escrever em cada uma de suas classes para obter um contexto de dados, por exemplo.

Na minha opinião, não deve haver absolutamente nenhuma necessidade, nem um pouco, de QUALQUER repetição de código em uma linguagem moderna.


Tenho a tendência de concordar, mas apenas a tendência: existe alguma redundância em qualquer idioma para detectar erros. De qualquer forma, você deve se juntar à equipe de desenvolvedores Felix, porque esse é um objetivo principal. Por exemplo, todas as declarações são mutuamente recursivas, e você pode ver para frente e para trás, para que não precise de declarações para frente (o escopo é definido por conjunto, como os rótulos C goto).
Yttrill

Concordo plenamente com isso - acabei de encontrar um problema semelhante aqui . As pessoas falam sobre o problema do diamante, citam-no religiosamente, mas, na minha opinião, é tão facilmente evitado. (Nem todos precisamos escrever nossos programas como eles criaram a biblioteca iostream.) Herança múltipla deve ser usada logicamente quando você tem um objeto que precisa da funcionalidade de duas classes base diferentes que não possuem funções ou nomes de funções sobrepostos. Nas mãos certas, é uma ferramenta.
jedd.ahyoung

3
@Turing Complete: wrt sem repetição de código: é uma boa idéia, mas é incorreta e impossível. Há um grande número de padrões de uso e desejamos abstrair os comuns na biblioteca, mas é uma loucura abstrair todos eles, porque mesmo que pudéssemos carregar a semântica lembrando todos os nomes, é muito alto. O que você quer é um bom equilíbrio. Não se esqueça que a repetição é o que dá estrutura às coisas (o padrão implica redundância).
Yttrill

@ lunchmeat317: O fato de que o código geralmente não deve ser escrito de tal forma que o 'diamante' represente um problema, não significa que um designer de linguagem / estrutura possa simplesmente ignorar o problema. Se uma estrutura fornecer que upcasting e downcasting preservem a identidade do objeto, deseje permitir que versões posteriores de uma classe aumentem o número de tipos pelos quais ela pode ser substituída sem que isso seja uma mudança de quebra e deseje permitir a criação do tipo em tempo de execução, Eu não acho que isso poderia permitir a herança de várias classes (em oposição à herança de interface), enquanto atendia aos objetivos acima.
Supercat

3

O Common Lisp Object System (CLOS) é outro exemplo de algo que suporta MI, evitando os problemas do estilo C ++: a herança recebe um padrão sensato , enquanto ainda permite a liberdade de decidir explicitamente como exatamente, digamos, chamar o comportamento de um super .


Sim, CLOS é um dos mais sistemas de objetos superiores desde o início da computação moderna em talvez até muito tempo desde passado :)
rostamn739

2

Não há nada errado na herança múltipla em si. O problema é adicionar herança múltipla a um idioma que não foi projetado com herança múltipla em mente desde o início.

A língua Eiffel está suportando herança múltipla sem restrições de uma maneira muito eficiente e produtiva, mas a linguagem foi projetada desde o início para suportá-la.

Esse recurso é complexo de implementar para desenvolvedores de compiladores, mas parece que essa desvantagem pode ser compensada pelo fato de que um bom suporte de herança múltipla pode evitar o suporte de outros recursos (ou seja, não há necessidade de Interface ou Método de Extensão).

Eu acho que apoiar a herança múltipla ou não é mais uma questão de escolha, uma questão de prioridades. Um recurso mais complexo leva mais tempo para ser corretamente implementado e operacional e pode ser mais controverso. A implementação C ++ pode ser a razão pela qual a herança múltipla não foi implementada em C # e Java ...


1
O suporte de C ++ para MI não é " muito eficiente e produtivo "?
Curiousguy

1
Na verdade, está um pouco quebrado no sentido em que não se encaixa com outros recursos do C ++. A atribuição não funciona corretamente com a herança, muito menos a herança múltipla (confira as regras realmente ruins). Criar diamantes corretamente é tão difícil que o comitê de padrões estragou a hierarquia de exceções para mantê-lo simples e eficiente, em vez de fazê-lo corretamente. Em um compilador mais antigo que eu estava usando no momento, testei esse e alguns mixins de MI e implementações de exceções básicas custam mais de um megabyte de código e demoraram 10 minutos para compilar .. apenas as definições.
Yttrill

1
Diamantes é um bom exemplo. Em Eiffel, o diamante é resolvido explicitamente. Por exemplo, imagine Aluno e Professor ambos herdando de Person. A Pessoa tem um calendário, portanto, Aluno e Professor herdarão esse calendário. Se você criar um diamante criando um TeachingStudent que seja herdado do Professor e do Aluno, poderá mudar o nome de um dos calendários herdados para manter os dois calendários disponíveis separadamente ou mesclá-los para que se comporte mais como Pessoa. Herança múltipla pode ser implementada muito bem, mas requer um projeto cuidadoso uma preferência desde o início ...
Christian Lemer

1
Os compiladores Eiffel precisam fazer uma análise global do programa para implementar esse modelo de MI com eficiência. Para chamadas de método polimórficas, eles usam thunks de expedidor ou matrizes esparsas, conforme explicado aqui . Isso não combina bem com a compilação separada do C ++ e o recurso de carregamento de classe dos C # e Java.
cyco130

2

Um dos objetivos de design de estruturas como Java e .NET é possibilitar que o código compilado funcione com uma versão de uma biblioteca pré-compilada, que funcione igualmente bem com as versões subseqüentes dessa biblioteca, mesmo que essas versões subseqüentes adicione novos recursos. Enquanto o paradigma normal em linguagens como C ou C ++ é distribuir executáveis ​​vinculados estaticamente que contêm todas as bibliotecas de que precisam, o paradigma no .NET e Java é distribuir aplicativos como coleções de componentes "vinculados" no tempo de execução .

O modelo COM que precedeu o .NET tentou usar essa abordagem geral, mas na verdade não tinha herança - em vez disso, cada definição de classe definiu efetivamente uma classe e uma interface com o mesmo nome que continha todos os seus membros públicos. As instâncias eram do tipo classe, enquanto as referências eram do tipo interface. Declarar uma classe como derivada de outra era equivalente a declarar uma classe como implementando a interface da outra e exigia que a nova classe reimplemente todos os membros públicos das classes das quais uma derivou. Se Y e Z derivam de X e W deriva de Y e Z, não importa se Y e Z implementam os membros de X de maneira diferente, porque Z não poderá usar suas implementações - terá que definir sua próprio. W pode encapsular instâncias de Y e / ou Z,

A dificuldade em Java e .NET é que o código tem permissão para herdar membros e ter acesso a eles referindo-se implicitamente aos membros pais. Suponha que alguém tenha classes WZ relacionadas como acima:

class X { public virtual void Foo() { Console.WriteLine("XFoo"); }
class Y : X {};
class Z : X {};
class W : Y, Z  // Not actually permitted in C#
{
  public static void Test()
  {
    var it = new W();
    it.Foo();
  }
}

Parece W.Test()que a criação de uma instância de W chama a implementação do método virtual Foodefinido em X. Suponha, no entanto, que Y e Z estejam realmente em um módulo compilado separadamente e, embora tenham sido definidos como acima quando X e W foram compilados, posteriormente foram alterados e recompilados:

class Y : X { public override void Foo() { Console.WriteLine("YFoo"); }
class Z : X { public override void Foo() { Console.WriteLine("ZFoo"); }

Agora, qual deve ser o efeito de ligar W.Test()? Se o programa tiver que ser vinculado estaticamente antes da distribuição, o estágio de link estático poderá discernir que, embora o programa não tenha ambiguidade antes que Y e Z sejam alterados, as alterações em Y e Z tornaram as coisas ambíguas e o vinculador pode se recusar a crie o programa, a menos ou até que essa ambiguidade seja resolvida. Por outro lado, é possível que a pessoa que possui W e as novas versões de Y e Z seja alguém que simplesmente queira executar o programa e não possua nenhum código-fonte. Quando W.Test()executado, não fica mais claro o queW.Test() deveria funcionar, mas até que o usuário tentasse executar W com a nova versão de Y e Z, nenhuma parte do sistema reconheceria que havia um problema (a menos que W fosse considerado ilegítimo antes mesmo das alterações em Y e Z) .


2

O diamante não é um problema, desde que você não use nada como herança virtual C ++: na herança normal, cada classe base se assemelha a um campo membro (na verdade, eles são dispostos na RAM dessa maneira), fornecendo um pouco de açúcar sintático e um capacidade extra de substituir mais métodos virtuais. Isso pode impor alguma ambiguidade no tempo de compilação, mas geralmente é fácil de resolver.

Por outro lado, com a herança virtual, ela sai do controle com muita facilidade (e depois se torna uma bagunça). Considere como exemplo um diagrama de "coração":

  A       A
 / \     / \
B   C   D   E
 \ /     \ /
  F       G
    \   /
      H

Em C ++ é totalmente impossível: assim que Fe Gsão mesclados em uma única classe, seus As também são mesclados, ponto final. Isso significa que você nunca pode considerar classes de base opaco em C ++ (neste exemplo, você tem que construir Aem Hque você tem que saber que presente em algum lugar na hierarquia). Em outros idiomas, pode funcionar, no entanto; por exemplo, Fe Gpoderia declarar explicitamente A como "interno", proibindo assim a consequente fusão e efetivamente se solidificando.

Outro exemplo interessante ( não específico do C ++):

  A
 / \
B   B
|   |
C   D
 \ /
  E

Aqui, apenas Busa herança virtual. Portanto, Econtém dois Bs que compartilham o mesmo A. Dessa forma, é possível obter um A*ponteiro que aponte para E, mas não é possível convertê-lo em um B*ponteiro, embora o objeto seja realmente B ambíguo e essa ambiguidade não possa ser detectada em tempo de compilação (a menos que o compilador veja o programa inteiro). Aqui está o código do teste:

struct A { virtual ~A() {} /* so that the class is polymorphic */ };
struct B: virtual A {};
struct C: B {};
struct D: B {};
struct E: C, D {};

int main() {
        E data;
        E *e = &data;
        A *a = dynamic_cast<A *>(e); // works, A is unambiguous
//      B *b = dynamic_cast<B *>(e); // doesn't compile
        B *b = dynamic_cast<B *>(a); // NULL: B is ambiguous
        std::cout << "E: " << e << std::endl;
        std::cout << "A: " << a << std::endl;
        std::cout << "B: " << b << std::endl;
// the next casts work
        std::cout << "A::C::B: " << dynamic_cast<B *>(dynamic_cast<C *>(e)) << std::endl;
        std::cout << "A::D::B: " << dynamic_cast<B *>(dynamic_cast<D *>(e)) << std::endl;
        std::cout << "A=>C=>B: " << dynamic_cast<B *>(dynamic_cast<C *>(a)) << std::endl;
        std::cout << "A=>D=>B: " << dynamic_cast<B *>(dynamic_cast<D *>(a)) << std::endl;
        return 0;
}

Além disso, a implementação pode ser muito complexa (depende da linguagem; veja a resposta de benjismith).


Esse é o verdadeiro problema com o MI. Os programadores podem precisar de diferentes resoluções dentro de uma classe. Uma solução em todo o idioma limitaria o que é possível e forçaria os programadores a criar kludges para que o programa funcionasse corretamente.
precisa saber é o seguinte
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.