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

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

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

Ответ 1

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

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

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

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

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

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

Такие языки, как C++, Java и С#, создают фиксированный макет на основе адреса для каждого типа объекта. Что-то вроде этого:

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

Ответ 2

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

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

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

Ответ 3

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

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

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

Ответ 4

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

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

Ответ 5

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

Ответ 6

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

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

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

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

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

Ответ 7

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

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

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

public sealed class CustomerEditView : Form, MVCView<Customer>

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

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

Ответ 8

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

Ответ 9

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

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

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

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

Ответ 10

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

Ответ 11

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

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

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

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

Еще один интересный пример ( не С++):

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

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