Как upcasting и vtables работают вместе, чтобы обеспечить правильную динамическую привязку?

Итак, vtable — это таблица, поддерживаемая компилятором, которая содержит указатели на функции, указывающие на виртуальные функции в этом классе.

и

Присвоение объекта производного класса объекту класса-предка называется восходящим преобразованием.

Восходящее приведение — это обработка экземпляра/объекта производного класса с использованием указателя или ссылки базового класса; объекты не «назначаются», что подразумевает перезапись значения ala operator= invocation.
(Спасибо: Tony D)

Теперь, как во время выполнения становится известно, "какая" виртуальная функция класса должна быть вызвана?

Какая запись в vtable относится к функции "конкретных" производных классов, которая должна вызываться во время выполнения?


person Aquarius_Girl    schedule 05.11.2014    source источник
comment
Я пытаюсь объяснить механизмы виртуальной отправки в своем ответе здесь. Re Присвоение объекта производного класса объекту класса-предка называется восходящим преобразованием. - нет - восходящее преобразование обрабатывает экземпляр/объект производного класса с использованием указателя или ссылки базового класса. ; объекты не назначены, что подразумевает перезапись значения аля operator= вызов.   -  person Tony Delroy    schedule 05.11.2014
comment
Поскольку мой ответ явно недостаточно хорош, я хотел бы понять, какая его часть НЕ отвечает на вопрос - я понимаю, что это может быть не самая лучшая иллюстрация, но я хотел бы понять, где я ошибаюсь. для дальнейшего использования.   -  person Mats Petersson    schedule 07.11.2014
comment
@MatsPetersson Я думал, что ты будешь думать так, как думал. :) На самом деле я ждал 2 дня. Ваш ответ не получил ни одного голоса, поэтому я не знал, был ли он полностью правильным или нет - я не эксперт в этой теме, поэтому я не был лучшим судьей - это одна из причин награды.   -  person Aquarius_Girl    schedule 07.11.2014
comment
Ну, технически, ответ может быть моей магией. В стандарте С++ не указано, как это работает, просто так оно и есть. Если вы можете реализовать это с помощью Magic(tm), то это действительная реализация. К сожалению, в большинстве случаев Магия на самом деле не работает, поэтому приходится полагаться на более утомительные методы...   -  person Mats Petersson    schedule 07.11.2014
comment
На эти темы есть отличная книга Inside C++ Object Model by Stanley-Lippman.   -  person Mantosh Kumar    schedule 07.11.2014


Ответы (9)


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

Итак, vtable — это таблица, поддерживаемая компилятором, которая содержит указатели на функции, указывающие на виртуальные функции в этом классе.

Точнее сказать это так:

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

Восходящее приведение — это обработка экземпляра/объекта производного класса с использованием указателя или ссылки базового класса; (...)

Возможно, более поучительно:

Восходящее приведение означает, что указатель или ссылка на экземпляр класса Derived обрабатывается так, как если бы он был указателем или ссылкой на экземпляр класса Base. Однако сам экземпляр по-прежнему является экземпляром Derived.

(Когда указатель «обрабатывается как указатель на Base», это означает, что компилятор генерирует код для работы с указателем на Base. Другими словами, компилятор и сгенерированный код не знают ничего лучше, чем то, что они имеют дело с указателем. к Base. Следовательно, указатель, который «обрабатывается как», должен указывать на объект, который предлагает по крайней мере тот же интерфейс, что и экземпляры Base. Это происходит в случае Derived из-за наследования. Мы увидим, как это работает ниже.)

На данный момент мы можем ответить на первую версию вашего вопроса.

Теперь, как во время выполнения становится известно, "какая" виртуальная функция класса должна быть вызвана?

Предположим, у нас есть указатель на экземпляр Derived. Сначала мы повышаем его преобразование, поэтому он обрабатывается как указатель на экземпляр Base. Затем мы вызываем виртуальный метод для нашего повышающего указателя. Поскольку компилятор знает, что метод является виртуальным, он знает, что нужно искать указатель виртуальной таблицы в экземпляре. Пока мы обрабатываем указатель так, как будто он указывает на экземпляр Base, фактический объект не изменил значение, а указатель виртуальной таблицы внутри него по-прежнему указывает на виртуальную таблицу Derived. Таким образом, во время выполнения адрес метода берется из виртуальной таблицы Derived.

Теперь конкретный метод может быть унаследован от Base или может быть переопределен в Derived. Это не имеет значения; при наследовании указатель метода в виртуальной таблице Derived просто содержит тот же адрес, что и соответствующий указатель метода в виртуальной таблице Base. Другими словами, обе таблицы указывают на одну и ту же реализацию метода для этого конкретного метода. В случае переопределения указатель метода в виртуальной таблице Derived отличается от соответствующего указателя метода в виртуальной таблице Base, поэтому поиск метода в экземплярах Derived найдет переопределенный метод, а поиск в экземплярах Base найдет исходную версию метода. метод — независимо от того, рассматривается ли указатель на экземпляр как указатель на Base или как указатель на Derived.

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

Какая запись в vtable относится к функции "конкретных" производных классов, которая должна вызываться во время выполнения?

Этот вопрос предполагает, что поиск vtable сначала выполняется по методу, а затем по классу. Наоборот: во-первых, указатель vtable в экземпляре используется для поиска vtable для нужного класса. Затем виртуальная таблица для этого класса используется для поиска правильного метода.

person Julian    schedule 08.11.2014
comment
Не могли бы вы разъяснить это: «Пока мы обрабатываем указатель так, как будто он указывает на экземпляр Base», как он обрабатывается как базовый указатель? Что это хотя бы значит? Я думаю, что это было бы ключом к моему пониманию концепции. - person Aquarius_Girl; 13.11.2014
comment
Я добавил пояснение в скобках в своем ответе. По сути, обработка A как B означает, что компилятор генерирует код для B, даже если на самом деле он может иметь дело с A. - person Julian; 13.11.2014
comment
Спасибо за хороший ответ. Нужно еще одно уточнение: compiler generate code for B Я хотел бы понять - вы говорите о распределениях памяти, которые происходили бы согласно B? Есть ли что-то большее, чем выделение памяти, когда компилятор генерирует код для B? И как компилятор узнает, что ему не нужно создавать новый указатель vtable для B? - person Aquarius_Girl; 15.11.2014
comment
Спасибо за комплимент, пожалуйста. Нет, выделение памяти никак не взаимодействует с динамической привязкой. Говоря о коде, сгенерированном компилятором, я имел в виду историю об использовании vtable для поиска правильного метода для экземпляра. Трюк с vtable применяется только к определяемым пользователем виртуальным методам, таким как классический пример Shape.draw(). К тому времени, когда ваша программа динамически связывает метод, экземпляр уже был выделен с его истинным типом, и к тому времени, когда он освобождается, у распределителя есть свои собственные средства, чтобы определить, что делать. - person Julian; 15.11.2014

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

Итак, если у нас есть такой класс:

class Base
{
  public:
     virtual void func1();
     virtual void func2(int x);
     virtual std::string func3();
     virtual ~Base();
   ... some other stuff we don't care about ... 
};

Затем компилятор создаст VTable примерно так:

struct VTable_Base
{
   int identifier;
   void (*func1)(Base* this);
   void (*func2)(Base* this, int x);
   std::string (*func3)(Base* this); 
   ~Base(Base *this);
};

Затем компилятор создаст внутреннюю структуру, что-то вроде этого (это невозможно скомпилировать как C++, это просто показать, что на самом деле делает компилятор, и я называю это Sbase, чтобы отличить фактическое class Base)

struct SBase
{
   VTable_Base* vtable;
   inline void func1(Base* this) { vtable->func1(this); }
   inline void func2(Base* this, int x) { vtable->func2(this, x); }
   inline std::string func3(Base* this) { return vtable->func3(this); }
   inline ~Base(Base* this) { vtable->~Base(this); }
};

Он также строит настоящую виртуальную таблицу:

VTable_Base vtable_base = 
{ 
   1234567, &Base::func1, &Base::func2, &Base::func3, &Base::~Base 
};

И в конструкторе для Base он установит vtable = vtable_base;.

Когда мы затем добавляем производный класс, в котором мы переопределяем одну функцию (и по умолчанию деструктор, даже если мы его не объявляем):

class Derived : public Base
{
    virtual void func2(int x) override; 
};

Теперь компилятор создаст такую ​​структуру:

struct VTable_Derived
{
   int identifier;
   void (*func1)(Base* this);
   void (*func2)(Base* this, int x);
   std::string (*func3)(Base* this); 
   ~Base(Derived *this);
};

а затем делает то же самое построение "структуры":

struct SDerived
{
   VTable_Derived* vtable;
   inline void func1(Base* this) { vtable->func1(this); }
   inline void func2(Base* this, int x) { vtable->func2(this, x); }
   inline std::string func3(Base* this) { return vtable->func3(this); }
   inline ~Derived(Derived* this) { vtable->~Derived(this); }
};

Нам нужна эта структура, когда мы используем Derived напрямую, а не через класс Base.

(Мы полагаемся на цепочку компилятора в ~Derived, чтобы вызвать ~Base также, как и обычные деструкторы, которые наследуются)

И, наконец, мы создаем настоящую виртуальную таблицу:

VTable_Derived vtable_derived = 
{ 
   7654339, &Base::func1, &Derived::func2, &Base::func3, &Derived::~Derived 
};

И снова конструктор Derived установит Dervied::vtable = vtable_derived для всех экземпляров.

Отредактируйте, чтобы ответить на вопрос в комментариях: компилятор должен тщательно разместить различные компоненты как в VTable_Derived, так и в SDerived так, чтобы они соответствовали VTable_Base и SBase, чтобы, когда у нас есть указатель на Base, Base::vtable и Base::funcN() соответствовали Derived::vtable и Derived::FuncN. Если это не совпадает, то наследование не будет работать.

Если к Derived добавляются новые виртуальные функции, они должны располагаться после функций, унаследованных от Base.

Завершить редактирование.

Итак, когда мы делаем:

Base* p = new Derived;

p->func2(); 

код будет искать SBase::Func2, который будет использовать правильный Derived::func2 (поскольку фактическое vtable внутри p->vtable равно VTable_Derived (как установлено конструктором Derived, который вызывается вместе с new Derived).

person Mats Petersson    schedule 05.11.2014
comment
Разве идентификатор в vtable не является избыточным? Я имею в виду, что вы можете идентифицировать значение указателя vtable, плюс это сохраняет уровень косвенности. - person dtech; 08.11.2014
comment
Хорошо, называя это идентификатором, это больше похоже на указатель на дополнительные данные об объекте. Но обычно в начале vtable есть какой-то не указатель на функцию. По крайней мере, в компиляторах, где я просматривал vtable. - person Mats Petersson; 08.11.2014
comment
Это имеет смысл - если вы собираетесь иметь дополнительные данные в vtable, вы хотели бы поместить их в единое место. Моя точка зрения заключалась в том, что расположение vtable в памяти достаточно id. Не смотрел, что находится внутри vtables производственных компиляторов. - person dtech; 08.11.2014
comment
Я понял ваш оставшийся ответ, за исключением этого утверждения: because the actual vtable inside p->vtable is VTable_Derived). Я не смог это понять. Пожалуйста, укажите, где в вашем ответе показано, что фактическая vtable внутри p->vtable — это VTable_Derived. Я упустил какой-то момент здесь. - person Aquarius_Girl; 13.11.2014
comment
Я добавил раздел, объясняющий, что SBase и SDerived должны быть эквивалентны (соответствовать друг другу), иначе это не сработает. - person Mats Petersson; 13.11.2014

Какая запись в vtable относится к функции «конкретных» производных классов, которая должна вызываться во время выполнения?

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

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

Как upcasting и vtables работают вместе, чтобы обеспечить правильную динамическую привязку?

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

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

Указатель, через который вы получаете доступ к объекту, не относится к правильному набору виртуальных функций для вызова, он просто служит индикатором того, к каким функциям в vtable вы можете обращаться как к разработчику. Вот почему можно безопасно повышать приведение, используя стиль C или статическое приведение, которое не выполняет проверки во время выполнения, потому что тогда вы ограничиваете свою калибровку только функциями, доступными в базовом классе, которые уже доступны в производном классе, поэтому существует нет права на ошибку и вред. И именно поэтому вы всегда должны использовать динамическое приведение или какой-либо другой пользовательский метод, все еще основанный на виртуальной диспетчеризации, когда вы выполняете понижающее приведение, потому что вы должны быть уверены, что связанная с объектом виртуальная таблица действительно содержит дополнительные функции, которые вы можете вызвать.

В противном случае вы получите неопределенное поведение, причем «плохого типа», то есть, скорее всего, произойдет что-то фатальное, поскольку интерпретировать произвольные данные как адрес вызываемой функции с определенной сигнатурой — очень большое нет-нет.

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

person dtech    schedule 08.11.2014

Полиморфизм и динамическая отправка (сокращенная версия)

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

Для интерпретации абстрактных типов и их поведения, видимого за пределами модуля, требуется общий двоичный интерфейс приложения (ABI). Стандарт C++, разумеется, не требует реализации какого-либо конкретного ABI.

ABI описывает:

  • Макет таблиц диспетчеризации виртуальных методов (vtables)
  • Метаданные, необходимые для проверки типов во время выполнения и операций приведения.
  • Name decoration (a.k.a. mangling), calling conventions, and many other things.

Предполагается, что оба модуля в следующем примере, external.so и main.o, связаны с одной и той же средой выполнения. Статическая и динамическая привязка отдает предпочтение символам, расположенным внутри вызывающего модуля.


Внешняя библиотека

external.h (распространяется среди пользователей):

class Base
{
    __vfptr_t __vfptr; // For exposition

public:

    __attribute__((dllimport)) virtual int Helpful();
    __attribute__((dllimport)) virtual ~Base();
};

class Derived : public Base
{
public:

    __attribute__((dllimport)) virtual int Helpful() override;

    ~Derived()
    {
        // Visible destructor logic here.


        // Note: This is in the header!


        // __vft@Base gets treated like any other imported symbol:
        // The address is resolved at load time.
        //
        this->__vfptr = &__vft@Base;
        static_cast<Base *>(this)->~Base();
    }
};

__attribute__((dllimport)) Derived *ReticulateSplines();

внешний.cpp:

#include "external.h" // the version in which the attributes are dllexport

__attribute__((dllexport)) int Base::Helpful()
{
    return 47;
}
__attribute__((dllexport)) Base::~Base()
{
}

__attribute__((dllexport)) int Derived::Helpful()
{
    return 4449;
}

__attribute__((dllexport)) Derived *ReticulateSplines()
{
    return new Derived(); // __vfptr = &__vft@Derived in external.so
}

external.so (не настоящий двоичный макет):

__vft@Base:
[offset to __type_info@Base] <-- in external.so
[offset to Base::~Base] <------- in external.so
[offset to Base::Helpful] <----- in external.so

__vft@Derived:
[offset to __type_info@Derived] <-- in external.so
[offset to Derived::~Derived] <---- in external.so
[offset to Derived::Helpful] <----- in external.so

Etc...

__type_info@Base:
[null base offset field]
[offset to mangled name]

__type_info@Derived:
[offset to __type_info@Base]
[offset to mangled name]

Etc...

Приложение, использующее внешнюю библиотеку

специальный.hpp:

#include <iostream>
#include "external.h"

class Special : public Base
{
public:

    int Helpful() override
    {
        return 55;
    }

    virtual void NotHelpful()
    {
        throw std::exception{"derp"};
    }
};

class MoreDerived : public Derived
{
public:

    int Helpful() override
    {
        return 21;
    }

    ~MoreDerived()
    {
        // Visible destructor logic here

        this->__vfptr = &__vft@Derived; // <- the version in main.o
        static_cast<Derived *>(this)->~Derived();
    }
};

class Related : public Base
{
public:

    virtual void AlsoHelpful() = 0;
};

class RelatedImpl : public Related
{
public:

    void AlsoHelpful() override
    {
        using namespace std;

        cout << "The time for action... Is now!" << endl;
    }
};

основной.cpp:

#include "special.hpp"

int main(int argc, char **argv)
{
    Base *ptr = new Base(); // ptr->__vfptr = &__vft@Base (in external.so)

    auto r = ptr->Helpful(); // calls "Base::Helpful" in external.so
    // r = 47

    delete ptr; // calls "Base::~Base" in external.so



    ptr = new Derived(); // ptr->__vfptr = &__vft@Derived (in main.o)

    r = ptr->Helpful(); // calls "Derived::Helpful" in external.so
    // r = 4449

    delete ptr; // calls "Derived::~Derived" in main.o



    ptr = ReticulateSplines(); // ptr->__vfptr = &__vft@Derived (in external.so)

    r = ptr->Helpful(); // calls "Derived::Helpful" in external.so
    // r = 4449

    delete ptr; // calls "Derived::~Derived" in external.so



    ptr = new Special(); // ptr->__vfptr = &__vft@Special (in main.o)

    r = ptr->Helpful(); // calls "Special::Helpful" in main.o
    // r = 55

    delete ptr; // calls "Base::~Base" in external.so



    ptr = new MoreDerived(); // ptr->__vfptr = & __vft@MoreDerived (in main.o)

    r = ptr->Helpful(); // calls "MoreDerived::Helpful" in main.o
    // r = 21

    delete ptr; // calls "MoreDerived::~MoreDerived" in main.o


    return 0;
}

основной.о:

__vft@Derived:
[offset to __type_info@Derivd] <-- in main.o
[offset to Derived::~Derived] <--- in main.o
[offset to Derived::Helpful] <---- stub that jumps to import table

__vft@Special:
[offset to __type_info@Special] <-- in main.o
[offset to Base::~Base] <---------- stub that jumps to import table
[offset to Special::Helpful] <----- in main.o
[offset to Special::NotHelpful] <-- in main.o

__vft@MoreDerived:
[offset to __type_info@MoreDerived] <---- in main.o
[offset to MoreDerived::~MoreDerived] <-- in main.o
[offset to MoreDerived::Helpful] <------- in main.o

__vft@Related:
[offset to __type_info@Related] <------ in main.o
[offset to Base::~Base] <-------------- stub that jumps to import table
[offset to Base::Helpful] <------------ stub that jumps to import table
[offset to Related::AlsoHelpful] <----- stub that throws PV exception

__vft@RelatedImpl:
[offset to __type_info@RelatedImpl] <--- in main.o
[offset to Base::~Base] <--------------- stub that jumps to import table
[offset to Base::Helpful] <------------- stub that jumps to import table
[offset to RelatedImpl::AlsoHelpful] <-- in main.o

Etc...

__type_info@Base:
[null base offset field]
[offset to mangled name]

__type_info@Derived:
[offset to __type_info@Base]
[offset to mangled name]

__type_info@Special:
[offset to __type_info@Base]
[offset to mangled name]

__type_info@MoreDerived:
[offset to __type_info@Derived]
[offset to mangled name]

__type_info@Related:
[offset to __type_info@Base]
[offset to mangled name]

__type_info@RelatedImpl:
[offset to __type_info@Related]
[offset to mangled name]

Etc...

Инвокация — это (а может и не быть) Магия!

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

Вызов динамического виртуального метода будет считывать адрес целевой функции из виртуальной таблицы, на которую указывает элемент __vfptr.

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

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

Base *ptr = new Special{};
MoreDerived *md_ptr = new MoreDerived{};

// The cast below is checked statically, which would
// be a problem if "ptr" weren't pointing to a Special.
//
Special *sptr = static_cast<Special *>(ptr);

// In this case, it is possible to
// prove that "ptr" could point only to
// a Special, binding statically.
//
ptr->Helpful();

// Due to the cast above, a compiler might not
// care to prove that the pointed-to type
// cannot be anything but a Special.
//
// The call below might proceed as follows:
//
// reg = sptr->__vptr[__index_of@Base::Helpful] = &Special::Helpful in main.o
//
// push sptr
// call reg
// pop
//
// This will indirectly call Special::Helpful.
//
sptr->Helpful();

// No cast required: LSP is satisfied.
ptr = md_ptr;

// Once again:
//
// reg = ptr->__vfptr[__index_of@Base::Helpful] = &MoreDerived::Helpful in main.o
//
// push ptr
// call reg
// pop
//
// This will indirectly call MoreDerived::Helpful
//
ptr->Helpful();

Приведенная выше логика одинакова для любого сайта вызова, требующего динамической привязки. В приведенном выше примере не имеет значения, на какой именно тип указывают ptr или sptr; код просто загрузит указатель с известным смещением, а затем вслепую вызовет его.


Приведение типов: взлеты и падения

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

Восходящее преобразование в этом простом ABI может выполняться полностью во время компиляции. Компилятору нужно только изучить иерархию типов, чтобы определить, связаны ли между собой исходный и целевой типы (в графе типов есть путь от исходного к целевому). По принципу подстановки указатель на MoreDerived также указывает на Base и может интерпретироваться как таковой. . Элемент __vfptr имеет одинаковое смещение для всех типов в этой иерархии, поэтому логике RTTI не нужно обрабатывать какие-либо особые случаи (в некоторых реализациях VMI потребуется получить другое смещение от преобразователя типа, чтобы получить другой vptr и скоро...).

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

Обратите внимание, что существует несколько экземпляров vtable для типа Derived: один в external.so и один в main.o. Это связано с тем, что виртуальный метод, определенный для Derived (его деструктора), появляется в каждой единице перевода, которая включает external.h.

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

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

Например:

Base *ptr = new MoreDerived();

// ptr->__vfptr = &__vft::MoreDerived in main.o
//
// This provides the code below with a starting point
// for dynamic cast graph traversals.

// All searches start with the type graph in the current image,
// then all other linked images, and so on...

// This example is not exhaustive!

// Starts by grabbing &__type_info@MoreDerived
// using the offset within __vft@MoreDerived resolved
// at load time.
//
// This is similar to a virtual method call: Just grab
// a pointer from a known offset within the table.
//
// Search path:
// __type_info@MoreDerived (match!)
//
auto *md_ptr = dynamic_cast<MoreDerived *>(ptr);

// Search path:
// __type_info@MoreDerived ->
// __type_info@Derived (match!)
//
auto *d_ptr = dynamic_cast<Derived *>(ptr);

// Search path:
// __type_info@MoreDerived ->
// __type_info@Derived ->
// __type_info@Base (no match)
//
// Did not find a path connecting RelatedImpl to MoreDerived.
//
// rptr will be nullptr
//
auto *rptr = dynamic_cast<RelatedImpl *>(ptr);

Ни в одном из приведенных выше кодов ptr->__vfptr не нужно было менять. Статическая природа вывода типа в C++ требует, чтобы реализация удовлетворяла принципу подстановки во время компиляции, а это означает, что фактический тип объекта не может измениться во время выполнения.


Резюме

Я понял этот вопрос как вопрос о механизмах динамической отправки.

Для меня вопрос "Какая запись в vtable относится к функции "конкретных" производных классов, которая должна вызываться во время выполнения?", задает вопрос о том, как работает vtable.

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

person defube    schedule 13.11.2014

приведение приведение — это концепция, связанная с переменной. Таким образом, любая переменная может быть приведена. Его можно бросать вверх или вниз.

char charVariable = 'A';
int intVariable = charVariable; // upcasting

int intVariable = 20;
char charVariale = intVariable; // downcasting 

для определенного системой типа данных Приведение вверх или вниз основано на вашей текущей переменной и в основном связано с тем, сколько памяти компилятор выделяет для обеих сравниваемых переменных.

Если вы назначаете переменную, которая выделяет меньше памяти, чем тип, в который выполняется преобразование, вызывается приведение.

Если вы назначаете переменную, которая выделяет больше памяти, чем тип, в который выполняется преобразование, это называется преобразованием вниз. Приведение вниз создает некоторые проблемы, когда значение, которое пытаются преобразовать, не помещается в выделенную область памяти.

Обновление на уровне класса Так же, как тип данных, определяемый системой, мы можем иметь объект базового класса и производного класса. Поэтому, если мы хотим преобразовать производный тип в базовый тип, это называется повышающим приведением. Это может быть достигнуто указателем базового класса, указывающим на тип производного класса.

class Base{
    public:
        void display(){
          cout<<"Inside Base::display()"<<endl;
    }
};
class Derived:public Base{
    public:
      void display(){
           cout<<"Inside Derived::display()"<<endl;
    }
};

int main(){
   Base *baseTypePointer = new Derived(); // Upcasting
   baseTypePointer.display();  // because we have upcasted we want the out put as Derived::display() as output

}

выход

Внутри Base::display()

Исключено

Внутри Derived::display()

В приведенном выше сценарии вывод не был исключением. Это потому, что у нас нет v-таблицы и vptr (виртуального указателя) в объекте, базовый указатель будет вызывать Base::display(), хотя мы присвоили производный тип базовому указателю.

Чтобы избежать этой проблемы, С++ дает нам виртуальную концепцию. Теперь функцию отображения базового класса нужно изменить на виртуальный тип.

virtual void display()

полный код:

class Base{
    public:
        virtual void display(){
          cout<<"Inside Base::display()"<<endl;
    }
};
class Derived:public Base{
    public:
      void display(){
           cout<<"Inside Derived::display()"<<endl;
    }
};

int main(){
   Base *baseTypePointer = new Derived(); // Upcasting
   baseTypePointer.display();  // because we have upcasted we want the out put as Derived::display() as output

}

выход

Внутри Derived::display()

Исключено

Внутри Derived::display()

Чтобы понять это, нам нужно понять v-table и vptr; когда когда-либо компилятор находит виртуальную вместе с функцией, он создает виртуальную таблицу для каждого из классов (как базовых, так и всех производных классов).

Если присутствует виртуальная функция, то каждый объект будет содержать vptr (виртуальный указатель), указывающий на соответствующий класс vtable, а vtable будет содержать указатель на соответствующую виртуальную функцию класса. когда вы вызовете функцию через vptr, будет вызвана виртуальная функция, которая вызовет соответствующую функцию класса, и мы получим требуемый результат.

введите здесь описание изображения

person Nihar    schedule 12.11.2014
comment
У вас есть выходные и ожидаемые результаты, в случае не-virtual вы не объясняете, почему они не совпадают. - person Jonathan Mee; 12.11.2014

Я считаю, что это лучше всего объясняется реализацией полиморфизма в C. Учитывая эти два класса C++:

class Foo {
    virtual void foo(int);
};

class Bar : public Foo {
    virtual void foo(int);
    virtual void bar(double);
};

определения структуры C (то есть заголовочный файл) будут выглядеть так:

//For class Foo
typedef struct Foo_vtable {
    void (*foo)(int);
} Foo_vtable;

typedef struct Foo {
    Foo_vtable* vtable;
} Foo;

//For class Bar
typedef struct Bar_vtable {
    Foo_vtable super;
    void (*bar)(double);
}

typedef struct Bar {
    Foo super;
} Bar;

Как видите, для каждого класса существует два определения структуры: одно для виртуальной таблицы и одно для самого класса. Также обратите внимание, что обе структуры для class Bar включают в себя объект базового класса в качестве своего первого члена, что позволяет нам повышать приведение: и (Foo*)myBarPointer, и (Foo_vtable*)myBar_vtablePointer допустимы. Таким образом, при наличии Foo* можно безопасно найти местонахождение члена foo(), выполнив

Foo* basePointer = ...;
(basePointer->vtable->foo)(7);

Теперь давайте посмотрим, как мы можем заполнить виртуальные таблицы. Для этого мы пишем некоторые конструкторы, которые используют некоторые статически определенные экземпляры vtable, вот как мог бы выглядеть файл foo.c

#include "..."

static void foo(int) {
    printf("Foo::foo() called\n");
}

Foo_vtable vtable = {
    .foo = &foo,
};

void Foo_construct(Foo* me) {
    me->vtable = vtable;
}

Это гарантирует возможность выполнения (basePointer->vtable->foo)(7) для каждого объекта, переданного в Foo_construct(). Теперь код для Bar очень похож:

#include "..."

static void foo(int) {
    printf("Bar::foo() called\n");
}

static void bar(double) {
    printf("Bar::bar() called\n");
}

Bar_vtable vtable = {
    .super = {
        .foo = &foo
    },
    .bar = &bar
};

void Bar_construct(Bar* me) {
    Foo_construct(&me->super);    //construct the base class.
    (me->vtable->foo)(7);    //This will print Foo::foo()
    me->vtable = vtable;
    (me->vtable->foo)(7);    //This will print Bar::foo()
}

Я использовал статические объявления для функций-членов, чтобы не придумывать новое имя для каждой реализации, static void foo(int) ограничивает видимость функции исходным файлом. Однако его по-прежнему можно вызывать из других файлов с помощью указателя на функцию.

Использование этих классов может выглядеть так:

#include "..."

int main() {
    //First construct two objects.
    Foo myFoo;
    Foo_construct(&myFoo);

    Bar myBar;
    Bar_construct(&myBar);

    //Now make some pointers.
    Foo* pointer1 = &myFoo, pointer2 = (Foo*)&myBar;
    Bar* pointer3 = &myBar;

    //And the calls:
    (pointer1->vtable->foo)(7);    //prints Foo::foo()
    (pointer2->vtable->foo)(7);    //prints Bar::foo()
    (pointer3->vtable->foo)(7);    //prints Bar::foo()
    (pointer3->vtable->bar)(7.0);  //prints Bar::bar()
}

Как только вы узнаете, как это работает, вы поймете, как работают виртуальные таблицы C++. Единственное отличие состоит в том, что в C++ компилятор выполняет ту же работу, что и я сам в приведенном выше коде.

person cmaster - reinstate monica    schedule 08.11.2014

Позвольте мне попытаться объяснить это на нескольких примерах:

class Base  
 {  
 public:  
    virtual void function1() {cout<<"Base :: function1()\n";};  
    virtual void function2() {cout<<"Base :: function2()\n";};  
    virtual ~Base(){};
};  

class D1: public Base  
{  
public:  
   ~D1(){};
   virtual void function1() { cout<<"D1 :: function1()\n";};
};  

class D2: public Base  
{  
public:  
   ~D2(){};
   virtual void function2() { cout<< "D2 :: function2\n";};  
}; 

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

ПРИМЕЧАНИЕ. Виртуальные таблицы содержат только указатели на виртуальные функции. Невиртуальные функции все равно будут разрешены во время компиляции...

Вы правы, говоря, что vtables — это не что иное, как указатели на функции. vtables для этих классов будет выглядеть примерно так: -

vtable для базы: -

&Base::function1 ();
&Base::function2 ();
&Base::~Base ();

vtable для D1: -

&D1::function1 ();
&Base::function2 ();
&D1::~D1();

vtable для D2: -

&Base::function1 ();
&D2::function2 ();
&D2::~D2 ();

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

Принимая во внимание все, если я вызову func, компилятор во время выполнения проверит, на что на самом деле указывает b: -

void func ( Base* b )
{
  b->function1 ();
  b->function2 ();
}

Допустим, у нас есть объект D1, переданный в func. Компилятор будет разрешать вызовы следующим образом: -

Сначала он извлекает vptr из объекта, а затем использует его для получения правильного адреса вызываемой функции. Итак, в этом случае vptr предоставит доступ к vtable D1. и когда он ищет функцию1, он получит адрес функции1, определенный в базовом классе. В случае вызова функции2 он получит адрес базовой функции2.

Надеюсь, я развеял ваши сомнения к вашему удовлетворению...

person ravi    schedule 08.11.2014

Реализация зависит от компилятора. Здесь я собираюсь высказать некоторые мысли, которые НИЧЕГО ОТНОСЯТСЯ НИ КАКИМ РЕАЛЬНЫМ ЗНАНИЯМ о том, как именно это делается в компиляторах, а просто с некоторыми минимальными требованиями, которые необходимы для того, чтобы работать как требуется. Имейте в виду, что каждый экземпляр класса с виртуальными методами во время выполнения знает, к какому классу он принадлежит.

Предположим, у нас есть цепочка базовых и производных классов длиной 10 (поэтому у производного класса есть пра-пра... пра-отец). Мы можем называть эти классы base0 base1 ... base9, где base9 происходит от base8 и т. д.

Каждый из этих классов определяет метод как: virtual void doit(){ ... }

Предположим, что в базовом классе мы используем этот метод внутри метода с именем «dowith_doit», который не переопределяется ни в одном производном классе. Семантика C++ подразумевает, что в зависимости от базового класса экземпляра, который у нас есть, мы должны применить к этому экземпляру «doit», определенный в базовом классе экземпляра.

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

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

Других простых способов для эффективной работы с виртуальными методами практически не существует.

Я предполагаю, что в реальных реализациях используется только второе решение (b), потому что компромисс между накладными расходами пространства, используемыми для несуществующих методов, по сравнению с эффективностью выполнения случая (b) благоприятен для случая b (принимая во внимание также, что методы ограничено по количеству - может быть 10 20 50, но не 5000).

person George Kourtis    schedule 09.11.2014

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

class Base {
hidden: // not part of the language, just to illustrate.
  static VDT baseVDT; // per class VDT for base
  VDT *vTable; // per object instance
private:
  ...
public:
  virtual int base1();
  virtual int base2();
  ...
};

vTable содержит указатели на все функции в Base.

Как скрытая часть конструктора Base vTable назначается baseVDT.

VDT Base::baseVDT[] = { 
  Base::base1, 
  Base::base2 
};

class Derived : public Base {
hidden:
  static VDT derivedVDT; // per class VDT for derived
private:
  ...
public:
  virtual int base2();
  ...
};

vTable для Derived содержит указатели на все функции, определенные в Base, за которыми следуют функции, определенные в Derived. Когда создаются объекты типа Derived, для vTable устанавливается значение производныйVDT.

VDT derived::derivedVDT[] = { 
  // functions first defined in Base
  Base::base1, 
  Derived::base2, // override
  // functions first defined in Derived are appended
  Derived::derived3 
}; // function 2 has an override in derived.

Теперь, если у нас есть

Base *bd    = new Derived;
Derived *dd = new Derived;
Base *bb    = new Base;

bd указывает на производный объект типа, vTable которого указывает на производный

Итак, функция вызывает

x = bd->base2();
y = bb->base2();

на самом деле

// "base2" here is the index into vTable for base2.
x = bd->vTable["base2"]();  // vTable points to derivedVDT
y = bb->vTable["base2"]();  // vTable points to baseVDT

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

Это также может быть реализовано как

// call absolute address to virtual dispatch function which calls the right base2.
x = Base::base2Dispatch(bd->vTable["base2"]); 

inline Base::base2Dispatch(void *call) {
  return call(); // call through function pointer.
}

Что с О2 или О3 будет одинаковым.


Есть несколько особых случаев:

dd указывает на производный или более производный объект, а base2 объявляется final, затем

z = dd->base2();

на самом деле

z = Derived::base2();  // absolute call to final method.

Если dd указывает на базовый объект или что-то еще, ваше поведение не определено, и компилятор все равно может это сделать.

Другой случай: если компилятор увидит, что есть только несколько производных классов от Base, он может сгенерировать интерфейс Oracle для base2. [бесплатно после компилятора MS или Intel на какой-то конференции C++ в 2012 или 2013 году? показывает, что (~ 500%?) больше кода дает (в 2+ раза?) ускорение в среднем]

inline Base::base2Dispatch(void *call) {
  if (call == Derived::base2)  // most likely from compilers static analysis or profiling.
    return Derived::base2(); // call absolute address
  if (call == Base::base2)
    return Base::base2(); // call absolute address

  //  Backup catch all solution in case of more derived classes
  return call(); // call through function pointer.
}

С какой стати вы хотите сделать это как компилятор??? больше кода - это плохо, ненужные ветки снижают производительность!

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

Получить адрес из памяти, 3+ цикла. Задержка конвейера при ожидании значения ip, 10 тактов, на некоторых процессорах 19+ тактов.

Если самые сложные современные процессоры могут предсказывать фактический адрес перехода [BTB] так же, как и предсказание ветвления, это может быть потерей. В противном случае ~8 дополнительных инструкций легко сохранят 4 * (3 + 10) инструкций, потерянных из-за остановок конвейера (если частота ошибок прогнозирования меньше 10-20%).

Если оба ветвления в двух "если" прогнозируются (т.е. оцениваются как "ложные"), ~2 потерянных цикла прекрасно покрываются задержкой памяти для получения адреса вызова, и мы не хуже.
Если одно из "если" неверно предсказано BTB, скорее всего, тоже ошибется. Тогда стоимость неправильных прогнозов составляет около 8 циклов, из которых 3 оплачиваются задержкой памяти, а правильное не принимает или 2-е, если может спасти положение, или мы платим полные 10+ остановок конвейера.
Если только 2 возможности существуют, одна из них будет взята, и мы спасем остановку конвейера от вызова указателя функции, и мы макс. получить один неверный прогноз, что не приведет к (значительному) ухудшению производительности по сравнению с прямым вызовом. Если задержка памяти больше, а результат предсказан правильно, эффект намного больше.

person Surt    schedule 10.11.2014