Найти в Дзене
Драневич Анастасия

ООП для продолжающих. Как работает таблица виртуальных функций?

Если вы думаете, что будучи способным дать ответы на вопрос о том, что такое инкапсуляция, наследование и полиморфизм (а то и, не ровен час, и перечислить все принципы SOLID), вы в совершенстве овладели пониманием ООП, спешу вас разочаровать - в действительности, это гораздо более глубокая тема, таящая в себе множество подводных камней, которые, кстати говоря, вполне могут встретиться на реальном собеседовании. Из этой статьи вы узнаете о том, что такое таблица виртуальных функций, виртуальный указатель, откуда объект класса знает, какую именно реализацию переопределенной функции ему следует исполнить и - last, but not least - для чего вам, как разработчику, все это нужно. Предполагается, что к сему моменту читатель уже знаком с понятиями наследования, полиморфизма, переопределения функций и другими базовыми концепциями ООП, поэтому здесь я не собираюсь останавливаться на них чересчур подробно. Для чего нам вообще следует лезть столь глубоко и разбираться, что же там происходит "под к
Оглавление

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

Из этой статьи вы узнаете о том, что такое таблица виртуальных функций, виртуальный указатель, откуда объект класса знает, какую именно реализацию переопределенной функции ему следует исполнить и - last, but not least - для чего вам, как разработчику, все это нужно.

Актуальность темы

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

Для чего нам вообще следует лезть столь глубоко и разбираться, что же там происходит "под капотом"? Ответ на этот вопрос примерно такой же, как и на то, если бы вас спросили, почему вы выбрали С++, а не Python: чем более детально вы понимаете устройство системы, тем гибче вы можете настраивать её под себя, а также понимать причину возникновения того или иного рода ошибок. Концепция языка С++ не запрещает разработчику делать иррациональные вещи, давая доступ к довольно низкоуровневые компоненты, однако, также воскладывает на него ответственность за последствия таких операций. Более того, понимание устройства языка позволяет здраво оценивать все потенциальные уязвимости разрабатываемого кода, что, согласитесь, никогда не бывает лишним.

Теперь перейдем к непосредственной сути вопроса. Предположим, что у нас имеются некоторые классы A и B, причём В наследуется от А:

Что это означает с точки зрения ООП?

  • Во-первых, В является надмножеством А, т.е. В - "это А + ещё что-то" (наследование);
  • Во-вторых, объект B все ещё может рассматриваться как А (полиморфизм): условно говоря, если А - это "животные", а В - "котики", то мы все ещё можем справедливо утверждать, что котики являются животными, параллелограммы - геометрическими фигурами, а программисты - сотрудниками компании, хотя обратное будет не всегда верно: не всякое животное является котиком и т.д.
-2

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

Тем не менее, здесь остается один очень важный вопрос: а откуда, собственно, программа знает о том, что перед ней объект именно дочернего класса, коль скоро доступ к нему был получен через указатель родительского? Более того, если дочерний класс переопределяет методы родительского, откуда ей становится известно, какой метод следует использовать?

Вот тут-то в игру и вступают виртуальные таблицы - но обо всем по порядку.

Проблема с переопределением методов родительского класса

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

  • При создании указателя дочернего класса для объекта дочернего класса будет вызван метод, определённый в потомке;
  • При создании указателя родительского класса для объекта дочернего класса будет вызван метод, определённый в родителе.
-3

Почему? Да просто потому, что указатель родительского класса "видит" только "ядро", относящееся к A, но не его надмножество В. Более того, если мы попытаемся обратиться к одному из методов или полей, существующих только в В, указатель типа *А не позволит вам этого сделать!

Виртуальные функции

Для того, чтобы тонко намекнуть компилятору, что метод базового класса может оказаться не тем, что нам на самом деле нужно, т.е. переопределен в классе-потомке, к нему добавляется ключевое слово virtual:

-4

Теперь все гораздо больше походит на правду:

-5
Кстати говоря, стоит ли прописывать ключевое слово virtual в переопределениях метода в классе-потомке при "многоэтажном" наследовании? Строго говоря, делать этого не обязательно, т.к. все переопределения виртуальных функций также автоматически будут считаться виртуальными - то есть, в примере ниже объекты класса С также унаследуют виртуальность, т.е. смогут корректно переопределить версии метода, присутствующие в А и В - но все же такая практика считается хорошим тоном.

Стоит заметить, что в случае многоуровневой иерархии наследования для виртуальной функции всегда будет вызвана наиболее поздняя реализация метода, определённая в цепочке между самым базовым и рассматриваемым классами: так, в примере ниже для объектов С будет вызвана B::helloThere(), т.к. С не предоставил собственной реализации данного метода, а для D - D::helloThere() - просто потому, что у него такая реализация имеется.

-6

Функция main():

-7

Ключевое слово override

Также, для переопределенyых методов в классе-потомке можно добавить ключевое слово override - обратите внимание, что оно идёт уже после круглых скобок.

В чем состоит различие между virtual и override? Грубо говоря, модификатор  virtual объявляется в базовом классе, чтобы пометить метод как потенциально переопределяемый в производных классах, а override используется в производном классе для фактического переопределения этого виртуального метода или свойства, предоставляя новую реализацию. Иными словами, virtual разрешает, а override реализует переопределение функции.

Полностью эквивалентно предыдущему примеру, где и в родительском, и дочернем классах явно прописан override
Полностью эквивалентно предыдущему примеру, где и в родительском, и дочернем классах явно прописан override

Ключевое слово final

Если мы по какой-либо причине НЕ хотим, чтобы виртуальный метод класса переопределялся классами-потомками, к нему можно добавить ключевое слово final, разместив его там же, где и override:

-9

Пожалуйста, обратите внимание: метод родительского класса - одновременно и virtual, и final - использовать ключевое слово final без virtual применительно к функции нельзя.

Виртуальный деструктор

Одним из важных (хотя, пожалуй, не столь очевидных) практических кейсов в использовании виртуальных функций являются виртуальные деструкторы. В чем тут суть? Вернемся к прежнему примеру, где указатель родительского класса А* "смотрит" на экземпляр дочернего класса В.

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

-10

И все бы было замечательно вплоть до той поры, пока тип указателя совпадает с типом объекта: заменив значение указателя на А*, я получаю следующий результат:

-11

Что произошло? Дело в том, что указатель типа не видит деструктора дочернего класса точно также, как и других его методов, что уже было рассмотрено нами ранее.

Почему это плохо? Да потому,что память, отведенная под все объекты, относящиеся к В и не относящиеся к А, никогда не будет освобождена, банальная утечка памяти.

Лечится же эта проблема очень просто: как и в случае с обычными методами, достаточно пометить конструктор базового класса как virtual:

-12

Знакомьтесь, *_vptr

Давайте добавим в наш невиртуальный класс А пару дополнительных полей и посмотрим, какой объем памяти занимает каждое из них:

-13

В общем случае, размер класса — это сумма размеров всех нестатических данных-членов плюс выравнивание, если оно требуется. 4 байта на int, 8 байт на double, плюс 4 байта для кратности - итого, 16 байт. Пока что все сходится.

Однако, коль скоро мы добавим ключевое слово virtual, начинает происходить что-то странное:

-14

Откуда взялись дополнительные 8 байт? Дело в том, что в любом классе, содержащем виртуальные методы (неважно, переопределенные или объявленные впервые) присутствует скрытый указатель на таблицу виртуальных функций, или vtable, к которой мы наконец-то подошли.

Таблица виртуальных функций

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

Для каждого класса, объявляющего или переопределяющего виртуальную функцию, существует своя собственная таблица виртуальных функций, на которую, в свою очередь, смотрит *__vptr, также наследуемый классами-потомками и "перенаправляемый" на нужную vtable при вызове очередного конструктора (читатель может справедливо вспомнить о том, что порядок вызова конструкторов при наследовании идёт от самого базового до самого производного класса), благодаря чему мы можем быть уверены, что уж он-то всегда будет иметь нужное значение.

Условно говоря, при попытке создания объекта B сначала вызовется конструктор А(), инициализирующий значение *__vptr адресом vtable класса A, после чего будет вызван конструктор В(), переписывающий значение созданного ранее указателя так, чтобы он указывал на vtable для B - т.е., сам *__vptr объявляется еще на уровне самого вложенного класса и потому доступен из его области видимости несмотря на то, что впоследствии мы, действительно, сможем присвоить ему новое значение.
-15

Давайте попробуем разобраться, что вообще здесь творится. Когда мы вызываем метод того или иного класса, обладающего собственной виртуальной таблицей, тот сверяется со своей vtable через *__vptr относительно того, какую, собственно, из реализаций функции следует вызвать.

  • Если функция была переопределена и/или определена впервые внутри текущего класса, vtable будет содержать адрес именно этой реализации, т.е. управление возвращается обратно в тот же класс;
  • Если функция была унаследована без каких-либо последующих модификаций, vtable будет содержать адрес реализации внутри наиболее позднего потомка;
  • Если функция абстрактная, то в vtable будет записан nullptr.

Но что, если нам все-таки нужен метод родительского класса?

Хочется заметить, что такая ситуация встречается нечасто, однако, предположим, что она всё-таки произошла и нам во что бы то ни стало требуется вызвать метод родительского класса из объекта дочернего класса.

Возможно ли это? Да разумеется, ведь, с технической стороны вопроса, В уже содержит в себе все, относящееся к классу А. Для этого можно воспользоваться разрешением пространства имен:

-16

Резюме:

  • Понимание принципов работы виртуальных таблиц позволяет избегнуть "непредсказуемого" (контринтуитивного) поведения программы;
  • Для того, чтобы использовать методы дочернего класса через указатель родительского типа, следует пометить их как virtual в родительском классе;
  • Если функция не была переопределена классом-потомком, им будет унаследована наиболее поздняя из доступных реализаций;
  • Деструктор родительского класса при использовании полиморфизма должен быть помечен как virtual;
  • В любом классе, содержащем переопределенные или объявленные впервые виртуальные методы, присутствует скрытый указатель на нужную таблицу виртуальных функций.
  • В таблице виртуальных функций записываются адреса ячеек памяти, где хранится "правильная" реализация метода, по одной записи на функцию.
  • В случае, когда требуется вызвать виртуальный метод родительского класса из объекта дочернего типа, следует использовать разрешение пространства имен.

Благодарю за внимание!