Двигаемся поэтапно, с самого начала.
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). Если мы поймем, как и когда эта таблица заполняется и меняется, мы поймем все про виртуальные функции. Поехали!
1 2 3 4 5 6 7 8 9 10 11 12 13 |
Parent* p; // vtable пуста. Указатель p указывает на vtable класса Parent p = new Child; // поиск всех виртуальных функций в наследнике Child // в нашем случае найден адрес f() в новом объекте класса Child // этот адрес помещается в vtable в строку указателя на f(), схематично так: // Parent::f() -> Child::f() p->f(); // указатель p все также указывает на vtable. В vtable ищется запись Parent::f() // и из этой записи извлекается адрес Child::f(), // после чего по этому адресу вызывается функция дочернего класса |
Послесловие
Вот и все. Приведу пример, когда это бывает нужно в реальной жизни. Например, клиент знает только про класс 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++.
Ответить