В чем именно заключается проблема множественного наследования?

121

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

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

Влад Гудим
источник
54
Я бы просто упомянул, что C ++ отлично подходит для того, чтобы дать вам достаточно веревки, чтобы повеситься.
tloach
1
В качестве альтернативы множественному наследованию, которое решает (и, как мне кажется , решает) многие из тех же проблем, посмотрите Traits ( iam.unibe.ch/~scg/Research/Traits )
Беван,
52
Я думал, что C ++ дает достаточно веревки, чтобы прострелить себе ногу.
KeithB
6
Этот вопрос, похоже, предполагает, что существует проблема с MI в целом, тогда как я нашел множество языков, в которых MI используется нечасто. Конечно, существуют проблемы с обработкой MI некоторыми языками, но я не знаю, что у MI вообще есть серьезные проблемы.
Дэвид Торнли

Ответы:

86

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

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

benjismith
источник
62
Эм, Python поддерживает MI
Неманья Трифунович
26
Это не очень убедительные аргументы - фиксированная компоновка совсем не сложна для большинства языков; в C ++ это сложно, потому что память не является непрозрачной, и поэтому вы можете столкнуться с некоторыми трудностями с арифметическими предположениями указателя. В языках, где определения классов статичны (например, в java, C # и C ++), множественные конфликты имен наследования могут быть запрещены во время компиляции (и C # в любом случае делает это с интерфейсами!).
Eamon Nerbonne,
10
ОП просто хотел разобраться в проблемах, и я объяснил их, не редактируя лично. Я только что сказал, что разработчики языка и разработчики компилятора «по-видимому, не считают, что выгода стоит затрат».
Benjismith
12
« Самая очевидная проблема связана с переопределением функции ». Это не имеет ничего общего с переопределением функции. Это простая проблема двусмысленности.
curiousguy 01
10
В этом ответе содержится неверная информация о Guido и Python, поскольку Python поддерживает MI. «Я решил, что пока я собираюсь поддерживать наследование, я мог бы с таким же успехом поддерживать простую версию множественного наследования». - Гвидо ван Россум python-history.blogspot.com/2009/02/… - Кроме того, в компиляторах довольно часто встречается разрешение неоднозначности (переменные могут быть локальными для блокировки, локальными для функции, локальными для включающей функции, члены объекта, члены класса, globals и т. д.), я не понимаю, как дополнительная область видимости будет иметь значение.
Маркус
46

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

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

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


источник
26
Я согласен. Основная причина, по которой люди ненавидят MI, та же, что и в случае с JavaScript или статической типизацией: большинство людей когда-либо использовали только очень плохие его реализации - или использовали их очень плохо. Судить MI по C ++ - все равно что судить OOP по PHP или автомобили по Pintos.
Jörg W Mittag
2
@curiousguy: MI вводит еще один набор сложностей, о которых нужно беспокоиться, как и многие «особенности» C ++. Просто потому, что это однозначно, нелегко с ним работать или отлаживать. Удаление этой цепочки, так как она вышла не по теме, и вы все равно ее сорвали.
Guvante
4
@Guvante единственная проблема с MI на любом языке - это дерьмовые программисты, которые думают, что могут прочитать учебник и внезапно узнать язык.
Miles Rout
2
Я бы сказал, что возможности языка не только в сокращении времени написания кода. Они также увеличивают выразительность языка и повышают производительность.
Miles Rout
4
Кроме того, ошибки возникают из-за MI только тогда, когда идиоты его неправильно используют.
Miles Rout
27

Проблема с бриллиантами :

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

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

Дж. Фрэнсис
источник
4
который имеет решение, известное как виртуальное наследование. Проблема только в том, что вы делаете это неправильно.
Ян Голдби 05
1
@IanGoldby: Виртуальное наследование - это механизм для решения части проблемы, если нет необходимости разрешать повышающие и понижающие преобразования с сохранением идентичности среди всех типов, от которых произошел экземпляр или для которых он является заменяемым . Учитывая X: B; Y: В; и Z: X, Y; предположим, что someZ является экземпляром Z. При виртуальном наследовании (B) (X) someZ и (B) (Y) someZ являются разными объектами; учитывая любой из них, один может получить другой с помощью понижающего и восходящего преобразования, но что, если у одного есть a someZи он хочет привести его к, Objectа затем к B? Что Bон получит?
supercat
2
@supercat Возможно, но подобные проблемы носят в основном теоретический характер и в любом случае могут быть обнаружены компилятором. Важно знать, какую проблему вы пытаетесь решить, а затем использовать лучший инструмент, игнорируя догмы людей, которые предпочли бы не беспокоиться о понимании «почему?»
Ян Голдби
@IanGoldby: о подобных проблемах может сигнализировать компилятор, только если он имеет одновременный доступ ко всем рассматриваемым классам. В некоторых фреймворках любое изменение базового класса всегда требует перекомпиляции всех производных классов, но возможность использовать более новые версии базовых классов без необходимости перекомпилировать производные классы (для которых может не быть исходного кода) является полезной функцией. для фреймворков, которые могут это предоставить. Кроме того, проблемы не только теоретические. Многие классы в .NET полагаются на тот факт, что приведение любого ссылочного типа к этому типу Objectи обратно ...
supercat
3
@IanGoldby: Достаточно честно. Я хотел сказать, что разработчики Java и .NET не просто «ленились», решив не поддерживать обобщенный MI; поддержка обобщенного ИМ помешала бы их структуре поддерживать различные аксиомы, обоснованность которых более полезна для многих пользователей, чем ИМ.
supercat
21

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

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

KeithB
источник
Можете ли вы объяснить, почему они не позволяют выполнять предварительные и пост-условия?
Yttrill 02
2
@Yttrill, потому что интерфейсы не могут иметь реализации методов. Куда вы положили assert?
curiousguy 01
1
@curiousguy: вы используете язык с подходящим синтаксисом, который позволяет вам помещать предварительные и постусловия непосредственно в интерфейс: никаких «утверждений» не требуется. Пример от Феликса: fun div (num: int, den: int when den! = 0): int expect result == 0 подразумевает num == 0;
Yttrill
@Yttrill Хорошо, но некоторые языки, например Java, не поддерживают ни MI, ни «предварительные и постусловия непосредственно в интерфейсе».
curiousguy
Он не используется часто, потому что он недоступен, и мы не знаем, как его правильно использовать. Если вы взглянете на какой-нибудь код Scala, вы увидите, как вещи становятся обычными и могут быть реорганизованы в черты (хорошо, это не MI, но доказывает мою точку зрения).
santiagobasulto
16

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

tloach
источник
1
Но это не совсем конкретный пример. Если и у A, и у B есть функция, весьма вероятно, что C также потребуется собственная реализация. В противном случае он все еще может вызывать A :: foo () в своей собственной функции foo ().
Питер Кюне,
@Quantum: А что, если этого не произойдет? Легко увидеть проблему с одним уровнем наследования, но если у вас много уровней и у вас есть случайная функция, которая где-то вдвое больше, это становится очень сложной проблемой.
tloach
Кроме того, дело не в том, что вы не можете вызвать метод A или B, указав, какой из них вы хотите, дело в том, что если вы не укажете, тогда нет хорошего способа выбрать один. Я не уверен, как C ++ справляется с этим, но если кто-то знает, можно ли упомянуть об этом?
tloach
2
@tloach - если C не разрешает двусмысленность, компилятор может обнаружить эту ошибку и вернуть ошибку времени компиляции.
Eamon Nerbonne,
@Earmon - из-за полиморфизма, если foo () виртуальный, компилятор может даже не знать во время компиляции, что это будет проблемой.
tloach
5

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

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

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

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

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

Mendelt
источник
4
Скомпилированному не нужно делать произвольный выбор - он может просто выйти из строя. В C # какой тип ([..bool..]? "test": 1)?
Eamon Nerbonne,
4
В C ++ компилятор никогда не делает таких произвольных выборов: определение класса, в котором компилятору потребуется сделать произвольный выбор, является ошибкой.
curiousguy
5

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

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

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

public sealed class CustomerEditView : Form, MVCView<Customer>

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

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

Полный Тьюринга
источник
Я склонен согласиться, но только склонен: в любом языке требуется некоторая избыточность для обнаружения ошибок. В любом случае вам следует присоединиться к команде разработчиков Felix, потому что это основная цель. Например, все объявления являются взаимно рекурсивными, и вы можете видеть как вперед, так и назад, поэтому вам не нужны прямые объявления (область видимости устанавливается по умолчанию, как метки C goto).
Yttrill 02
Я полностью согласен с этим - я просто столкнулся с аналогичной проблемой здесь . Люди говорят о проблеме с алмазами, они религиозно ее цитируют, но, на мой взгляд, ее так легко избежать. (Нам не всем нужно писать наши программы, как они написали библиотеку iostream.) Множественное наследование должно логически использоваться, когда у вас есть объект, которому необходимы функциональные возможности двух различных базовых классов, не имеющих перекрывающихся функций или имен функций. В умелых руках это инструмент.
jedd.ahyoung
3
@Turing Complete: без повторения кода: это хорошая идея, но это неверно и невозможно. Существует огромное количество шаблонов использования, и мы хотим абстрагировать общие в библиотеке, но абстрагировать их все - безумие, потому что даже если бы мы могли, семантическая нагрузка запоминания всех имен слишком высока. Вам нужен хороший баланс. Не забывайте, что повторение - это то, что придает вещам структуру (шаблон подразумевает избыточность).
Yttrill
@unchmeat317: Тот факт, что код обычно не должен быть написан таким образом, чтобы «ромб» представлял проблему, не означает, что разработчик языка / фреймворка может просто игнорировать проблему. Если фреймворк обеспечивает, что повышающее и понижающее преобразование сохраняет идентичность объекта, желает позволить более поздним версиям класса увеличивать количество типов, для которых он может быть заменен, не являясь критическим изменением, и желает разрешить создание типа во время выполнения, Я не думаю, что он может позволить наследование нескольких классов (в отличие от наследования интерфейса) при достижении вышеуказанных целей.
supercat
3

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

Фрэнк Шеарар
источник
Да, CLOS - одна из самых совершенных объектных систем с момента зарождения современных вычислений, может быть, даже давно :)
rostamn739
2

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

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

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

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

Кристиан Лемер
источник
1
Поддержка C ++ для MI не « очень эффективна и продуктивна »?
curiousguy 01
1
На самом деле он несколько сломан в том смысле, что не вписывается в другие возможности C ++. Присваивание не работает должным образом с наследованием, не говоря уже о множественном наследовании (проверьте действительно плохие правила). Правильное создание ромбов настолько сложно, что комитет по стандартам испортил иерархию исключений, чтобы сделать ее простой и эффективной, вместо того, чтобы делать это правильно. На более старом компиляторе, который я использовал в то время, когда я тестировал это, и несколько миксинов MI и реализации базовых исключений стоили более мегабайта кода и занимали 10 минут, чтобы скомпилировать ... только определения.
Yttrill
1
Бриллианты - хороший тому пример. В Eiffel алмаз разрешен явно. Например, представьте, что ученик и учитель наследуют от Person. У человека есть календарь, поэтому и ученик, и учитель унаследуют этот календарь. Если вы создаете ромб, создав TeachingStudent, который наследуется и от Teacher, и от Student, вы можете решить переименовать один из унаследованных календарей, чтобы оба календаря были доступны отдельно, или решить объединить их, чтобы он вел себя как Person. Множественное наследование можно реализовать красиво, но это требует тщательного проектирования, желательно с самого начала ...
Кристиан Лемер
1
Компиляторы Eiffel должны провести глобальный программный анализ, чтобы эффективно реализовать эту модель MI. Для вызовов полиморфных методов они используют либо преобразователи диспетчера, либо разреженные матрицы, как описано здесь . Это не очень хорошо сочетается с отдельной компиляцией C ++ и функцией загрузки классов C # и Java.
cyco130
2

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

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

Supercat
источник
2

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

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

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

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

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

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

Здесь Bиспользуется только виртуальное наследование. So Eсодержит два Bодинаковых s 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;
}

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

число ноль
источник
Это настоящая проблема с МИ. Программистам могут понадобиться разные разрешения в рамках одного класса. Решение для всего языка ограничит возможности и заставит программистов создавать кладжи, чтобы программа работала правильно.
shawnhcorey