Урок №167. Виртуальные таблицы

  Юрий  | 

  |

  Обновл. 15 Сен 2021  | 

 52523

 ǀ   14 

Для реализации виртуальных функций язык C++ использует специальную форму позднего связывания — виртуальные таблицы.

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

Виртуальная таблица в языке С++ — это таблица поиска функций для выполнения вызовов функций в режиме позднего (динамического) связывания. Виртуальную таблицу еще называют «vtable», «таблицей виртуальных функций» или «таблицей виртуальных методов».

Виртуальная таблица на самом деле довольно-таки проста, хотя её сложно описать словами.

Во-первых, любой класс, который использует виртуальные функции (или дочерний класс, родительский класс которого использует виртуальные функции), имеет свою собственную виртуальную таблицу. Это обычный статический массив, который создается компилятором во время компиляции. Виртуальная таблица содержит по одной записи на каждую виртуальную функцию, которая может быть вызвана объектами класса. Каждая запись в этой таблице — это указатель на функцию, указывающий на наиболее дочерний метод, доступный объекту этого класса.

Во-вторых, компилятор также добавляет скрытый указатель на родительский класс, который мы будем называть *__vptr. Этот указатель автоматически создается при создании объекта класса и указывает на виртуальную таблицу этого класса. В отличие от скрытого указателя *this, который фактически является параметром функции, используемым компилятором для «указания на самого себя», *__vptr является реальным указателем. Следовательно, размер каждого объекта увеличивается на размер этого указателя. *__vptr также наследуется дочерними классами.

Сейчас вы, скорее всего, немного удивлены и, возможно, задаетесь вопросом: «Как это всё вместе работает?». Поэтому давайте рассмотрим следующий простой пример:

Здесь у нас есть 3 класса, соответственно, компилятор создаст 3 виртуальные таблицы: одна для Parent, одна для C1 и одна для C2.

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

При создании объектов классов Parent, C1 или C2, *__vptr будет указывать на виртуальную таблицу класса Parent, C1 или C2 (соответственно).

Как заполняются виртуальные таблицы?


В примере, приведенном выше, у нас есть только две виртуальные функции, поэтому каждая виртуальная таблица будет иметь две записи (одна для function1() и одна для function2()). Помните, что при заполнении виртуальных таблиц выбираются наиболее дочерние методы, доступ к которым имеют объекты.

Виртуальная таблица для объектов класса Parent проста. Объект класса Parent имеет доступ только к членам класса Parent, он не имеет доступ к членам классов C1 и C2. Следовательно, запись function1 будет указывать на Parent::function1(), а запись function2 будет указывать на Parent::function2().

Виртуальная таблица для C1 уже немного сложнее. Объект класса C1 имеет доступ как к членам C1, так и к членам Parent. Однако C1 имеет переопределение function1(), что делает C1::function1() более дочерним методом, нежели Parent::function1(). Следовательно, запись function1 будет указывать на C1::function1(). C1 не переопределяет function2(), поэтому запись function2 остается указывать на Parent::function2().

В виртуальной таблице для C2 запись function1 будет указывать на Parent::function1(), а запись function2 будет указывать на C2::function2().

Смотрим:

Хотя здесь уже можно удивиться во второй раз, всё, на самом деле, очень просто: *__vptr каждого класса указывает на виртуальную таблицу этого же класса. Записи в виртуальной таблице указывают на наиболее дочерние методы (переопределения), доступ к которым имеют объекты.

Рассмотрим, что произойдет при создании объекта класса C1:

Поскольку c1 является объектом класса C1, то он имеет свой *__vptr, который указывает на виртуальную таблицу класса C1.

Теперь создадим указатель класса Parent на объект c1:

Поскольку cPtr является указателем класса Parent, то он указывает только на часть Parent объекта c1. Однако, *__vptr тоже находится в части Parent, поэтому cPtr имеет доступ к этому указателю. Наконец, cPtr->__vptr будет указывать на виртуальную таблицу C1, поскольку cPtr указывает на объект класса C1! Даже если cPtr является указателем класса Parent, он всё равно имеет доступ к виртуальной таблице C1.

Поэтому, что произойдет, если мы попытаемся вызвать cPtr->function1()?

Во-первых, компилятор распознает, что function1() является виртуальной функцией. Во-вторых, он будет использовать cPtr->__vptr для перехода к виртуальной таблице C1. В-третьих, он будет искать, какую версию function1() вызывать в виртуальной таблице C1. Он найдет C1::function1(). Следовательно, cPtr->function1() будет вызывать C1::function1()!

Теперь вы можете спросить: «А если бы cPtr указывал на объект класса Parent вместо объекта класса C1? Вызывал бы он по-прежнему C1::function1()?». Ответ: «Нет, не вызывал бы!».

В этом случае, при создании объекта p, *__vptr указывает на виртуальную таблицу класса Parent вместо C1. Следовательно, pPtr->__vptr также будет указывать на виртуальную таблицу класса Parent. Запись function1() в виртуальной таблице класса Parent будет указывать на Parent::function1(). Таким образом, pPtr->function1() будет вызывать Parent::function1(), который является наиболее дочерним методом, доступ к которому имеет объект p.

С помощью виртуальных таблиц компилятор и программа могут гарантировать, что вызовы функций будут вызывать соответствующие виртуальные функции/переопределения, даже если вы будете использовать только указатель или ссылку на родительский класс!

Вызов виртуальной функции происходит медленнее, чем вызов невиртуальной функции из-за следующего:

   Во-первых, мы должны использовать *__vptr для перехода к соответствующей виртуальной таблице.

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

   И только теперь мы сможем выполнить вызов функции.

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

Заключение

Любой класс, который использует виртуальные функции, имеет свой *__vptr, и размер каждого объекта этого класса увеличивается на размер этого указателя. Виртуальные функции мощные, но цена этому — производительность.


Оценить статью:

Звёзд: 1Звёзд: 2Звёзд: 3Звёзд: 4Звёзд: 5 (229 оценок, среднее: 4,90 из 5)
Загрузка...

Комментариев: 14

  1. Сергей:

    "Однако для современных компьютеров затраченное дополнительное время не является значительным."

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

    1. Vasily:

      Вызов виртуальной функции дороже на 2 прямого и на 1 косвенного вызова обычной функции. Это ерунда на фоне промахов кэша и предсказаний ветвлений.

       

  2. Артём:

    для случая, когда у родительского метода somemethod забыли слово virtual, а в наследнике он переопределен, когда: Parent* ptr = new Child;
    Какая тогда vtable у объекта? что в ней за метод somemethod? вызваться вроде должен родительский, при том, что согласно статье vtable будет child-овский

    1. Андрей:

      Если у Parent ни в одном из родителей метод somemethod еще не объявлялся как virtual, то этого метода нет в таблице витруальных методов класса Parent, а значит, и не появится в таблицах классов-наследников, пока какой-нибудь из наследников (например, Child) не переопределит его как virtual somemethod. В этом случае все наследники Child получат somemethod в таблицу виртуальных методов, даже если они будут переопределять somemethod без virtual.
      http://cpp.sh/72pzo

      1. Андрей:

        Это, кстати, поясняет другой мой комментарий здесь же, видимо, не очень четкий, т.к. как оказалось, понятный не для всех.

  3. Максим:

    "Во-первых, компилятор распознает, что function1() является виртуальной функцией. Во-вторых, он будет использовать cPtr->__vptr для перехода к виртуальной таблице C1. В-третьих, он будет искать, какую версию function1() вызывать в виртуальной таблице C1. Он найдет C1::function1()"
    "в-третьих, он будет искать…" тут не верно, потому что это происходит в рантайме (не компилятор ищет, а процессор, но он не ищет, а просто обращается по ГОТОВОМУ адресу). Но это мелочи, конечно. Спасибо за статью в любом случае!

  4. Анастасия:

    Мне кажется, что в определении " компилятор также добавляет скрытый указатель на родительский класс, который мы будем называть *__vptr. Этот указатель автоматически создаётся при создании объекта класса и указывает на виртуальную таблицу этого класса." есть противоречие — с одной стороны, он указывает на родительский класс, с другой — на виртуальную таблицу. Из остальной части урока я сделала вывод, что всё-таки это указатель на виртуальную таблицу, а не на родительский класс. Если одно другому не противоречит, то поясните, пожалуйста, почему?

    1. Анастасия:

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

      1. Андрей:

        Согласен. Формулировка в статье только путает. Попробую сформулировать свою версию. "…добавляет скрытый указатель на таблицу виртуальных методов, который мы будем называть *__vptr. Этот указатель создается в самом старшем из классов в иерархии наследования, которые имеют хотя бы одну виртуальную функцию, и во всех производных классах хранит указатель на виртуальные таблицы этих производных классов." Эта формулировка довольно громоздка, но, ИМХО, проясняет суть.

        1. Алексей:

          Неправильное определение изобрели, этот указатель создается для каждого ОБЪЕКТА, и указывает на виртуальную таблицу соответствующего КЛАССА (таблица есть у каждого класса, но с ними никаких хитрых полиморфных действий не производится, вся магия работает потому что в этих таблицах находятся указатели на нужные наиболее дочерние функции, все указатели заполняет компилятор.)
          Работает образно говря так:
          ОБЪЕКТ.__vptr->КЛАСС.ВТАБЛИЦА[i]->КЛАСС.ФУНКЦИЯ()
          i — индекс и количество виртуальных функций

        2. Андрей:

          Судя по комментарию, согласен, в своей формулировке мне не удалось то, на чем хотел сакцентировать внимание. То, что указатель будет в структуре каждого объекта дочернего класса, это и ужу понятно. В этой ветке обсуждается наследование этого указателя и когда этот указатель появляется в структуре данных КЛАССА, если следовать иерархии наследования от родительских классов к дочерним. Возможно, если заменить в предложенной мной формулировке "… создается в самом старшем …" на "… появляетсяется в структуре самого старшего …", это уменьшит количество вопросов (заметьте, я ни разу не упомянул ОБЪЕКТЫ классов, поэтому, хотя Ваше утверждение и разумно, оно немного "не туда").
          Хотя… все равно найдется кто-нибудь, кто докопается, потому что ему показалось, что речь идет именно об объектах.
          Прошу прощения за некропост. В ящик упало напоминание об этом обсуждении, заглянул и…

  5. Алексей:

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

  6. Максим:

    Почему на рисунке указано, что function1() класса С2 указывает на функцию Parent::function1() ? В предыдущих уроках было написано, что в случае отсутствия предопределенной функции, должен вызываться наиболее дочерний переопределенный метод, а им является C1::function1()

    1. Фото аватара Юрий:

      На рисунке имеется в виду, что, поскольку в классе C2 переопределения function1() нет, то вызываться будет Parent::function1() (класс С2 просто унаследует этот метод). C1::function1() вызываться не будет потому что класс С2 наследует Parent, а не C1. Существует связь Parent -> С2 (и есть еще связь Parent -> С1, но класс С1 никак не взаимодействует с классом С2).

Добавить комментарий

Ваш E-mail не будет опубликован. Обязательные поля помечены *