Двигаемся поэтапно, с самого начала.
1. Никаких виртуальных функций (пока что)
Используем struct
вместо class
чтобы каждый раз не писать public
. В нашем примере не будет никаких приватных членов класса, а в остальном все тоже самое:
1 2 3 4 5 6 7 8 9 |
struct Parent { void f() {} // (1) }; struct Child : public Parent { void f() {} // (2) }; |
Функция f()
переопределена в дочернем классе Child
(overriding
, не путать с overloading
). Вызов функций:
1 2 3 4 5 |
Parent* p = new Parent; p->f(); // будет вызвана (1) Child* p = new Child; p->f(); // будет вызвана (2) |
Пока никаких неожиданностей нет — все работает как должно.
Замечу, что если в дочернем классе не будет функции (2), то будет вызвана функция (1) родительского класса. Это происходит потому, что Child
хранит гены родителя Parent
и в силу наследования имеет доступ ко всем полям родительского класса. Но это так, к слову.
2. Все еще никаких виртуальных функций
Сделаем странную вещь — присвоим указателю на объект родительского класса объект производного класса и поразмышляем над тем, что получилось.
1 2 |
Parent* p = new Child; p->f(); // будет вызвана (1) !!! |
Указатель на Parent
, а объект Child
… нет ли здесь несоответствия или противоречия?
Рассуждаем так. Указатель на объект класса — это не просто адрес в памяти. Когда мы имеем дело с классами, фактически имеем набор указателей на все поля и функции класса — это если упростить (а упрощать мы любим). Выше я говорил, что Child
хранит «гены» родителя Parent
, в нашем случае — функцию (1) родительского класса. Поэтому указатель на Parent
находит в Child
то, про что он знает — отсылку на свою собственную функцию (1).
При этом родительский класс не знает абсолютно ничего про остальные поля и функции, которые эксклюзивно находятся в Child
. И если последний содержит например функцию g()
, то компиляция p->g()
даст ошибку.
В ситуации наоборот
1 |
Child* p = new Parent; // ошибка компиляции |
Указатель захочет сослаться на поля, которые есть только в Child
, но в объекте Parent
их быть не может!
3. Подозрительная конструкция
Поехали дальше. Возникает закономерный вопрос: зачем вообще нужна такая конструкция
1 |
Parent* p = new Child; |
когда все прекрасно работает и без нее? Следствие, которое ведут Колобки, знает: встретили такую запись — значит где-то поблизости виртуальная функция (и может быть не одна).
И наоборот — видите объявление виртуальной функции, ищите в коде конструкцию подобного вида: она обязательно будет, потому что без нее объявлять функцию виртуальной нет смысла.
Поэтому смотрим пока на то что «нет смысла», а потом переходим к заключительному полноценному примеру.
4. Бессмысленная виртуальная функция
1 2 3 4 5 6 7 8 9 |
struct Parent { virtual void f() {} // (1) }; struct Child : public Parent { void f() override {} // (2) }; |
Теперь функция f()
стала виртуальной. Заведите хорошую привычку дописывать override
в дочерних классах, чтобы во-первых было понятно что функция виртуальная (это здесь все видно, а если вы копаетесь в многочисленных листингах?), и во-вторых это страхует вас от ошибки в сигнатуре функции — без override компилятор все пропустит, но будет считать f()
в дочернем классе уже не виртуальной, а с override
— предупредит.
Повторяем наш эксперимент с самого начала.
1 2 3 4 5 |
Parent* p = new Parent; p->f(); // будет вызвана (1) Child* p = new Child; p->f(); // будет вызвана (2) |
Вы заметили какую — нибудь разницу по сравнению с первым сценарием без виртуальных функций? И я тоже нет.
Давайте быстро забудем про это бессмысленное применение virtual
и перейдем к нашей сакраментальной конструкции, и тут она уже заиграет в полную силу.
5. Складываем все вместе
Объявление класса с виртуальными функциями:
1 2 3 4 5 6 7 8 9 |
struct Parent { virtual void f() {} // (1) }; struct Child : public Parent { void f() override {} // (2) }; |
Действие:
1 2 |
Parent* p = new Child; p->f(); // будет вызвана (2) !!! |
У меня для вас новость: объект родительского класса каким-то образом узнал про функцию в дочернем классе и вызвал именно ее. Как он это сделал?
Сейчас самое главное — не погрузиться в дебри новых понятий и не потерять прозрачность повествования. Новое понятие — это таблица виртуальных функций vtable
класса Parent
, которая содержит указатели на виртуальные функции. Отныне все вызовы виртуальных функций будут происходить не через указатель класса Parent* p
, а через посредника — таблицу vtable
.
Принципиально важным является то, что содержимое этой таблицы не задается жестко во время компиляции, а может меняться по ходу выполнения программы (это то, что вас на собеседовании спрашивали про позднее связывание или dynamic binding). Если мы поймем, как и когда эта таблица заполняется и меняется, мы поймем все про виртуальные функции. Поехали!
6. Gdb, vtbl
Ехать будем с помощью отладчика gdb — это наиболее удобный способ посмотреть расположение vtable
и виртуальных функций в памяти. Поменяем наш пример, чтобы с отладчиком было удобнее работать:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
#include <iostream> using namespace std; class Base { public: virtual void base_and_derived(void) { cout << "Base::f" << endl; } virtual void base(void) { cout << "Base::fonly" << endl; } }; class Derived : public Base { public: void base_and_derived(void) override { cout << "Derived::f" << endl; } virtual void derived(void) { cout << "Derived::v" << endl; } }; int main (int argh, char *argv[]) { Base* b = new Base(); Derived* d = new Derived(); b = d; return 0; } |
Компилируемся, запускаем отладку:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
(gdb) b main Breakpoint 1 at 0x11dd: file faddr.cpp, line 21. (gdb) run Starting program: /home/***/faddr [Thread debugging using libthread_db enabled] Using host libthread_db library "/lib/x86_64-linux-gnu/libthread_db.so.1". Breakpoint 1, main (argh=1, argv=0x7fffffffdfc8) at faddr.cpp:21 21 b = new Base(); (gdb) n 23 d = new Derived(); (gdb) n 24 b = d; (gdb) info vtbl b vtable for 'Base' @ 0x555555557d50 (subobject @ 0x55555556aeb0): [0]: 0x5555555552a0 <Base::base_and_derived()> [1]: 0x5555555552de <Base::base()> (gdb) info vtbl d vtable for 'Derived' @ 0x555555557d28 (subobject @ 0x55555556aed0): [0]: 0x55555555531c <Derived::base_and_derived()> [1]: 0x5555555552de <Base::base()> [2]: 0x55555555535a <Derived::derived()> |
Замечу что b = d
еще не выполнялось: мы остановились до этой строчки.
Для успокоения души проверим адреса, которые нам выдал отладчик, например для b
:
1 2 |
(gdb) x /2xg 0x555555557d50 0x555555557d50 <_ZTV4Base+16>: 0x00005555555552a0 0x00005555555552de |
Все в порядке, vtable
содержит адреса Base::base_and_derived()
и Base::base()
(доверяй, но проверяй!)
Теперь посмотрим на таблицы, на какие виртуальные функции они указывают. С классом Base
все понятно, это пара собственных виртуальных функций base_and_derived(), base()
. С дочерним классом Derived
немного интереснее: его таблица содержит переопределенную виртуальную функцию базового класса base_and_derived()
, собственно виртуальную функцию base()
которая принадлежит базовому классу (родитель!) и собственная виртуальная derived()
.
Само собой, адреса виртуальных таблиц 0x555555557d50
и 0x555555557d28
различаются, поскольку у каждого класса (не экземпляра класса!) своя виртуальная таблица. Это замечание пригодится нам прямо сейчас.
Продолжаем программу и выполняем наше легендарное присвоение указателя производного класса указателю базового класса:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
(gdb) n 26 return 0; (gdb) info vtbl b vtable for 'Base' @ 0x555555557d28 (subobject @ 0x55555556aed0): [0]: 0x55555555531c <Derived::base_and_derived()> [1]: 0x5555555552de <Base::base()> (gdb) info vtbl d vtable for 'Derived' @ 0x555555557d28 (subobject @ 0x55555556aed0): [0]: 0x55555555531c <Derived::base_and_derived()> [1]: 0x5555555552de <Base::base()> [2]: 0x55555555535a <Derived::derived()> |
Произошли существенные изменения, и главное из них — мы фактически потеряли виртуальную таблицу базового класса (что еще можно было ожидать от присваивания?) Точнее, базовый и производный класс сейчас содержат только одну виртуальную таблицу расположенную по адресу 0x555555557d28
, которая по сути таблица класса Derived
оставленная без изменений —.
Поскольку класс Base
теперь пользуется виртуальной таблицей класса Derived
, предыдущая запись
0x5555555552a0 <Base::base_and_derived()>
Поменялась на запись
0x5555555552a0 <Base::base_and_derived()>
какой она и была для Derived
раньше.
Это означает: все вызовы виртуальной функции base_and_derived
выполненные с помощью указателя b->base_and_derived()
приведут не к вызову виртуальной функции базового класса (как можно было ожидать, ведь b
— указатель на Base
), а к вызову виртуальной функции произвольного класса.
Что и требовалось доказать.
7. Послесловие
Вот и все. Приведу пример, когда это бывает нужно в реальной жизни. Например, клиент знает только про класс Interface
и пользуется им:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
struct Interface { virtual int api() {} // client interface }; // то что ниже - клиенту знать не обязательно struct Implementation_1 : public Parent { int api() override {} // api version 1 }; struct Implementation_2 : public Parent { int api() override {} // api version 2 }; |
У нас есть возможность подсовывать различные варианты реализации интерфейса, оставляя клиента в неведении:
1 2 3 4 5 6 7 |
Interface* if; if = new Implementation_1; if->api(); // будет вызвана версия 1 api if = new Implementation_2; if->api(); // будет вызвана версия 2 api |
Этот пример можно воспринимать по другому: например, Implementation_1
это боевая реализация, а Implementation_2
это mock
заглушка для тестирования. Или Interface
это класс прокси или адаптера. В общем все шаблоны C++ которые только существуют базируются на этой записи:
1 |
Parent* p = new Child; |
и это все, что вам нужно знать про полиморфизм в C++.
Ответить