Какова точная проблема с множественным наследованием?

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

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

11 ответов

Решение

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

Допустим, есть два класса 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#, они знают, что поддержка множественного наследования значительно усложнит реализацию компилятора, и, по-видимому, они не думают, что это преимущество стоит затрат.

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

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

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

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

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

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

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

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

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

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

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

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

public sealed class CustomerEditView : Form, MVCView<Customer>

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

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

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

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

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

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

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

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

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

Язык Eiffel поддерживает множественное наследование без ограничений очень эффективным и продуктивным способом, но язык с самого начала был разработан для его поддержки.

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

Я думаю, что поддержка множественного наследования или нет - это больше вопрос выбора, вопрос приоритетов. Более сложная функция занимает больше времени, чтобы быть реализованы правильно и оперативно и может быть более спорным. Реализация C++ может быть причиной, по которой множественное наследование не было реализовано в C# и Java...

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

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

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

В C++ это совершенно невозможно: как только F а также G объединены в один класс, их As тоже объединены, точка. Это означает, что вы никогда не можете считать базовые классы непрозрачными в 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;
}

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

Одна из целей разработки таких фреймворков, как 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 заключается в том, что коду разрешено наследовать элементы, и доступ к ним неявно ссылается на родительские элементы. Предположим, что у одного были классы WZ, связанные как выше:

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),

Другие вопросы по тегам