Наследование. Множественное и виртуальное наследование / FAQ C++
Как организован этот раздел?
В данном разделе рассматривается широкий спектр вопросов/ответов, начиная от проблем высокого уровня / стратегии / проектирования и заканчивая проблемами низкого уровня / тактики / программирования. Мы проходимся по ним именно в таком порядке.
Убедитесь, что вы понимаете вопросы высокого уровня / стратегии / проектирования. Слишком многие программисты беспокоятся о том, чтобы «это» компилировалось, не решив сначала, действительно ли они хотят «это». Поэтому, пожалуйста, прочтите несколько первых ответов на часто задаваемые вопросы в этом разделе, прежде чем беспокоиться о (важных) технических деталях в последних нескольких ответах на часто задаваемые вопросы.
Действительно ли нам нужно множественное наследование?
На самом деле, нет. Мы можем обойтись без множественного наследования, используя обходные пути, точно так же, как мы можем обойтись без одиночного наследования, используя обходные пути. Мы можем даже обойтись без классов, используя обходные пути. Язык C является доказательством этого утверждения. Однако каждый современный язык со статической проверкой типов и наследованием предоставляет некоторую форму множественного наследования. В C++ абстрактные классы часто служат интерфейсами, и у класса может быть много интерфейсов. Другие языки – часто называемые «не MI» (без множественного наследования) – просто имеют отдельное название для своего эквивалента чисто абстрактного класса: интерфейс. Причина, по которой языки обеспечивают наследование (как одиночное, так и множественное), заключается в том, что наследование, поддерживаемое языком, обычно превосходит обходные пути (например, использование функций переадресации к подобъектам или отдельно выделенным объектам) для простоты программирования, для обнаружения логических проблем, для удобства поддержки, и часто для производительности.
Мне сказали, что я никогда не должен использовать множественное наследование. Это правильно?
Хм...
Меня действительно беспокоит, когда люди думают, что знают, что лучше для решения вашей задачи, даже если они никогда не видели вашу задачу! Как кто-то может знать, что множественное наследование не поможет вам в достижении ваших целей, не зная ваших целей?!
В следующий раз, когда кто-то скажет вам, что вы никогда не должны использовать множественное наследование, посмотрите ему прямо в глаза и скажите: «Не всем подходит один размер». Если они скажут что-то о своем неудачном опыте работы над своим проектом, посмотрите им в глаза и повторите, на этот раз медленнее: «Не всем подходит один размер».
Люди, которые придерживаются универсальных правил, предполагают, что могут принимать за вас решения проектирования, не зная ваших требований. Они не знают, куда вы собираетесь, но знают, как вам туда добраться.
Не верьте ответу от того, кто не знает вопроса.
Так бывают случаи, когда множественное наследование – это неплохо?!
Конечно, бывают!
Вы не будете использовать его всё время. Вы можете даже не использовать его регулярно. Но бывают ситуации, когда решение с множественным наследованием дешевле создавать, отлаживать, тестировать, оптимизировать и поддерживать, чем решение без множественного наследования. Если множественное наследование сокращает ваши расходы, улучшает график, снижает риски и дает хорошие результаты, воспользуйтесь им.
С другой стороны, то, что оно есть, не означает, что вы должны его использовать. Как и любой другой инструмент, используйте для работы то, что лучше подходит. Если помогает множественное наследование, используйте его; если нет, то не используйте. И если у вас есть плохой опыт работы с ним, не вините инструмент. Возьмите на себя ответственность за свои ошибки и скажите: «Я использовал для работы неправильный инструмент; это я был виноват.» Не говорите: «Поскольку это не помогло решению моей задачи, это плохо для всех задач во всех отраслях и во все времена». Хорошие рабочие никогда не обвиняют свои инструменты.
Какие существуют правила использования множественного наследования?
Эмпирическое правило множественного наследования №1: Используйте наследование только в том случае, если это приведет к удалению операторов if
/switch
из вызывающего кода. Обоснование: это уводит людей от «бездумного» наследования (одиночного или множественного), что часто бывает хорошо. Бывают случаи, когда вы будете использовать наследование без динамического связывания, но будьте осторожны: если вы будете делать это часто, возможно, вы заразились неправильным мышлением. В частности, наследование не предназначено для повторного использования кода. Иногда вы получаете небольшое повторное использование кода через наследование, но основная цель наследования – динамическое связывание, которое служит для гибкости. Для повторного использования кода предназначена композиция, наследование – для гибкости. Это эмпирическое правило не относится к множественному наследованию, но является общим для всех видов наследования.
Эмпирическое правило множественного наследования №2: При использовании множественного наследования особенно старайтесь использовать абстрактные базовые классы. В частности, большинство классов выше класса соединения (и часто сам класс соединения) должны быть абстрактными базовыми классами. В этом контексте «абстрактный базовый класс» не означает просто «класс, по крайней мере, с одной чисто виртуальной функцией»; на самом деле это означает чисто абстрактный базовый класс, то есть класс с минимально возможным объемом данных (часто без них) и с большинством (часто всеми) методов, являющихся чисто виртуальными. Обоснование: это правило помогает избежать ситуаций, когда вам нужно наследовать данные или код двумя путями, а также побуждает вас правильно использовать наследование. Эта вторая цель трудноуловима, но чрезвычайно важна. В частности, если вы привыкли использовать наследование для повторного использования кода (в лучшем случае, это сомнительное решение; смотрите выше), это практическое правило уведет вас от множественного наследования и, возможно (надеюсь!), в первую очередь, от наследования для повторного использования кода. Другими словами, это эмпирическое правило подталкивает людей к «наследованию для заменяемости интерфейса», что всегда безопасно, и от «наследования, просто чтобы помочь мне писать меньше кода в моем производном классе», что часто (не всегда) небезопасно.
Эмпирическое правило множественного наследования №3: Рассмотрите паттерн «мост» или вложенное обобщение как возможные альтернативы множественному наследованию. Это не означает, что с множественным наследованием что-то «не так»; это просто означает, что существует как минимум три альтернативы, и мудрый разработчик проверяет все альтернативы, прежде чем выбрать лучшую.
Можете ли вы привести пример, демонстрирующий приведенные выше рекомендации?
Предположим, у вас есть наземный транспорт, водный транспорт, воздушный транспорт и космический транспорт (для этого примера забудьте всю концепцию транспортных средств амфибий; представьте, что их не существует). Предположим, у нас также есть разные источники энергии: газовые, ветряные, ядерные, педальные и т.д. Мы могли бы использовать множественное наследование, чтобы связать всё вместе, но прежде чем мы это сделаем, мы должны задать несколько сложных вопросов:
- Потребуется ли пользователям
LandVehicle
(наземного транспортного средства) иметь ссылкуVehicle&
, которая ссылается на объектLandVehicle
? В частности, будут ли пользователи вызывать методы ссылки наVehicle
и ожидать, что фактическая реализация этих методов будет специфичной дляLandVehicles
? - То же самое для
GasPoweredVehicles
(транспортного средства, питаемого газом): захотят ли пользователи ссылку наVehicle
, которая ссылается на объектGasPoweredVehicle
, и, в частности, захотят ли они вызывать методы для этой ссылки наVehicle
и ожидать, что реализации будут переопределены классомGasPoweredVehicle
?
Если оба ответа – «да», то, вероятно, лучшим вариантом будет множественное наследование. Но прежде чем закрыть дверь к альтернативам, вот еще несколько «критериев принятия решения». Предположим, имеется N географических пространств (суша, вода, воздух, космос и т.д.) и M источников энергии (газовые, ядерные, ветровые, педальные и т.д.). Существует как минимум три варианта проектирования: паттерн «мост», вложенное обобщение и множественное наследование. У каждого есть свои плюсы и минусы:
- С помощью паттерна «мост» вы создаете две отдельные иерархии: абстрактный базовый класс
Vehicle
имеет производные классыLandVehicle
,WaterVehicle
и т.д., а абстрактный базовый классEngine
имеет производные классыGasPowered
,NuclearPowered
и т.д. Затем уVehicle
(транспортного средства) естьEngine*
(то есть указатель на двигатель), а пользователи во время выполнения смешивают и сопоставляют транспортные средства и двигатели. Преимущество этого в том, что вам нужно написать только N+M производных классов, что означает, что всё растет очень плавно: когда вы добавляете новое географическое пространство (увеличение N) или тип двигателя (увеличение M), вам нужно добавить только один новый производный класс. Однако в этом случае также есть несколько недостатков: у вас есть только N+M производных классов, что означает, что у вас есть не более N+M переопределений и, следовательно, N+M конкретных алгоритмов / структур данных. Если вам в конечном итоге нужны разные алгоритмы и/или структуры данных в комбинациях N×M, вам придется много работать, чтобы это реализовать, и вам, вероятно, лучше будет использовать что-то другое, чем чистый паттерн моста. Еще одна вещь, которую мост не решает для вас, – это избавление от бессмысленных комбинаций, таких как космические аппараты с педальным приводом. Вы можете решить эту проблему, добавив дополнительные проверки, когда пользователи комбинируют транспортные средства и двигатели во время выполнения, но для этого требуется небольшая хитрость, которую паттерн моста бесплатно не предоставляет. Мост также ограничивает пользователей, поскольку, несмотря на то, что существует общий базовый класс над всеми географическими пространствами (что означает, что пользователь может передать любой вид транспортного средства какVehicle&
), не существует общего базового класса выше, например, всех транспортных средств, работающих на газе, и, следовательно, пользователи не могут рассматривать любое транспортное средство, работающее на газе, какGasPoweredVehicle&
. Наконец, мост имеет то преимущество, что он имеет общий код для группы, например, водных транспортных средств, а также группы, например, транспортных средств, работающих на газе. Другими словами, различные транспортные средства, работающие на газе, имеют общий код в производном классеGasPoweredEngine
. - При вложенном обобщении вы выбираете одну из иерархий в качестве первичной, а другую в качестве вторичной, и у вас есть вложенная иерархия. Например, если вы выберете географическое пространство в качестве основной иерархии,
Vehicle
будет иметь производные классыLandVehicle
,WaterVehicle
и т.д., и каждый из них будет иметь дополнительные производные классы, по одному для каждого типа источника энергии. Например,LandVehicle
будет иметь производные классыGasPoweredLandVehicle
,PedalPoweredLandVehicle
,NuclearPoweredLandVehicle
и т.д.;WaterVehicle
будет иметь аналогичный набор производных классов и т.д. Это решение требует, чтобы вы написали примерно N×M различных производных классов, что означает, что при увеличении N или M рост не будет плавным, но это дает вам преимущество перед паттерном «мост» в том, что у вас может быть N×M различных алгоритмов и структур данных. Это также дает вам точный контроль, поскольку пользователь не может выбирать бессмысленные комбинации, такие как космические аппараты с педальным приводом, поскольку пользователь может выбирать только те комбинации, которые разработчик сочтет разумными. К сожалению, вложенное обобщение не решает проблемы с передачей любого транспортного средства, работающего на газе, в качестве общего базового класса, поскольку нет общего базового класса выше вторичной иерархии, например, нет базового классаGasPoweredVehicle
. И, наконец, не очевидно, как использовать общий код для всех транспортных средств, использующих один и тот же источник энергии, например, для всех транспортных средств, работающих на газе. - При множественном наследовании у вас есть две разные иерархии, как и у «моста», но вы удаляете
Engine*
из «моста» и вместо этого создаете примерно N×M производных классов под иерархией географических пространств и иерархией источников энергии. Это не так просто, поскольку вам нужно будет изменить концепцию классовEngine
. В частности, вы захотите переименовать классы в этой иерархии, например, сGasPoweredEngine
наGasPoweredVehicle
; плюс вам нужно будет внести соответствующие изменения в методы в иерархии. В любом случае классGasPoweredLandVehicle
будет множественно наследоваться отGasPoweredVehicle
иLandVehicle
, аналогичное множественное наследование будет и сGasPoweredWaterVehicle
,NuclearPoweredWaterVehicle
и т.д. Как и во вложенном обобщении, вам нужно написать примерно N×M классов, количество которых не растет плавно; но это решение дает вам детальный контроль над тем, какой алгоритм и структуры данных использовать в различных производных классах, а также то, какие комбинации считаются «разумными», то есть вы просто не создаете бессмысленных вариантов, таких какPedalPoweredSpaceVehicle
. Этот вариант решает проблему, присущую как паттерну «мост, так и вложенному обобщению, а именно позволяет пользователю передавать любое транспортное средство, работающее на газе, используя общий базовый класс. Наконец, этот вариант обеспечивает решение проблемы совместного использования кода, решение, которое, по крайней мере, не уступает решению с паттерном «мост»: оно позволяет всем транспортным средствам, работающим на газе, использовать общий код, когда это необходимо. Мы говорим, что это «по крайней мере, так же хорошо, как и решение с паттерном мост», поскольку, в отличие от моста, производные классы могут использовать общий код в транспортных средствах, работающих на газе, но также могут, в отличие от моста, переопределить и заменить этот код в случаях, где общий код не идеален.
Самый важный момент: не существует универсального «лучшего» решения. Возможно, вы надеялись, что я скажу вам всегда использовать тот или иной из вышеперечисленных вариантов. Я был бы счастлив, сделать это, за исключением одной незначительной детали: это было бы ложью. Если бы одно из вышеперечисленных решений всегда было лучше остальных, то «один размер подошел бы всем», а мы знаем, что это не так.
Итак, вот что вам нужно сделать: ПОДУМАЙТЕ. Вам нужно принять решение. Я дам вам несколько рекомендаций, но в конечном итоге вам придется решить, что лучше (или, возможно, «наименее плохо») в вашей ситуации.
Есть ли простой способ визуализировать все эти компромиссы?
Вот некоторые из «критериев добродетели», то есть качества, которые могут вам понадобиться. В этом описании N – это количество географических пространств, а M – количество источников энергии:
- Плавный рост: плавно ли увеличивается размер кода, когда вы добавляете новое географическое пространство или источник энергии? Если вы добавляете новое географическое пространство (переход от N к N+1), нужно ли вам добавить один новый фрагмент кода (отлично), M новых фрагментов кода (самое плохое) или что-то среднее?
- Небольшой объем кода: достаточно ли небольшой объем кода? Обычно он пропорционален затратам на обслуживание – чем больше кода, тем больше затраты при прочих равных условиях. Это также обычно связано с критерием «плавного роста»: помимо основной части кода собственно фреймворка, в лучшем случае будет N+M фрагментов кода, в худшем случае – N×M фрагментов кода.
- Детальный контроль: есть ли у вас детальный контроль над алгоритмами и структурами данных? Например, есть ли у вас возможность иметь другой алгоритм и/или структуру данных для любой из возможных N×M комбинаций, или вы застряли в использовании одного и того же алгоритма и/или структуры данных для всех, скажем, транспортных средств с газовым двигателем?
- Статическое обнаружение плохих комбинаций: можете ли вы статически («во время компиляции») обнаруживать и предотвращать недопустимые комбинации. Например, предположим, что на данный момент нет космических аппаратов с педальным приводом. Если кто-то попытается создать космический аппарат с педальным приводом, можно ли это обнаружить во время компиляции (хорошо), или нам нужно обнаружить это во время выполнения?
- Полиморфизм с обеих сторон: позволяет ли это решение пользователям обращаться к любому из базовых классов полиморфно? Другими словами, можете ли вы создать некоторый пользовательский код
f()
, который принимает любые, скажем, наземные транспортные средства (где вы можете добавить новый вид наземного транспортного средства, не требуя каких-либо изменений вf()
), а также создать другой пользовательский кодg()
, который принимает любые, скажем, транспортные средства, работающие на газе (где вы можете добавить новый тип транспортных средств, работающих на газе, не требуя каких-либо изменений вg()
)? - Совместное использование общего кода: позволяет ли это решение новым комбинациям использовать общий код с обеих сторон? Например, когда вы создаете новый тип наземного транспортного средства с газовым двигателем, может ли этот новый класс при желании использовать общий для многих транспортных средств с газовым двигателем код и при желании использовать общий код для многих наземных транспортных средств?
Следующая матрица показывает эти технологии в виде строк и «критерии качества» в виде столбцов. означает, что технология строки соответствует критериям качества столбца, «—» означает, что нет.
Плавный рост? | Небольшой объем кода? | Детальный контроль? | Статическое обнаружение плохих комбинаций? | Полиморфизм с обеих сторон? | Совместное использование общего кода? | |
---|---|---|---|---|---|---|
Паттерн «Мост» | (N+M фрагментов) | — | — | — | ||
Вложенное обобщение | — | — (N×M фрагментов) | — | — | ||
Множественное наследование | — | — (N×M фрагментов) |
Важно: не будьте наивны. Выбирая лучшее или наименее плохое решение, а не просто складывайте количество смайликов. ДУМАЙТЕ!
- Первый шаг – подумать, есть ли в вашей конкретной ситуации другие варианты проектирования, то есть дополнительные строки.
- Напомним, что строка «мост» на самом деле представляет собой пару строк – у него есть асимметрия, которая может идти в любом направлении. Другими словами, можно поместить
Engine*
вVehicle
илиVehicle*
вEngine
(или и то, и другое, или каким-либо другим способом их соединить, например, небольшой объект, содержащий толькоVehicle*
иEngine*
). - Подобные же комментарии для строки вложенного обобщения: на самом деле это пара строк, потому что она также имеет асимметрию, и эта асимметрия дает вам дополнительную возможность: вы можете сначала разложить по географическому признаку (земля, вода и т.д.) или сначала по источнику энергии (газ, атомная и др.). Эти два порядка разложения дают два разных дизайна с разными компромиссами.
- Напомним, что строка «мост» на самом деле представляет собой пару строк – у него есть асимметрия, которая может идти в любом направлении. Другими словами, можно поместить
- Второй шаг в использовании приведенной выше матрицы – подумать, какой столбец наиболее важен для вашей конкретной ситуации. Это позволит вам присвоить «вес» или «важность» каждому столбцу.
- Например, в вашей конкретной ситуации объем кода, который должен быть написан (второй столбец), может быть более или менее важным, чем детальный контроль над алгоритмами / структурами данных. Не увлекайтесь попытками выяснить, какой столбец более важен в некотором абстрактном, общем, универсальном взгляде на мир, потому что «один размер не подходит всем»!
- Вопрос: является ли объем кода (и, следовательно, стоимость обслуживания) более или менее важным, чем детальный контроль? Ответ: Да, объем кода (и, следовательно, затраты на обслуживание) более или менее важен, чем детальный контроль. Это шутка; расслабьтесь.
- А эта часть – не шутка: не доверяйте никому, кто думает, что знает, всегда ли объем кода (и, следовательно, затраты на обслуживание) более или менее важен, чем детальный контроль. Это невозможно узнать, пока вы не изучите все требования и ограничения в вашей конкретной ситуации! Слишком многие программисты думают, что знают ответ до того, как ознакомятся с ситуацией. Это хуже, чем глупо; это непрофессионально и опасно. Их универсальный ответ иногда бывает правильным. Их универсальный ответ мог быть правильным в каждом случае, который они когда-либо видели в своем ограниченном диапазоне опыта. Но если их прошлый успех мешает им задавать сложные вопросы в будущем, они представляют опасность для вашего проекта.
Ваш окончательный выбор должен быть сделан после выяснения, какой подход лучше всего подходит для вашей ситуации. Универсального решения нет – не ожидайте, что ответ в одном проекте будет таким же, как ответ в другом проекте. Если вы не будете осторожны, ваши прошлые успехи могут стать семенами ваших будущих неудач. То, что «это» было лучшим в вашем предыдущем проекте, не означает, что «это» будет лучшим в вашем следующем проекте.
Можете ли вы привести другой пример, чтобы проиллюстрировать вышеуказанные правила?
Этот второй пример лишь немного отличается от предыдущего, поскольку он очевидно более симметричен. Эта симметрия немного наклоняет чашу весов в сторону решения множественного наследования, но в некоторых ситуациях лучше всего подходит одно из других решений.
В этом примере у нас есть только две категории транспортных средств: наземные и водные. Потом кто-то указывает, что нам нужны автомобили-амфибии. Теперь мы переходим к хорошей части: к вопросам.
- Нужен ли нам вообще отдельный класс
AmphibiousVehicle
? Можно ли также использовать один из других классов с «битом», указывающим, что транспортное средство может находиться как в воде, так и на суше? Тот факт, что в «реальном мире» есть автомобили-амфибии, не означает, что нам нужно имитировать это в программном обеспечении. - Потребуется ли пользователям
LandVehicle
использоватьLandVehicle&
, которая ссылается на объектAmphibiousVehicle
? Нужно ли будет вызывать методы наLandVehicle&
и ожидать, что фактическая реализация этих методов будет специфичной («переопределенной») дляAmphibiousVehicle
? - То же самое и для водных транспортных средств: захотят ли пользователи
WaterVehicle&
, которая может ссылаться на объектAmphibiousVehicle
, и, в частности, вызывать методы по этой ссылке и ожидать, что реализация будет переопределенаAmphibiousVehicle
?
Если мы получим три ответа «да», то множественное наследование, вероятно, будет правильным выбором. Безусловно, вы должны задать и другие вопросы, например, проблему плавного роста, степень детализации управления и т.д.
Что такое «страшный алмаз» / «проблема ромба/алмаза»?
Термин «проблема ромба» относится к структуре классов, в которой в иерархии наследования класса какой-либо класс появляется более одного раза. Например,
class Base {
public:
// ...
protected:
int data_;
};
class Der1 : public Base { /*...*/ };
class Der2 : public Base { /*...*/ };
class Join : public Der1, public Der2 {
public:
void method()
{
data_ = 1; // Плохо: это двусмысленно; смотрите ниже
}
};
int main()
{
Join* j = new Join();
Base* b = j; // BПлохо: это двусмысленно; смотрите ниже
}
Простите за ASCII-арт, но иерархия наследования выглядит примерно так:
Base
/ \
/ \
/ \
Der1 Der2
\ /
\ /
\ /
Join
Прежде чем мы объясним, почему этот «страшный алмаз» вызывает страх, важно отметить, что C++ предоставляет методы, позволяющие справиться с каждым из «страхов». Другими словами, эту структуру часто называют «страшным алмазом», но на самом деле она не пугает; это больше просто то, о чем нужно знать.
Ключевой момент состоит в том, чтобы понять, что Base
наследуется дважды, что означает, что любые члены данных, объявленные в Base
, такие как data_
выше, будут дважды появляться в объекте Join
. Это может создать двусмысленность: какой из data_
вы хотите изменить? По той же причине неоднозначно преобразование из Join*
в Base*
или из Join&
в Base&
: подобъект какого класса Base
вам нужен?
C++ позволяет разрешить двусмысленность. Например, вместо того, чтобы говорить data_ = 1
, вы можете сказать Der2::data_ = 1
или преобразовать из Join*
в Der1*
, а затем в Base*
. Однако, пожалуйста, ПОЖАЛУЙСТА, подумайте перед этим. Это почти всегда не лучшее решение. Обычно лучшее решение – сообщить компилятору C++, что только один подобъект Base
должен отображаться в объекте Join
, а это описывается далее.
Где в иерархии следует использовать виртуальное наследование?
Чуть ниже вершины ромба, а не на стыковочном классе.
Чтобы избежать дублирования подобъекта базового класса, которое встречается в «проблеме ромба», вы должны использовать ключевое слово virtual
в части наследования классов, которые происходят непосредственно от вершины ромба:
class Base {
public:
// ...
protected:
int data_;
};
class Der1 : public virtual Base {
↑↑↑↑↑↑↑ // Вот это ключевое слово
public:
// ...
};
class Der2 : public virtual Base {
↑↑↑↑↑↑↑ // Вот это ключевое слово
public:
// ...
};
class Join : public Der1, public Der2 {
public:
void method()
{
data_ = 1; // Хорошо: это теперь недвусмысленно
}
};
int main()
{
Join* j = new Join();
Base* b = j; // Хорошо: это теперь недвусмысленно
}
Из-за ключевого слова virtual
в части базового класса Der1
и Der2
экземпляр Join
будет иметь только один подобъект Base
. Это устраняет двусмысленность. Обычно это лучше, чем использовать полную квалификацию, как описано в предыдущем ответе FAQ.
Необходимо акцентировать, что ключевое слово virtual
находится в иерархии выше Der1
и Der2
. Помещение ключевого слова virtual
в сам класс Join
не поможет. Другими словами, при создании классов Der1
и Der2
вы должны знать, что класс Join
будет существовать.
Base
/ \
/ \
virtual / \ virtual
Der1 Der2
\ /
\ /
\ /
Join
Что значит «делегировать сестринскому классу» через виртуальное наследование?
Рассмотрим следующий пример:
class Base {
public:
virtual void foo() = 0;
virtual void bar() = 0;
};
class Der1 : public virtual Base {
public:
virtual void foo();
};
void Der1::foo()
{ bar(); }
class Der2 : public virtual Base {
public:
virtual void bar();
};
class Join : public Der1, public Der2 {
public:
// ...
};
int main()
{
Join* p1 = new Join();
Der1* p2 = p1;
Base* p3 = p1;
p1->foo();
p2->foo();
p3->foo();
}
Хотите – верьте, хотите – нет, но когда Der1::foo()
вызывает this->bar()
, он в конечном итоге вызывает Der2::bar()
. Да, верно: класс, о котором Der1
ничего не знает, предоставит переопределение виртуальной функции, вызываемой Der1::foo()
. Это «перекрестное делегирование» может быть мощным методом настройки поведения полиморфных классов.
Что особенного мне нужно знать при использовании виртуального наследования?
Как правило, виртуальные базовые классы лучше всего подходят для использования, когда классы, производные от виртуального базового класса, и особенно сам виртуальный базовый класс, являются чисто абстрактными классами. Это означает, что классы выше «класса соединения» имеют очень мало данных или вообще не имеют их.
Примечание: даже если виртуальный базовый класс сам по себе является чисто абстрактным классом без членов данных, вы, вероятно, всё равно не захотите удалять виртуальное наследование внутри классов Der1
и Der2
. Для разрешения любых возникающих двусмысленностей вы можете использовать полностью определенные имена, однако адрес объекта будет несколько неоднозначен (в объекте Join
всё еще есть два подобъекта класса Base
), и такие простые вещи, как попытка определить, указывают ли два указателя на один и тот же экземпляр, могут быть сложными. Просто будьте осторожны – очень осторожны.
Что особенного мне нужно знать при наследовании от класса, использующего виртуальное наследование?
Список инициализации конструктора самого производного класса напрямую вызывает конструктор виртуального базового класса.
Поскольку подобъект виртуального базового класса встречается в экземпляре только один раз, существуют специальные правила, гарантирующие, что конструктор и деструктор виртуального базового класса вызываются для каждого экземпляра ровно один раз. Правила C++ говорят, что виртуальные базовые классы создаются раньше всех невиртуальных базовых классов. Вот что вам, как программисту, нужно знать: конструкторы виртуальных базовых классов в любом месте иерархии наследования вашего класса вызываются конструктором «самого производного» класса.
На практике это означает, что при создании конкретного класса, имеющего виртуальный базовый класс, вы должны быть готовы передать любые параметры, необходимые для вызова конструктора виртуального базового класса. И, конечно же, если где-либо в вашей иерархии имеется несколько виртуальных базовых классов, вы должны быть готовы вызвать все их конструкторы. Это может означать, что конструктору «самого производного» класса требуется больше параметров, чем вы могли бы подумать.
Однако если автор виртуального базового класса следовал рекомендациям из предыдущего ответа FAQ, то конструктор виртуального базового класса, вероятно, не принимает никаких параметров, так как у него нет данных для инициализации. Это означает, (к счастью!) авторам конкретных классов, которые в конечном итоге наследуются от виртуального базового класса, не нужно беспокоиться о передаче дополнительных параметров в конструктор виртуального базового класса.
Что особенного мне нужно знать, когда я использую класс, использующий виртуальное наследование?
Никаких преобразований типов в стиле C, вместо них используйте dynamic_cast
.
Еще раз: каков точный порядок конструкторов в ситуации множественного и/или виртуального наследования?
Самыми первыми выполняемыми конструкторами являются конструкторы виртуальных базовых классов в любом месте иерархии. Они выполняются в том порядке, в котором они появляются при обходе слева направо в глубину графа базовых классов, где фраза «слева направо» относится к порядку появления имен базовых классов.
После того, как все конструкторы виртуальных базовых классов завершены, порядок построения будет обычным – от базового класса к производному классу. Подробности легче всего понять, если представить, что самое первое, что компилятор делает в конструкторе производного класса, – это скрытый вызов конструкторов своих невиртуальных базовых классов (подсказка: именно так многие компиляторы на самом деле и поступают). Поэтому, если класс D
множественно наследуется от B1
и B2
, сначала выполняется конструктор для B1
, затем конструктор для B2
, а затем конструктор для D
. Это правило применяется рекурсивно; например, если B1
наследуется от B1a
и B1b
, а B2
наследуется от B2a
и B2b
, то окончательный порядок будет B1a
, B1b
, B1
, B2a
, B2b
, B2
, D
.
Обратите внимание, что порядок B1
, а затем B2
(или B1a
, затем B1b
) определяется порядком, в котором базовые классы появляются в объявлении класса, а не в том порядке, в котором инициализатор появляется в списке инициализации производного класса.
Каков точный порядок деструкторов в ситуации множественного и/или виртуального наследования?
Краткий ответ: полностью противоположный порядку конструкторов.
Развернутый ответ: предположим, что «самым производным» классом является D
, что означает, что фактический объект, который был первоначально создан, был класса D
, и что D
множественно (и не виртуально) наследуется от B1
и B2
. Первым запускается подобъект, соответствующий «самому производному» классу D
, за которым следуют деструкторы для его невиртуальных базовых классов в порядке, обратном объявлению. Таким образом, порядок деструкторов будет D
, B2
, B1
. Это правило применяется рекурсивно; например, если B1
наследуется от B1a
и B1b
, а B2
наследуется от B2a
и B2b
, окончательный порядок будет D
, B2
, B2b
, B2a
, B1
, B1b
, B1a
.
После того, как всё это закончено, обрабатываются виртуальные базовые классы, которые появляются где угодно в иерархии. Деструкторы для этих виртуальных базовых классов выполняются в порядке, обратном которому они появляются при обходе графа базовых классов слева направо в глубину, где фраза «слева направо» относится к порядку появления имен базовых классов. Например, если порядок обхода виртуальных базовых классов – это V1
, V1
, V1
, V2
, V1
, V2
, V2
, V1
, V3
, V1
, V2
, и уникальными являются V1
, V2
, V3
, то окончательный порядок будет следующим: D
, B2
, B2b
, B2a
, B1
, B1b
, B1a
, V3
, V2
, V1
.
Напоминаю, деструктор базового класса необходимо делать виртуальным, по крайней мере, в обычном случае. Если вы не до конца понимаете правила, почемувы делаете деструкторы базовых классов виртуальными, то либо изучите обоснование, либо просто поверьте мне и сделайте их виртуальными.