Дисклеймер.
Год от года темы этого раздела тасуются как попало. Я попытался их разбить логически (так, чтобы всё, имеющее отношение к виртуальным функциям, не было разделено на части, например), так что не удивляйтесь, если в вашем курсе темы будут в другом порядке разложены.
Наследование и виртуальные функции
В курсе предполагается, что что-то о наследовании вы знаете и примерно представляете, что это такое. Оно было в курсе Java, и это первая причина, почему мы не будем обсуждать всё в мельчайших деталях. Вторая — потому что в книжках оно обсасывается очень подробно.
Про наследование сложно говорить в том же ключе, в котором мы говорили/будем говорить об исключениях, шаблонах и т.п. Исключения и шаблоны решают конкретные задачи, а для наследования таковой нет. Поэтому на эту тему мы будем смотреть иначе: есть механизм, а на кой он нужен?
Lore.
Немного введения. Откуда идёт наследование? Если мы решаем задачу о моделировании дорожной сети, то первый порыв души — сопоставить объектам предметной области объекты нашей программы. Таким образом у нас очень естественно получается полиморфизм — у нас есть произвольные транспортные средства, которые очень похожи, а есть автобусы, трамваи и подобное, то есть более специализированные штуки.
Это не столько способ организации программы, сколько образ мысли. Причём полезный: мы можем начать декомпозировать задачу, даже не зная её решения. И, кстати, совсем необязательно объекту реальности сопоставлять объект программы, это может быть неэффективно — если мы решаем задачу о минимизации чего-то (за минимальные деньги перестроить дорогу так что...), то совершенно необязательно у вас будут такие же объекты.
А ещё бывает ситуация, когда вы придумываете движок регулярных выражений — никакие структуры из внешнего мира не приходят, вы делаете что-то, не имеющее отношения к реальности. И реальные программы где-то посередине: часть из реального мира, часть к нему не имеет отношения.
Итак, наследование.
struct vehicle {
std::string registration_number;
};
struct bus : vehicle { // Наследуется от `vehicle`.
int32_t route_number;
std::string next_stop() const;
};
struct truck : vehicle {
double cargo_mass;
};
int main() {
bus b;
vehicle& v = b; // Можно делать так.
v.registration_number = "123"; // Обращение к полям базового класса.
}
bus
содержит и route_number
, и registration_number
.
Когда вы обращаетесь к какому-то полю или методу, компилятор сначала ищет его внутри вашего класса, а если не находит — идёт в базовый. Поэтому если у базового и наследуемого класса есть поле с одним именем, обращение к нему вернём поле последнего. Если наследоваться от двух классов (в C++ есть множественное наследование), и в двух базовых есть поле/метод с одинаковым названием, а в наследнике нет, то будет ошибка компиляции при обращении от объекта наследника.
Если поля классов совпадают в названии, то обратиться к полю другого можно так:
struct base {
int xyz; // 1.
};
struct derived : base {
int xyz; // 2.
};
int main() {
derived d;
d.xyz = 123; // Изменяется 2.
((base&)d).xyz = 123; // Изменяется 1.
d.base::xyz = 123; // Специальный синтаксис для изменения 1.
}
Как можно видеть из примера, класс можно приводить к любому его базовому по ссылке. Указатель на наследуемый класс можно приводить к указателю на базовый.
С методами в наследовании работают точно так же, как и с полями:
struct base {
void g() {} // 1.
};
struct derived : base {
void g() {} // 2.
};
int main() {
derived d;
d.g(); // Запуск 2.
d.base::g(); // Запуск 1.
}
При создании объектов класса-наследника вызывается конструктор базового класса по умолчанию (в котором, если тот тоже от кого-то наследуется, вызывается конструктор его базы). Если нужно вызывать другой конструктор, используйте списки инициализации:
struct bus : vehicle {
bus(std::string const& registration_number, std::string const& next_stop_name)
: vehicle(registration_number), next_stop_name(next_stop_name){}
};
При этом стандарт определяет следующий порядок инициализации (перевод с cppreference):
- Инициализируются виртуальные базовые классы (про них будет потом) в порядке обхода в глубину. Внутри одного класса инициализация баз происходит в том порядке, в котором вы их написали (слева направо).
- Инициализируются прямые базовые классы тоже слева направо.
То есть если вы написалиclass C : B, A
, то сначала инициализируетсяB
, а потом —A
. - Инициализируются нестатические члены класса в порядке их объявления.
Worst practices.
Во-первых, не надо наследоваться, если вам нужно только расширить класс. Если вы хотите добавить новый функционал в std::string
, не нужно от него наследоваться, а то ваш коллега тоже захочет по-другому её расширить, и вы получите два новых типа, а потом не сможете вызывать функции друг друга. Лучше создайте обычную функцию. Обычные функции — это хорошо, не надо писать всё классами.
Во-вторых, не надо создавать отдельный класс под одну операцию:
struct string_printer {
private:
std::string msg;
public:
string_printer(const std::string& msg)
: msg(msg) {
}
void print() {
std::cout << msg;
}
};
Помимо того, что этот код и так выглядит как странное дизайнерское решение, у него есть вторая проблема — вот сделали вы string_printer("Hello, world").print()
. А что если вы не сделаете print
, или сделаете дважды? Потому что напечатать-то вы можете и в конструкторе, зачем вам метод? Не интуитивно.
Мораль: не заводите класс, если вам нужно сделать действие. Исключение — какие-нибудь компараторы, которые в общем случае могут быть полноценными классами, но вообще могут являться и обёрткой вокруг функции. И вот тут ничего не поделаешь, std::map
принимает класс, а не функцию.
Slicing.
Рассмотрим пример.
struct base
{
virtual std::string get_name() const
{
return "base";
}
};
struct derived : base
{
std::string get_name() const override
{
return "derived";
}
};
int main()
{
derived d;
base& b = d;
b.get_name(); // --> "derived"
}
Тут всё хорошо. А теперь представим, что в main
'е забыли поставить &
:
int main()
{
derived d;
base b = d;
b.get_name(); // --> "base" !!!
}
Что это всё значит?
Заметим, что эти два примера совсем разные: в первом создаётся ровно один объект и этот объект имеет тип derived
.
Во втором же случае у нас два разных объекта. Один типа derived
, а другой типа base
. Причём у второго объекта base
— это и статический, и динамический тип. Так что логично, что выводится "base".
А почему base b = d;
— не ошибка компиляции?
На самом деле внутри base
компилятор сгенерировал конструктор копирования.
И на самом деле у нас написано такое:
base b(static_cast<base const&>(d));
Так что тут у нас просто скопировались мемберы base
'а, а мемберы derived
'а потерялись.
Это называется Object slicing.
Как избежать непреднамеренного slicing'а?
Если для класса копирование не имеет смысла, то это стоит явно запретить:
base(base const& other) = delete;
base& operator=(base const& other) = delete;
Тогда случайного slicing'а не случится.
Кстати, у derived
'а удалять эти функции уже не обязательно, они не сгенерятся, если у base
'а удалены.
Вообще, часто копирование базового типа действительно не имеет смысла: в примере с vehicle
, car
и bus
не совсем понятно, что мы хотим сделать, копируя vehicle
. Мы скопировали какой-то абстрактый vehicle
, зачем нам это?
Виртуальные функции.
Виртуальные функции — главное (если не единственное), для чего вам нужно наследование.
Мы уже рассматривали код
struct vehicle {
void print() {
std::cout << "vehicle" << std::endl;
}
};
struct bus : vehicle {
void print() {
std::cout << "bus" << std::endl;
}
};
struct truck : vehicle {};
int main() {
vehicle v;
v.print(); // "vehicle".
bus b;
b.print(); // "bus".
truck t;
t.print(); // "vehicle".
}
При этом если мы напишем функцию
void foo(vehicle& v) {
v.print();
}
То даже если передать в неё bus
, она выведет "vehicle"
. Думаю, никому не надо объяснять, почему. Потому введём пару определений, а после поясним, как поправить код.
Статический тип — это тип объекта, который в программе написан. И у всего, что приходит в функцию foo
статический тип vehicle
(вообще vehicle&
, но не суть).
Динамический тип — то, чем ваш объект был создан и по сути является. Так, ваш vehicle
может являться bus
'ом или truck
'ом.
Так вот, виртуальные функции позволяют выбирать метод исходя из динамического типа, а не статического:
struct vehicle {
virtual void print() {
std::cout << "vehicle" << std::endl;
}
};
struct bus : vehicle {
void print() {
std::cout << "bus" << std::endl;
}
};
struct truck : vehicle {};
int main() {
vehicle v;
foo(v); // "vehicle"
bus b;
foo(b); // "bus"
truck t;
foo(t); // "vehicle"
}
Кстати, если у базового класса, функция виртуальная, то у производных тоже virtual
.
Когда вы подобным образом подменяете виртуальную функцию, говорят, что bus::print
override'ит vehicle::print
.
Виртуальный деструктор.
int main() {
bus* b = new bus();
vehicle* v = b;
v->print();
delete v;
}
Тут вызывается v->~vehicle
. А если bus
имеет какой-то нетривиальный деструктор, он не вызовется. Поэтому тут вам всё так же надо вызывать деструктор в зависимости от динамического типа, а не статического.
struct vehicle {
virtual ~vehicle() {}
};
С точки зрения языка, вы не имеете права делать delete
у базового класса, если создали наследуемый и не пометили деструктор базового как virtual
. Если будете так делать — UB. Даже если все деструкторы тривиальные.
Виртуальные функции и параметры по умолчанию.
Параметры по умолчанию являются частью декларации, поэтому соответствуют статическому типу, даже если указать другие в наследнике:
#include <string>
#include <iostream>
struct vehicle {
virtual void print_name(std::string prefix = "Base: "){
std::cout << prefix << "vehicle" << std::endl;
}
};
struct bus : vehicle {
void print_name(std::string prefix = "Derived: "){
std::cout << prefix << "bus" << std::endl;
}
};
void foo(vehicle& t){
t.print_name();
}
int main() {
bus b;
b.print_name(); // Derived: bus
foo(b); // Base: bus
}
Как полиморфизм устроен изнутри.
Как бы мы сделали полиморфизм руками, если бы у нас его не было? Ну, через указатели на функции. По-другому не получится, потому что мы не знаем список всех наших наследников:
struct base {
base();
void (*foo)(base*);
void (*bar)(base*, int);
void (*baz)(base*, double);
};
void foo_base(base* self) {
// ...
}
void bar_base(base* self, int x) {
// ...
}
void baz_base(base* self, double y) {
// ...
}
base::base()
: foo(foo_base), bar(bar_base), baz(baz_base) {}
struct derived : base {
derived();
};
void foo_derived(base* self) {
derived* derived_self = static_cast<derived*>(self);
// ...
}
void bar_derived(base* self, int x) {
derived* derived_self = static_cast<derived*>(self);
// ...
}
void baz_derived(base* self, double y) {
derived* derived_self = static_cast<derived*>(self);
// ...
}
derived::derived()
: foo(foo_derived), bar(bar_derived), baz(baz_derived) {}
int main() {
derived d;
d.bar(&d, 42);
}
Так в целом можно, но можно и чуть оптимальнее. У нас наши тройки функций не могут комбинироваться как им вздумается, всегда либо все из base
, либо все из derived
. И к тому же, нам не хочется с каждой новой функцией увеличивать размер структуры. Поэтому есть такая штука как таблица виртуальных функций. Это мы берём наши 3 указателя и выносим их в особый объект, указатель на который помещается в нашу структуру. А такие структуры создаем под каждый класс глобальными переменными. Это даёт нам ещё один indirection, но сокращает размер структур. И именно так это и работает во всех компиляторах. Во множественном наследовании у класса просто появляются две таблицы, под каждый базовый класс своя.
Абстрактные методы (и классы):
Вот создали вы, скажем, устройство вывода. И отнаследовались из него, создав устройство, которое пишет в одно место, в другое место и т.д. Возникает вопрос: а что должно делать базовое устройство? Ну, непонятно. Ничего путного. Для этого есть механизм чисто виртуальных (абстрактных) функций — пометить, что этой функции не существует:
struct output_device {
virtual void write(void const* data, size_t size) = 0;
};
struct speakers : output_device {};
struct twitch_stream : output_device {};
struct null_output : output_device {};
Чисто виртуальную функцию нельзя вызвать. И нельзя создать экземпляр класса, содержащего чисто виртуальную функцию.
Но есть проблема:
struct base {
base() {
foo();
}
void foo() {
bar();
}
virtual void bar() = 0;
};
struct derived : base {
void bar() {};
};
Тут, когда вы создадите derived
, вам на этапе исполнения скажут, что вы вызываете чисто виртуальную функцию. Почему?
А вот смотрите. Когда вы конструируете derived
, сначала вызывается конструктор base
, а только потом присваивается указатель на таблицу виртуальных функций. То есть когда вызывается конструктор объекта, он не сразу с правильным динамическим типом, а изменяется по чуть-чуть: сначала он базовый, а потом нормальный. Когда иерархия больше, динамический тип меняется большее количество раз.
Поэтому в конструкторе base::base
bar
— это функция без тела.
А ещё этот пример не компилируется без прослойки вида foo
. Почему так? А вот смотрите. Никто вам не мешает вызвать виртуальную функцию напрямую (обращения к таблице) так: base::foo
. А если вы вызываете функцию в конструкторе и деструкторе, то вы точно знаете, ваш динамический тип. Поэтому, написав код
struct base {
base() {
bar();
}
virtual void bar() = 0;
};
struct derived : base {
void bar() {};
};
Вы получите ошибку компиляции, потому что в base::base
происходит вызов base::bar
, а не просто bar
, а значит мы явно вызываем виртуальную функцию.
Множественное наследование.
Начнём с того, почему некорректно делать delete
указателю на базовый класс, если все деструкторы тривиальные, но не виртуальные:
struct base1 {
int x;
};
struct base2 {
int y;
};
struct derived : base1, base2 {};
int main() {
derived* d = new derived;
base2* b2 = d;
delete b2;
}
Потому что первый базовый класс лежит по тому же адресу, что и оригинальный класс, а второй — со смещением. Поэтому его удалить нельзя, вы освобождаете память не по тому указателю. А виртуальный деструктор вас спасёт.
Ещё про множественное наследование нужно сказать вот что:
struct base2;
struct derived;
base2& to_base2(derived& d) {
return (base2&)d;
}
struct base1 {
int x;
base1(int x) : x(x) {}
};
struct base2 {
int y;
base2(int y) : y(y) {}
};
struct derived : base1, base2
{
derived(int x, int y)
: base1(x), base2(y) {}
};
int main() {
derived d(1, 2);
std::cout << to_base2(d).y << std::endl; // Выводится 1.
}
Почему? А вот почему. Когда мы пишем to_base2
, мы ещё не знаем, что один класс наследуется от другого, причём так, что ещё и указатели надо двигать. Он будет их двигать, если написать to_base2
после классов, а так нет. Поэтому в C++ не надо использовать каст из C, вместо него есть 4 новых.
Приведение типов (cast).
Пример выше правится так:
base2& to_base2(derived& d) {
return static_cast<base2&>(d);
}
Если это будет написано в том же месте, то словим ошибку компиляции, а если после определения derived
, то проблем не будет.
Итак, cast
'ы:
-
static_cast
— то, что нам нужно в $99%$ случаев. Кастует- Числа друг в друга.
- Ссылки и указатели по иерархии наследования в любую сторону.
void*
в любой указатель и обратно.
При этом, понятно, кастовать
void*
куда-то корректно можно, если там изначально было то, куда вы кастуете.Аналогично, вниз по иерархии (от базового к наследуемому) можно кастовать только тогда, когда совпадает динамический тип. Иначе UB.
-
reinterpret_cast
— всё зашкварное из C-style cast'а. Перевод указателей несвязанных друг с другом типов, указателей в число и обратно. Простой и эффективный способ получить UB. В стандарте так и написано, это implementation-defined cast. Обратитесь к поставщику вашего компилятора, чтобы понять, как у вас работаетreinterpret_cast
. -
const_cast
. Снимает модификаторыconst
иvolatile
. Чаще всего это делать не надо, но иногда бывает нужно всё-таки. В стародавние времена, когдаconst
'ов не было, были функции, принимавшие указатель. Неконстантный, хотя не меняли его содержимое. И вот если вы хотите использовать эту функцию, вы можете снятьconst
с указателя. А вообще правило вот какое: если изначальный объект былconst
, снимать с негоconst
ни в коем случае нельзя (UB). Если изначальный объект константным не был, а потом вы сначала навесилиconst
, а потом сняли, то всё хорошо. -
dynamic_cast
. Немного другое, нежели все остальные касты. Работает для указателей и ссылок на полиморфные (хотя бы одна виртуальная функция) типы. Кастует по иерархии вниз (это, напомню, от базового к наследуемому), но, в отличие отstatic_cast
'а, проверяет, что преобразование корректно. Если некорректно, возвращаетnullptr
в случае указателей, кидает исключениеstd::bad_cast
в случае ссылок. Работает это при помощи RTTI:
RTTI. typeid
.
В таблицах виртуальных функций может храниться нечто другое, не только указатели на функции. В частности, в них хранится такая штука как RTTI — runtime type information. Это какая-то информация, которую компилятор вставляет в таблицу, чтобы понимать динамический тип. И к ней даже можно доступ получить. Для этого есть ключевое слово typeid
. Вы даёте ему объект, а он возвращает вам std::type_info const&
, который по сути и является RTTI.
Кстати, в большинстве компиляторов можно выключать RTTI (в GCC — ключ -fno-rtti), чтобы не тратить место в бинарном файле. И в каких-то кодовых базах можно увидеть код без dynamic_cast
'ов и typeid
.
Парочка полезных ключевых слов.
final
.
final
— нельзя наследовать. Либо нельзя наследовать класс, либо нельзя больше override'ить виртуальную функцию. Пишется так:
struct inderriveable final {
// ...
};
struct error1 : inderriveable {} // A `final` class cannot be used as a base class.
struct base {
virtual void foo() {}
};
struct derived : base {
void foo() final {}
};
struct error2 : derived {
void foo() {} // Cannot override `final` function `derived::foo`.
}
override
.
Явно указать, что вы override'ите виртуальную функцию, а не пишете что-то своё. Очень советуется это писать. Если кто-то изменит базовый класс, вы хотите явно видеть, что все функции
поломаются. Пишется в том же месте, где и final
.
protected
.
Представим, что мы пишем виджет на основе QT. Там есть базовый виджет, у которого есть операции, что делать в случае нажатия мышки, в случае перемещения колёсика и прочее подобное. Вам всё это нужно переопределить. В таком случае в базовом виджете используется ключевое слово protected
. Оно для похожих случаев и было создано, лол. Это модификатор доступа, дающий доступ только дочерним классам и себе.
С ним, правда, есть вопрос. Если метод не ломает инвариант, почему он не public
, а если ломает, то хотим ли мы давать доступ дочерним классам. Тем не менее эти вопросы не риторические, если вы нашли на них ответ — делайте protected
.
Ещё best practices.
Давайте дополним наш пример с виджетами выше. Вот есть у нас виджет, который знает, как его красить. И это виртуальная функция. Мы наследуемся, меняем функцию, всё хорошо. Но есть же второй вариант — создать отдельный класс, который отвечает за покраску, наследовать только его, и передавать этот объект в конструктор виджета. Это может быть очень полезно, если мы хотим, например, одинаково красить разные классы в разных местах. Более того, мы можем собирать наш виджет из кусочков. В QT, например, используется оба подхода. Реакция на мышку, на клавиатуру, перекраска и некоторые другие штуки обычно очень сильно связаны с самим классом, а какая-нибудь стилизация — уже что-то внешнее.
Однако надо понимать, что комбинируя кусочки, можно зайти так далеко, что вы будете складывать $2+2$, получая двойку из какого-то data_provider
'а, складывая каким-нибудь классом adder
и подобное. Не надо плодить фабрики непонятных классов. Когда вы делаете точку настройки, вы делаете ставку на то, что будете менять. Тут надо сильно думать. Более того, если вы сделали какие-то точки настройки, а расширять надо в другую сторону, то ваши точки настройки будут вам во вред, потому что вам надо будет их с новыми согласовывать.
Мораль: когда вы делаете фабрики/точки настройки/всё остальное, думайте, для чего вы это делаете.
Мем про квадрат и прямоугольник.
Как правильно наследоваться: квадрат от прямоугольника или прямоугольника от квадрата? (Для более детального понимания проблемы — смотрите лекцию по Java.)
Это зависит от того, что требуется от интерфейса. Давайте посмотрим, что требуется от этих фигур:
Квадрат | Прямоугольник | Оба |
---|---|---|
get_side | set_width | get_width |
set_height | get_height | |
set_side |
Если нам нужно всё из этого, то отнаследовать какую-либо фигуру от другой не получится. Но если у нас нет set_*
, то методы, специфичные для прямоугольника, резко пропадают, а значит его можно отнаследовать от квадрата.
Наследование против union
'а.
Мы же можем использовать наследование для той же цели, что и union
/std::variant
— выбирать из альтернативы. В случае с std::variant
мы даже можем проверять корректность обращения. Что же лучше?
Преимущества наследования | Преимущества union 'а |
---|---|
Если альтернативы разного размера, то union жрёт много памяти. | Наследование работает по указателю, а это даёт лишнюю индирекцию. |
Можно легко добавить новую альтернативу. | Можно легко добавить новую операцию. |
Модификаторы доступа наследования.
Как и у полей, у базовых классов можно указывать такие штуки как class A : public B
. Причём в случае приватного наследования, вы не только поля из B
не будете видеть в A
извне, у вас даже static_cast
не сработает. То есть наследование с модификаторами доступа скрывает/показывает сам факт наследования.
С какой целью это можно использовать — смотрите далее.
Виртуальное наследование.
struct A {
int x;
};
struct B : A {};
struct C : A {};
struct D : B, C {};
int main() {
D d;
d.x = 7; // Не работает, x is ambiguous.
d.B::x = 7; // Работает.
d.C::x = 7; // Работает.
}
Если две копии A
(а, следовательно, x
) — это то, что вы хотите, то хорошо. А иначе есть виртуальное наследование:
struct A {
int x;
};
struct B : virtual A {};
struct C : virtual A {};
struct D : B, C {};
int main() {
D d;
d.x = 7;
}
Если базовый класс помечен virtual
, то это значит, что он шарится с другими такими же виртуальными классами в иерархии. Для иерархии все virtual
базы склеиваются в один подобъект (subobject).
С методами это, кстати, работает точно также. Казалось бы, в чём проблема тут:
struct A {
void foo();
};
struct B : A {};
struct C : A {};
struct D : B, C {};
Ведь метод и вас по-любому один, зачем тут виртуальное наследование? А нифига, в этот метод надо передать this
типа A* const
, а таких у вас два, непонятно, какой брать. Поэтому тут тоже нужно виртуальное наследование.
Теперь посмотрим на вот такой пример:
struct A {
virtual void foo() = 0;
};
struct B : A {
void foo() override {}
};
struct C : A {};
struct D : B, C {};
int main() {
D d;
}
Тут понятно, в чём проблема. У D
есть два подобъекта типа A
, в одном из которых никто не за'override'ил foo
. Значит D
виртуальный класс. Значит его нельзя создать.
Это правится так:
struct A {
virtual void foo() = 0;
};
struct B : A {
void foo() override {}
};
struct C : A {
void foo() override {}
};
struct D : B, C {};
int main() {
D d;
}
И так тоже правится:
struct A {
virtual void foo() = 0;
};
struct B : virtual A {
void foo() override {}
};
struct C : virtual A {};
struct D : B, C {};
Подобъект типа A
у нас один, и его чисто виртуальная функция переопределена. Значит всё хорошо.
struct A {
virtual void foo() = 0;
};
struct B : virtual A {
void foo() override {}
};
struct C : virtual A {
void foo() override {}
};
struct D : B, C {};
int main() {
D d;
d.foo();
}
А так, понятно, нельзя, потому что у одного подобъекта мы два раза за'override'или одну и ту же функцию. И фиг знает, что использовать. Только если вы за'override'иде эту же функцию прямо в D
, то будет понятно, что использовать, и компилироваться исправно будет.
Описанным выше образом, кстати, работают вообще любые объекты одного имени. Т.е. переменные и обычные методы взаимодействуют с виртуальным наследованием также:
struct A {
int x;
};
struct B : virtual A {
int x;
};
struct C : virtual A {};
struct D : B, C {};
У вас будет две переменных x
, в D
по умолчанию будет использоваться B::x
.
Применение виртуального наследования.
С помощью виртуального наследования можно реализовывать интерфейсы по чуть-чуть. У нас есть абстрактный базовый класс, который умеет, там, рендериться, изменяться, что-то ещё делать, и мы можем override'ить одну его часть в одном классе, другую — в другом. А для сборки этих штук в одну придётся использовать виртуальное наследование:
struct game_object {
virtual void render() = 0;
virtual void update() = 0;
};
struct billbord_object : virtual game_object {
void render() override {/* ... */};
};
struct static_object : virtual game_object {
void update() override {/* ... */};
};
struct static_billboard : billbord_object, static_object
{};
Но это ещё цветочки, на самом деле. Представьте, что у вас есть какой-то публичный базовый класс (например, widget_painter
), и вы создаёте несколько похожих его наследников. А потом видите, что наследники похожи, их можно обобщить, и получить какую-то такую иерархию:
// Somewhere.h
struct widget_painter {
virtual void paint() {/* ... */}
};
// Your_file.h
struct my_base_painter : widget_painter {
void paint() override {/* ... */}
};
struct my_painter1 : my_base_painter {};
struct my_painter2 : my_base_painter {};
Но мы не хотим, чтобы кто-то приводил наши классы my_painter*
к my_base_painter
, это деталь реализации. Поэтому хочется написать
struct my_painter1 : private my_base_painter {};
Но это не сработает, потому что тогда никто снаружи и наследование от widget_painter
видеть не будет. Поэтому вот как надо:
// Вот сюда смотреть: vvvvvvv
struct my_base_painter : virtual widget_painter {
void paint() override {/* ... */}
};
// И сюда смотреть: vvvvvvv vvvvvvv
struct my_painter1 : private my_base_painter, virtual widget_painter {};
struct my_painter2 : private my_base_painter, virtual widget_painter {};
Мораль: не надо бояться виртуального наследования и пренебрегать им.
Виртуальное наследование изнутри.
Во что бы мы оттранслировали виртуальное наследование? Давайте вместо того, чтобы внутри объекта B хранить объект A, хранить указатель на него. Но если у нас много виртуальных баз, то хочется табличку. А табличку указателей на базы нельзя, у всех объектов типа D
эти указатели свои, в отличие от виртуальных методов.
Давайте хранить не указатель, а смещение до нужной виртуальной базы. И тогда в каждом объекте всё одинаковое, а значит можно объединить в табличку — табличку виртуальных баз.
А теперь пример мемы:
struct base {};
struct derived : virtual base {};
derived& test(base& b) {
return static_cast<derived&>(b);
}
Так вот это не компилируется, потому что мы совершенно не шарим, откуда в объекте b
взять смещение, которое хранится в derived
. Но зато можно так:
struct base {
virtual ~base() {}
};
struct derived : virtual base {};
derived& test(base& b) {
return dynamic_cast<derived&>(b);
}
Это, как ни странно, компилируется, потому что вы можете взять RTTI из b
, понять, что это derived
, и украсть смещение из таблицы виртуальных баз для него. Более того, с dynamic_cast
есть ещё больший мем:
struct A {
virtual void foo() {}
};
struct B {};
B& test(A& a) {
return dynamic_cast<B&>(a);
}
Как ни странно, это компилируется, более того, даже может быть корректно, если вы создадите класс
C
и отнаследуете его и от A
, и от B
.