Какова точная проблема с множественным наследованием? - PullRequest
112 голосов
/ 22 октября 2008

Я вижу людей, постоянно спрашивающих, следует ли включить множественное наследование в следующую версию C # или Java. Люди С ++, которым посчастливилось обладать такой способностью, говорят, что это все равно, что дать кому-то веревку, чтобы в итоге повеситься.

Что случилось с множественным наследованием? Есть ли конкретные образцы?

Ответы [ 11 ]

77 голосов
/ 22 октября 2008

Наиболее очевидная проблема связана с переопределением функций.

Допустим, есть два класса A и B, оба из которых определяют метод doSomething. Теперь вы определяете третий класс C, который наследуется от A и B, но вы не переопределяете метод doSomething.

Когда компилятор запустит этот код ...

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

... какую реализацию метода он должен использовать? Без каких-либо дополнительных разъяснений компилятор не сможет устранить неоднозначность.

Помимо переопределения, другой большой проблемой множественного наследования является расположение физических объектов в памяти.

Языки, такие как C ++ и Java и C #, создают фиксированную адресную компоновку для каждого типа объекта. Примерно так:

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

Когда компилятор генерирует машинный код (или байт-код), он использует эти числовые смещения для доступа к каждому методу или полю.

Множественное наследование делает его очень сложным.

Если класс C наследуется от A и B, компилятор должен решить, расположить ли данные в порядке AB или BA.

Но теперь представьте, что вы вызываете методы для B объекта. Это действительно просто B? Или это на самом деле C объект, вызываемый полиморфно через его B интерфейс? В зависимости от фактической идентичности объекта, физическое расположение будет различным, и невозможно определить смещение функции, вызываемой на сайте вызова.

Способ управления системой такого типа состоит в том, чтобы отказаться от подхода с фиксированной разметкой, позволяя каждому объекту запрашивать его макет до того, как попытается вызвать функции или получить доступ к их полям.

Итак ... короче говоря ... авторам компилятора очень тяжело поддерживать множественное наследование. Поэтому, когда кто-то, как Гвидо ван Россум, разрабатывает python, или когда Андерс Хейлсберг разрабатывает c #, они знают, что поддержка множественного наследования значительно усложнит реализацию компилятора, и, по-видимому, они не думают, что преимущество стоит затрат.

44 голосов
/ 15 ноября 2008

Проблемы, о которых вы, ребята, упоминаете, не так уж сложно решить. Например, Эйфелева делает это на отлично! (и без введения произвольного выбора или чего-либо еще)

например. если вы наследуете от A и B, оба имеют метод foo (), то, конечно, вам не нужен произвольный выбор в вашем классе C, унаследованный от A и B. Вы должны либо переопределить foo, чтобы было понятно, что будет использоваться, если вызывается c.foo (), или иначе вам придется переименовать один из методов в C. (он может стать bar ())

Также я думаю, что множественное наследование часто весьма полезно. Если вы посмотрите на библиотеки Eiffel, вы увидите, что они используются повсеместно, и лично я пропустил эту функцию, когда мне пришлось вернуться к программированию на Java.

26 голосов
/ 22 октября 2008

Алмазная проблема :

неоднозначность, которая возникает, когда два класса B и C наследуются от A, а класс D наследуется от B и C. Если существует метод в A, в котором B и C имеют переопределенные , а D - не переопределяйте его, тогда какую версию метода наследует D: версию B или версию C?

... Это называется «проблема алмаза» из-за формы диаграммы наследования классов в этой ситуации. В этом случае класс A находится сверху, а B и C - отдельно под ним, а D соединяет их вместе внизу, образуя ромбовидную форму ...

21 голосов
/ 22 октября 2008

Множественное наследование - это одна из тех вещей, которая используется не часто и может использоваться неправильно, но иногда необходима.

Я никогда не понимал, не добавляя функцию, просто потому, что она может быть использована не по назначению, когда нет хороших альтернатив. Интерфейсы не являются альтернативой множественному наследованию. С одной стороны, они не позволяют вам применять предварительные условия или постусловия. Как и любому другому инструменту, вам нужно знать, когда его использовать и как его использовать.

16 голосов
/ 22 октября 2008

скажем, у вас есть объекты A и B, которые оба наследуются C. A и B оба реализуют foo (), а C - нет. Я звоню C.foo (). Какая реализация выбрана? Есть и другие проблемы, но этот тип вещей очень важен.

5 голосов
/ 06 июля 2010

Я не думаю, что проблема с алмазами - это проблема, я бы рассмотрел эту софистику, ничего больше.

Худшая проблема, с моей точки зрения, с множественным наследованием - это RAD-жертвы и люди, которые утверждают, что являются разработчиками, но на самом деле застряли с половинным знанием (в лучшем случае).

Лично я был бы очень рад, если бы мог наконец-то что-то сделать в Windows Forms, как это (это не правильный код, но это должно дать вам идею):

public sealed class CustomerEditView : Form, MVCView<Customer>

Это основная проблема, которую я имею, не имея множественного наследования. Вы МОЖЕТЕ сделать что-то похожее с интерфейсами, но есть то, что я называю «кодом ***», именно этот болезненный повторяющийся код *** вам нужно написать в каждом из ваших классов, например, чтобы получить контекст данных.

На мой взгляд, не должно быть абсолютно ни малейшей необходимости в ЛЮБОМ повторении кода на современном языке.

5 голосов
/ 22 октября 2008

Основная проблема с множественным наследованием хорошо подытожена на примере tloach. При наследовании от нескольких базовых классов, которые реализуют одну и ту же функцию или поле, компилятор должен принять решение о том, какую реализацию наследовать.

Это становится хуже, когда вы наследуете от нескольких классов, которые наследуют от одного базового класса. (наследование алмазов, если вы рисуете дерево наследования, вы получаете форму ромба)

Эти проблемы не являются проблемой для компилятора. Но выбор, который должен сделать здесь компилятор, довольно произвольный, что делает код гораздо менее интуитивным.

Я считаю, что при хорошем проектировании ОО мне никогда не требуется множественное наследование. В тех случаях, когда это мне нужно, я обычно нахожу, что использую наследование для повторного использования функциональности, тогда как наследование подходит только для отношений «есть».

Существуют и другие методы, такие как миксины, которые решают те же проблемы и не имеют проблем, связанных с множественным наследованием.

3 голосов
/ 26 марта 2010

Common Lisp Object System (CLOS) - это еще один пример чего-то, что поддерживает MI, избегая при этом проблем в стиле C ++: наследованию дается разумное значение по умолчанию , в то же время предоставляя вам свободу явно определять, как скажем так, чтобы назвать поведение супер.

2 голосов
/ 23 мая 2016

Алмаз не является проблемой, если вы не используете что-либо вроде виртуального наследования C ++: при обычном наследовании каждый базовый класс напоминает поле члена (на самом деле они располагаются в ОЗУ таким образом ), давая вам некоторый синтаксический сахар и дополнительную возможность переопределять больше виртуальных методов. Это может навязать некоторую двусмысленность во время компиляции, но это обычно легко решить.

С другой стороны, при виртуальном наследовании он слишком легко выходит из-под контроля (а затем становится беспорядком). Рассмотрим в качестве примера диаграмму «сердца»:

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

В C ++ это совершенно невозможно: как только F и G объединяются в один класс, их A также объединяются, точка. Это означает, что вы никогда не можете считать базовые классы непрозрачными в C ++ (в этом примере вы должны создать A в H, поэтому вы должны знать, что он присутствует где-то в иерархии). На других языках это может работать, однако; например, F и G могли бы явно объявить A как «внутреннее», тем самым запретив последующее слияние и фактически сделав себя твердыми.

Еще один интересный пример ( не C ++ - специфичный):

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

Здесь только B использует виртуальное наследование. Таким образом, E содержит два B с одинаковыми A. Таким образом, вы можете получить указатель A*, который указывает на E, но вы не можете привести его к указателю B*, хотя объект равен на самом деле B, поскольку такое приведение неоднозначно и эта неоднозначность не может быть обнаружена во время компиляции (если компилятор не видит всю программу). Вот код теста:

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;
}

Более того, реализация может быть очень сложной (зависит от языка; см. Ответ Бенджисмита).

2 голосов
/ 08 апреля 2013

Одна из целей разработки таких фреймворков, как Java и .NET, - сделать так, чтобы код, скомпилированный для работы с одной версией предварительно скомпилированной библиотеки, одинаково хорошо работал с последующими версиями этой библиотеки, даже если эти последующие версии добавляют новые функции. В то время как обычная парадигма в таких языках, как C или C ++, заключается в распространении статически связанных исполняемых файлов, которые содержат все необходимые им библиотеки, парадигма в .NET и Java заключается в распространении приложений в виде наборов компонентов, которые «связаны» во время выполнения .

COM-модель, которая предшествовала .NET, пыталась использовать этот общий подход, но в действительности у нее не было наследования - вместо этого каждое определение класса эффективно определяло как класс, так и интерфейс с тем же именем, которое содержало все его открытые члены. , Экземпляры были типа класса, в то время как ссылки были типа интерфейса. Объявленный класс как производный от другого был эквивалентен объявлению класса как реализующего интерфейс другого, и требовал, чтобы новый класс повторно реализовал все открытые члены классов, из которых один произошел. Если Y и Z являются производными от X, а затем W происходит от Y и Z, не имеет значения, будут ли Y и Z реализовывать элементы X по-разному, потому что Z не сможет использовать их реализации - ему придется определить его своя. W может инкапсулировать экземпляры Y и / или Z и связывать свои реализации методов X с их помощью, но не будет никакой двусмысленности относительно того, что должны делать методы X - они будут делать то, что явно указано в коде Z.

Сложность в Java и .NET заключается в том, что коду разрешено наследовать элементы и иметь к ним доступ неявно относится к родительским элементам. Предположим, что у каждого были классы W-Z, связанные как выше:

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();
  }
}

Казалось бы, W.Test() должен создать экземпляр W, вызывающий реализацию виртуального метода Foo, определенного в X. Предположим, однако, что Y и Z на самом деле были в отдельно скомпилированном модуле, и хотя они были определены, как указано выше, когда X и W компилировались, позже они были изменены и перекомпилированы:

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

Теперь, каким должен быть эффект вызова W.Test()? Если бы перед распространением программу нужно было статически связать, то на этапе статического связывания можно было бы обнаружить, что, хотя программа не имела неоднозначности до того, как Y и Z были изменены, изменения в Y и Z сделали вещи двусмысленными, и компоновщик мог отказаться постройте программу, если или пока не решена такая неоднозначность. С другой стороны, вполне возможно, что человек, у которого есть и W, и новые версии Y и Z, - это тот, кто просто хочет запустить программу и не имеет исходного кода ни для одной из них. Когда запускается W.Test(), больше не будет понятно, что должен делать W.Test(), но пока пользователь не попытается запустить W с новой версией Y и Z, ни одна часть системы не сможет распознать, что существует проблема (если W не считался нелегитимным даже до изменения Y и Z).

...