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

Я смотрю выступление BackToBasics: Virtual Dispatch and its Alternatives на CppCon2019. Докладчик говорит, и слайд показывает (при условии, что я правильно понял), что производный класс наследует указатель vtable от базового класса и, кроме того, имеет свой собственный vptr.

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

Спасибо

P.S. Просто для ясности: в этом контексте мы рассматриваем более распространенное открытое наследование, а не виртуальное или множественное наследование (докладчик явно упоминает об этом в предыдущей части выступления).


person CanISleepYet    schedule 20.11.2019    source источник


Ответы (2)


Виртуальная таблица содержит адрес каждой виртуальной функции для класса с известным смещением.

[Примечание: на практике, в отличие от обычного класса, vtables имеют элементы с отрицательным смещением, как указатель в середине массива. Это просто соглашение, которое не сильно меняет свободу реализации. В любом случае, единственная проблема заключается в том, что размещение информации в виртуальной таблице законодательно закреплено соглашением (ABI), и компиляторы, следуя одному и тому же, создают совместимый код для полиморфных классов.]

Что происходит, когда у вас есть дополнительные функции в производном классе? (не только функции, «унаследованные» от базового класса)

Как только вы примете идею о том, что указатель на структуру указывает как на весь объект, так и на его первый элемент, у вас появится представление о том, что указатель на производный класс указывает на базовый класс, который соответствующим образом расположен по нулевому смещению. Таким образом, вы можете иметь точно такое же значение указателя, представленное как void*, которое можно использовать в качестве альтернативы для производного объекта или базы в соответствии с этим соглашением для одиночного наследования.

Теперь вы можете применить это к любой структуре данных и даже к виртуальной таблице, которая на самом деле является не таблицей (массивом элементов одного типа или значений, которые можно интерпретировать одинаково), а записью (объектов несвязанного типа или значение); вы можете видеть, что виртуальная таблица для такого производного класса может быть получена из виртуальной таблицы его уникальной базы точно таким же образом.

(Обратите внимание, что если вы компилируете C++ в C, вы можете столкнуться с правилами псевдонимов типов, когда делаете такие вещи. Конечно, у сборки нет такой проблемы, как и у наивно скомпилированного «ассемблера высокого уровня» C.)

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

  • для элементов данных экземпляра (типа класса)
  • а для членов виртуальных функций это члены данных vtable (или члены метакласса, если вы его себе представляете).

Обратите внимание, что размещение базы с нулевым смещением позволяет разместить базу vtable с нулевым смещением, что, в свою очередь, позволяет использовать тот же vptr, но не подразумевает его; и наоборот, совместное использование vptr с базой подразумевает, что базовая vtable находится на нулевом смещении (макет vtable = уровень метакласса), поэтому база должна быть на нулевом смещении (макет элементов данных = уровень класса).

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

Как мы видим, все унаследованные полиморфные классы, кроме одного, располагаются с ненулевым смещением при множественном наследовании. Каждый несет дополнительный «унаследованный» vptr в производном классе; этот (скрытый) указатель должен быть правильно заполнен любым производным конструктором.

Эти дополнительные vptr предназначены для базовых классов, которые встречаются с ненулевым смещением, поэтому указатель на унаследованную базу необходимо скорректировать (добавьте положительную константу для преобразования в базовый указатель, удалите ее, чтобы преобразовать обратно). То, что компилятор должен создать код для выполнения неявного преобразования, является тривиальным замечанием (преобразование целого числа в тип с плавающей запятой — гораздо более сложная задача); но здесь преобразование this происходит между вызовом функции для данного базового типа и приземлением в функции, которая является переопределением в базовом или производном классе: разница в том, что настройка зависит от переопределения функции, которая известна только для class (экземпляр метатипа). Таким образом, vptr должен указывать на отдельную информацию vtable: ту, которая знает, как работать с этими преобразованиями базовых указателей в производные.

Как экземпляры «метатипа», vtables имеют всю информацию для автоматической настройки всех указателей. (Это зависит от конкретных задействованных типов классов и ни от каких других факторов.)

Итак, на уровне реализации есть два типа наследования:

  • наследование с нулевым смещением; совместное использование vptr; называется первичным базовым классом в некоторых описаниях vtable и ABI;
  • наследование произвольного смещения; наличие еще одного vptr; называется вторичным базовым классом.

Это для основных вещей. Виртуальное наследование гораздо более тонкое на уровне реализации, и даже концепция первичности не так ясна, поскольку виртуальные базы могут быть «первичными» производного класса только в некоторых других производных классах!

person curiousguy    schedule 20.11.2019
comment
Спасибо за ваш ответ. Я правильно понимаю, что вы говорите, что на каждую цепочку наследования будет один vptr. Итак, если ptr составляет 8 байтов, дополнительная стоимость памяти будет составлять 8 байтов для одной цепочки наследования (независимо от того, насколько глубокой), 16 байтов, если я выполняю множественное наследование из 2 цепочек и т. д.? - person CanISleepYet; 21.11.2019
comment
@CanISleepYet Это по одному на цепочку, включающую полиморфное основание; базы без виртуалов не учитываются; также подсчет цепочек отличается для виртуальных баз. 8 байт для одиночной цепочки наследования (независимо от глубины) Точная глубина не имеет значения размера. - person curiousguy; 21.11.2019
comment
Спасибо за разъяснения! Теперь я немного сбит с толку, потому что, как я понял другой ответ, кажется, что они противоречат друг другу? (немного в случае множественного наследования) - person CanISleepYet; 21.11.2019
comment
@CanISleepYet Я не думаю, что было противоречие, поскольку Дэвид Шварц говорил о ИМ. - person curiousguy; 21.11.2019

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

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

Типичные реализации справляются с этим, создавая таблицу виртуальных функций для OwnedVehicle, которая содержит одну часть для OwnedVehicle виртуальных функций (если есть), одну для Vehicle виртуальных функций и одну для Possession виртуальных функций. Затем при вызове виртуальной функции из указателя на объект другого типа все, что должен сделать компилятор, — это применить применимую дельту к указателю таблицы виртуальных функций, чтобы указать на правильную ее часть.

Хотя случай множественного наследования является более сложным, то же самое происходит и с единичным наследованием. Таблица виртуальных функций для OwnedVehicle содержит внутри себя таблицу виртуальных функций для Vehicle и будет иметь ее, даже если Possession не будет задействован.

person David Schwartz    schedule 20.11.2019
comment
Спасибо, что нашли время ответить. Правильно ли я понял из вашего ответа, что всегда будет только один указатель виртуальной таблицы, независимо от того, насколько глубока цепочка наследования и даже если задействовано множественное наследование. Компилятор просто создаст составную виртуальную таблицу для каждого уровня иерархии (где это требуется)? - person CanISleepYet; 21.11.2019
comment
Извиняюсь, прочитав другой ответ, я понял, что, вероятно, неправильно понял здесь часть о множественном наследовании. - person CanISleepYet; 21.11.2019
comment
@CanISleepYet Будет одна большая виртуальная таблица, потому что это то, что вам нужно для классов, производных от этого класса. Но большая виртуальная таблица будет состоять из нескольких меньших виртуальных таблиц, по одной для каждого класса, из которого этот класс является производным и который имеет хотя бы одну виртуальную функцию. - person David Schwartz; 21.11.2019
comment
@DavidSchwartz Согласны ли вы с тем, что концептуально существуют только небольшие виртуальные таблицы, а большая таблица — это просто совместное размещение без какого-либо существенного инварианта макета? - person curiousguy; 22.11.2019
comment
@curiousguy Нет. Если бы это было правдой, как бы вы произошли от класса? Каждый класс имеет макет виртуальной таблицы, которого должны придерживаться все производные классы. - person David Schwartz; 22.11.2019
comment
@DavidSchwartz Я согласен с тем, что каждая виртуальная таблица абстрактно представляет собой набор аксиом, которым должны следовать производные классы. Чего я не понимаю, так это вашей большой vtable и того, что она делает больше, чем маленькие vtables. - person curiousguy; 22.11.2019
comment
@curiousguy Большая виртуальная таблица используется для перехода к виртуальным функциям в этом классе. Маленькие переменные используются для перехода к виртуальным функциям в производных классах. Если что-то происходит от класса A, который является производным от B и C, должен быть экземпляр vtable B и vtable C для класса A. Если D происходит от A, ему нужна большая vtable (содержащая как B, так и C). vtables для D) для перехода к функциям, определенным в классе A). - person David Schwartz; 22.11.2019
comment
@DavidSchwartz Итак, вы говорите, что к виртуальным функциям всех баз можно получить доступ только с одного vptr? - person curiousguy; 22.11.2019
comment
@curiousguy Да, и это то, что передается виртуальной функции, определенной в этом самом классе. Таблица для виртуальной функции, определенной в одной из ее баз, может быть подмножеством этой большой таблицы. - person David Schwartz; 22.11.2019
comment
Давайте продолжим обсуждение в чате. - person curiousguy; 22.11.2019